Skip to main content

datasynth_generators/master_data/
asset_generator.rs

1//! Fixed asset generator for asset master data with depreciation schedules.
2
3use chrono::NaiveDate;
4use datasynth_core::models::{
5    AssetAccountDetermination, AssetClass, AssetStatus, DepreciationMethod, FixedAsset,
6    FixedAssetPool,
7};
8use datasynth_core::utils::seeded_rng;
9use rand::prelude::*;
10use rand_chacha::ChaCha8Rng;
11use rust_decimal::Decimal;
12use tracing::debug;
13
14use crate::coa_generator::CoAFramework;
15
16/// Configuration for asset generation.
17#[derive(Debug, Clone)]
18pub struct AssetGeneratorConfig {
19    /// Distribution of asset classes (class, probability)
20    pub asset_class_distribution: Vec<(AssetClass, f64)>,
21    /// Distribution of depreciation methods (method, probability)
22    pub depreciation_method_distribution: Vec<(DepreciationMethod, f64)>,
23    /// Default useful life by asset class (class, months)
24    pub useful_life_by_class: Vec<(AssetClass, u32)>,
25    /// Acquisition cost range (min, max)
26    pub acquisition_cost_range: (Decimal, Decimal),
27    /// Salvage value percentage (typically 5-10%)
28    pub salvage_value_percent: f64,
29    /// Probability of asset being fully depreciated
30    pub fully_depreciated_rate: f64,
31    /// Probability of asset being disposed
32    pub disposed_rate: f64,
33}
34
35impl Default for AssetGeneratorConfig {
36    fn default() -> Self {
37        Self {
38            asset_class_distribution: vec![
39                (AssetClass::Buildings, 0.05),
40                (AssetClass::Machinery, 0.25),
41                (AssetClass::Vehicles, 0.15),
42                (AssetClass::Furniture, 0.15),
43                (AssetClass::ItEquipment, 0.25),
44                (AssetClass::Software, 0.10),
45                (AssetClass::LeaseholdImprovements, 0.05),
46            ],
47            depreciation_method_distribution: vec![
48                (DepreciationMethod::StraightLine, 0.70),
49                (DepreciationMethod::DoubleDecliningBalance, 0.15),
50                (DepreciationMethod::Macrs, 0.10),
51                (DepreciationMethod::SumOfYearsDigits, 0.05),
52            ],
53            useful_life_by_class: vec![
54                (AssetClass::Buildings, 480),             // 40 years
55                (AssetClass::BuildingImprovements, 180),  // 15 years
56                (AssetClass::Machinery, 84),              // 7 years
57                (AssetClass::Vehicles, 60),               // 5 years
58                (AssetClass::Furniture, 84),              // 7 years
59                (AssetClass::ItEquipment, 36),            // 3 years
60                (AssetClass::Software, 36),               // 3 years
61                (AssetClass::LeaseholdImprovements, 120), // 10 years
62                (AssetClass::Land, 0),                    // Not depreciated
63                (AssetClass::ConstructionInProgress, 0),  // Not depreciated
64            ],
65            acquisition_cost_range: (Decimal::from(1_000), Decimal::from(500_000)),
66            salvage_value_percent: 0.05,
67            fully_depreciated_rate: 0.10,
68            disposed_rate: 0.02,
69        }
70    }
71}
72
73/// Asset description templates by class.
74const ASSET_DESCRIPTIONS: &[(AssetClass, &[&str])] = &[
75    (
76        AssetClass::Buildings,
77        &[
78            "Corporate Office Building",
79            "Manufacturing Facility",
80            "Warehouse Complex",
81            "Distribution Center",
82            "Research Laboratory",
83            "Administrative Building",
84        ],
85    ),
86    (
87        AssetClass::Machinery,
88        &[
89            "Production Line Equipment",
90            "CNC Machining Center",
91            "Assembly Robot System",
92            "Industrial Press Machine",
93            "Packaging Equipment",
94            "Testing Equipment",
95            "Quality Control System",
96            "Material Handling System",
97        ],
98    ),
99    (
100        AssetClass::Vehicles,
101        &[
102            "Delivery Truck",
103            "Company Car",
104            "Forklift",
105            "Van Fleet Unit",
106            "Executive Vehicle",
107            "Service Vehicle",
108            "Cargo Truck",
109            "Utility Vehicle",
110        ],
111    ),
112    (
113        AssetClass::Furniture,
114        &[
115            "Office Workstation Set",
116            "Conference Room Furniture",
117            "Executive Desk Set",
118            "Reception Area Furniture",
119            "Cubicle System",
120            "Storage Cabinet Set",
121            "Meeting Room Table",
122            "Ergonomic Chair Set",
123        ],
124    ),
125    (
126        AssetClass::ItEquipment,
127        &[
128            "Server Rack System",
129            "Network Switch Array",
130            "Desktop Computer Set",
131            "Laptop Fleet",
132            "Storage Array",
133            "Backup System",
134            "Security System",
135            "Communication System",
136        ],
137    ),
138    (
139        AssetClass::Software,
140        &[
141            "ERP System License",
142            "CAD Software Suite",
143            "Database License",
144            "Office Suite License",
145            "Security Software",
146            "Development Tools",
147            "Analytics Platform",
148            "CRM System",
149        ],
150    ),
151    (
152        AssetClass::LeaseholdImprovements,
153        &[
154            "Office Build-out",
155            "HVAC Improvements",
156            "Electrical Upgrades",
157            "Floor Renovations",
158            "Lighting System",
159            "Security Improvements",
160            "Accessibility Upgrades",
161            "IT Infrastructure",
162        ],
163    ),
164];
165
166/// Generator for fixed asset master data.
167pub struct AssetGenerator {
168    rng: ChaCha8Rng,
169    seed: u64,
170    config: AssetGeneratorConfig,
171    asset_counter: usize,
172    coa_framework: CoAFramework,
173    /// Optional template provider for user-supplied asset descriptions (v3.2.1+)
174    template_provider: Option<datasynth_core::templates::SharedTemplateProvider>,
175}
176
177impl AssetGenerator {
178    /// Create a new asset generator.
179    pub fn new(seed: u64) -> Self {
180        Self::with_config(seed, AssetGeneratorConfig::default())
181    }
182
183    /// Create a new asset generator with custom configuration.
184    pub fn with_config(seed: u64, config: AssetGeneratorConfig) -> Self {
185        Self {
186            rng: seeded_rng(seed, 0),
187            seed,
188            config,
189            asset_counter: 0,
190            coa_framework: CoAFramework::UsGaap,
191            template_provider: None,
192        }
193    }
194
195    /// Set the accounting framework for framework-aware asset generation.
196    pub fn set_coa_framework(&mut self, framework: CoAFramework) {
197        self.coa_framework = framework;
198    }
199
200    /// Set a template provider so user-supplied asset descriptions
201    /// override the embedded pool. (v3.2.1+)
202    pub fn set_template_provider(
203        &mut self,
204        provider: datasynth_core::templates::SharedTemplateProvider,
205    ) {
206        self.template_provider = Some(provider);
207    }
208
209    /// Generate a single fixed asset.
210    pub fn generate_asset(
211        &mut self,
212        company_code: &str,
213        acquisition_date: NaiveDate,
214    ) -> FixedAsset {
215        self.asset_counter += 1;
216
217        let asset_id = format!("FA-{}-{:06}", company_code, self.asset_counter);
218        let asset_class = self.select_asset_class();
219        let description = self.select_description(&asset_class);
220
221        let mut asset = FixedAsset::new(
222            asset_id,
223            description.to_string(),
224            asset_class,
225            company_code,
226            acquisition_date,
227            self.generate_acquisition_cost(),
228        );
229
230        // GWG check: German GAAP immediate expensing for assets ≤ 800 EUR
231        let is_gwg = self.coa_framework == CoAFramework::GermanSkr04
232            && asset.acquisition_cost <= Decimal::from(800)
233            && asset_class.is_depreciable();
234
235        if is_gwg {
236            asset.is_gwg = Some(true);
237            asset.depreciation_method = DepreciationMethod::ImmediateExpense;
238            asset.useful_life_months = 1;
239            asset.salvage_value = Decimal::ZERO;
240        } else {
241            // Set depreciation parameters
242            asset.depreciation_method = self.select_depreciation_method(&asset_class);
243            asset.useful_life_months = self.get_useful_life(&asset_class);
244            asset.salvage_value = (asset.acquisition_cost
245                * Decimal::from_f64_retain(self.config.salvage_value_percent)
246                    .unwrap_or(Decimal::from_f64_retain(0.05).expect("valid decimal literal")))
247            .round_dp(2);
248        }
249
250        // Set account determination
251        asset.account_determination = self.generate_account_determination(&asset_class);
252
253        // Set location info
254        asset.location = Some(format!("P{company_code}"));
255        // v5.9.0: align with master cost-centers vocabulary (FIN / PROD /
256        // SALES / RD / CORP).  ADMIN was specific to asset_generator and
257        // didn't exist in the cost-centers master, so JE lines derived
258        // from these assets fell outside the cost_centers join.
259        asset.cost_center = Some(format!("CC-{company_code}-CORP"));
260
261        // Generate serial number for equipment
262        if matches!(
263            asset_class,
264            AssetClass::Machinery | AssetClass::Vehicles | AssetClass::ItEquipment
265        ) {
266            asset.serial_number = Some(self.generate_serial_number());
267        }
268
269        // Handle fully depreciated or disposed assets
270        if self.rng.random::<f64>() < self.config.disposed_rate {
271            let disposal_date =
272                acquisition_date + chrono::Duration::days(self.rng.random_range(365..1825) as i64);
273            let (proceeds, _gain_loss) = self.generate_disposal_values(&asset);
274            asset.dispose(disposal_date, proceeds);
275        } else if self.rng.random::<f64>() < self.config.fully_depreciated_rate {
276            asset.accumulated_depreciation = asset.acquisition_cost - asset.salvage_value;
277            asset.net_book_value = asset.salvage_value;
278        }
279
280        asset
281    }
282
283    /// Generate an asset with specific class.
284    pub fn generate_asset_of_class(
285        &mut self,
286        asset_class: AssetClass,
287        company_code: &str,
288        acquisition_date: NaiveDate,
289    ) -> FixedAsset {
290        self.asset_counter += 1;
291
292        let asset_id = format!("FA-{}-{:06}", company_code, self.asset_counter);
293        let description = self.select_description(&asset_class);
294
295        let mut asset = FixedAsset::new(
296            asset_id,
297            description.to_string(),
298            asset_class,
299            company_code,
300            acquisition_date,
301            self.generate_acquisition_cost_for_class(&asset_class),
302        );
303
304        asset.depreciation_method = self.select_depreciation_method(&asset_class);
305        asset.useful_life_months = self.get_useful_life(&asset_class);
306        asset.salvage_value = (asset.acquisition_cost
307            * Decimal::from_f64_retain(self.config.salvage_value_percent)
308                .unwrap_or(Decimal::from_f64_retain(0.05).expect("valid decimal literal")))
309        .round_dp(2);
310
311        asset.account_determination = self.generate_account_determination(&asset_class);
312        asset.location = Some(format!("P{company_code}"));
313        // v5.9.0: align with master cost-centers vocabulary (FIN / PROD /
314        // SALES / RD / CORP).  ADMIN was specific to asset_generator and
315        // didn't exist in the cost-centers master, so JE lines derived
316        // from these assets fell outside the cost_centers join.
317        asset.cost_center = Some(format!("CC-{company_code}-CORP"));
318
319        if matches!(
320            asset_class,
321            AssetClass::Machinery | AssetClass::Vehicles | AssetClass::ItEquipment
322        ) {
323            asset.serial_number = Some(self.generate_serial_number());
324        }
325
326        asset
327    }
328
329    /// Generate an asset with depreciation already applied.
330    pub fn generate_aged_asset(
331        &mut self,
332        company_code: &str,
333        acquisition_date: NaiveDate,
334        as_of_date: NaiveDate,
335    ) -> FixedAsset {
336        let mut asset = self.generate_asset(company_code, acquisition_date);
337
338        // Calculate months elapsed
339        let months_elapsed = ((as_of_date - acquisition_date).num_days() / 30) as u32;
340
341        // Apply depreciation for each month
342        for month_offset in 0..months_elapsed {
343            if asset.status == AssetStatus::Active {
344                // Calculate the depreciation date for this month
345                let dep_date =
346                    acquisition_date + chrono::Duration::days((month_offset as i64 + 1) * 30);
347                let depreciation = asset.calculate_monthly_depreciation(dep_date);
348                asset.apply_depreciation(depreciation);
349            }
350        }
351
352        asset
353    }
354
355    /// Generate an asset pool with specified count.
356    pub fn generate_asset_pool(
357        &mut self,
358        count: usize,
359        company_code: &str,
360        date_range: (NaiveDate, NaiveDate),
361    ) -> FixedAssetPool {
362        debug!(count, company_code, "Generating fixed asset pool");
363        let mut pool = FixedAssetPool::new();
364
365        let (start_date, end_date) = date_range;
366        let days_range = (end_date - start_date).num_days() as u64;
367
368        for _ in 0..count {
369            let acquisition_date =
370                start_date + chrono::Duration::days(self.rng.random_range(0..=days_range) as i64);
371            let asset = self.generate_asset(company_code, acquisition_date);
372            pool.add_asset(asset);
373        }
374
375        pool
376    }
377
378    /// Generate an asset pool with aged assets (depreciation applied).
379    pub fn generate_aged_asset_pool(
380        &mut self,
381        count: usize,
382        company_code: &str,
383        acquisition_date_range: (NaiveDate, NaiveDate),
384        as_of_date: NaiveDate,
385    ) -> FixedAssetPool {
386        let mut pool = FixedAssetPool::new();
387
388        let (start_date, end_date) = acquisition_date_range;
389        let days_range = (end_date - start_date).num_days() as u64;
390
391        for _ in 0..count {
392            let acquisition_date =
393                start_date + chrono::Duration::days(self.rng.random_range(0..=days_range) as i64);
394            let asset = self.generate_aged_asset(company_code, acquisition_date, as_of_date);
395            pool.add_asset(asset);
396        }
397
398        pool
399    }
400
401    /// Generate a diverse asset pool with various classes.
402    pub fn generate_diverse_pool(
403        &mut self,
404        count: usize,
405        company_code: &str,
406        date_range: (NaiveDate, NaiveDate),
407    ) -> FixedAssetPool {
408        let mut pool = FixedAssetPool::new();
409
410        let (start_date, end_date) = date_range;
411        let days_range = (end_date - start_date).num_days() as u64;
412
413        // Define class distribution
414        let class_counts = [
415            (AssetClass::Buildings, (count as f64 * 0.05) as usize),
416            (AssetClass::Machinery, (count as f64 * 0.25) as usize),
417            (AssetClass::Vehicles, (count as f64 * 0.15) as usize),
418            (AssetClass::Furniture, (count as f64 * 0.15) as usize),
419            (AssetClass::ItEquipment, (count as f64 * 0.25) as usize),
420            (AssetClass::Software, (count as f64 * 0.10) as usize),
421            (
422                AssetClass::LeaseholdImprovements,
423                (count as f64 * 0.05) as usize,
424            ),
425        ];
426
427        for (class, class_count) in class_counts {
428            for _ in 0..class_count {
429                let acquisition_date = start_date
430                    + chrono::Duration::days(self.rng.random_range(0..=days_range) as i64);
431                let asset = self.generate_asset_of_class(class, company_code, acquisition_date);
432                pool.add_asset(asset);
433            }
434        }
435
436        // Fill remaining slots
437        while pool.assets.len() < count {
438            let acquisition_date =
439                start_date + chrono::Duration::days(self.rng.random_range(0..=days_range) as i64);
440            let asset = self.generate_asset(company_code, acquisition_date);
441            pool.add_asset(asset);
442        }
443
444        pool
445    }
446
447    /// Select asset class based on distribution.
448    fn select_asset_class(&mut self) -> AssetClass {
449        let roll: f64 = self.rng.random();
450        let mut cumulative = 0.0;
451
452        for (class, prob) in &self.config.asset_class_distribution {
453            cumulative += prob;
454            if roll < cumulative {
455                return *class;
456            }
457        }
458
459        AssetClass::ItEquipment
460    }
461
462    /// Select depreciation method based on distribution and asset class.
463    fn select_depreciation_method(&mut self, asset_class: &AssetClass) -> DepreciationMethod {
464        // Land and CIP are not depreciated
465        if matches!(
466            asset_class,
467            AssetClass::Land | AssetClass::ConstructionInProgress
468        ) {
469            return DepreciationMethod::StraightLine; // Won't be used but needs a value
470        }
471
472        // German GAAP: use Degressiv instead of DDB/MACRS, with higher SL proportion
473        if self.coa_framework == CoAFramework::GermanSkr04 {
474            let roll: f64 = self.rng.random();
475            return if roll < 0.75 {
476                DepreciationMethod::StraightLine
477            } else {
478                DepreciationMethod::Degressiv
479            };
480        }
481
482        let roll: f64 = self.rng.random();
483        let mut cumulative = 0.0;
484
485        for (method, prob) in &self.config.depreciation_method_distribution {
486            cumulative += prob;
487            if roll < cumulative {
488                return *method;
489            }
490        }
491
492        DepreciationMethod::StraightLine
493    }
494
495    /// Get useful life for asset class.
496    fn get_useful_life(&self, asset_class: &AssetClass) -> u32 {
497        // German GAAP: use AfA-Tabellen defaults
498        if self.coa_framework == CoAFramework::GermanSkr04 {
499            return self.get_useful_life_german(asset_class);
500        }
501
502        for (class, months) in &self.config.useful_life_by_class {
503            if class == asset_class {
504                return *months;
505            }
506        }
507        60 // Default 5 years
508    }
509
510    /// Select description for asset class.
511    ///
512    /// v3.2.1+: prefer the user's [`TemplateProvider`] pool; fall back
513    /// to the embedded `ASSET_DESCRIPTIONS` when the provider returns
514    /// the generic fallback (i.e. no file-backed entries for this
515    /// class). Byte-identical to v3.2.0 when `templates.path` is unset.
516    fn select_description(&mut self, asset_class: &AssetClass) -> String {
517        let class_key = Self::asset_class_to_key(asset_class);
518
519        if let Some(ref provider) = self.template_provider {
520            let candidate = provider.get_asset_description(class_key, &mut self.rng);
521            let generic_fallback = format!("{class_key} asset");
522            if candidate != generic_fallback {
523                return candidate;
524            }
525        }
526
527        for (class, descriptions) in ASSET_DESCRIPTIONS {
528            if class == asset_class {
529                let idx = self.rng.random_range(0..descriptions.len());
530                return descriptions[idx].to_string();
531            }
532        }
533        "Fixed Asset".to_string()
534    }
535
536    /// Map an `AssetClass` variant to its lowercase/snake_case YAML key.
537    fn asset_class_to_key(asset_class: &AssetClass) -> &'static str {
538        match asset_class {
539            AssetClass::Buildings => "buildings",
540            AssetClass::BuildingImprovements => "building_improvements",
541            AssetClass::Machinery => "machinery",
542            AssetClass::Vehicles => "vehicles",
543            AssetClass::Furniture => "furniture",
544            AssetClass::ItEquipment => "it_equipment",
545            AssetClass::Software => "software",
546            AssetClass::LeaseholdImprovements => "leasehold_improvements",
547            AssetClass::Land => "land",
548            _ => "other",
549        }
550    }
551
552    /// Generate acquisition cost.
553    fn generate_acquisition_cost(&mut self) -> Decimal {
554        // German GAAP: ~15% of assets are GWG-eligible (≤ 800 EUR)
555        if self.coa_framework == CoAFramework::GermanSkr04 && self.rng.random::<f64>() < 0.15 {
556            let gwg_cost = Decimal::from(100)
557                + Decimal::from_f64_retain(self.rng.random::<f64>() * 700.0)
558                    .unwrap_or(Decimal::ZERO);
559            return gwg_cost.round_dp(2);
560        }
561
562        let min = self.config.acquisition_cost_range.0;
563        let max = self.config.acquisition_cost_range.1;
564        let range = (max - min).to_string().parse::<f64>().unwrap_or(0.0);
565        let offset =
566            Decimal::from_f64_retain(self.rng.random::<f64>() * range).unwrap_or(Decimal::ZERO);
567        (min + offset).round_dp(2)
568    }
569
570    /// Generate acquisition cost for specific asset class.
571    fn generate_acquisition_cost_for_class(&mut self, asset_class: &AssetClass) -> Decimal {
572        let (min, max) = match asset_class {
573            AssetClass::Buildings => (Decimal::from(500_000), Decimal::from(10_000_000)),
574            AssetClass::BuildingImprovements => (Decimal::from(50_000), Decimal::from(500_000)),
575            AssetClass::Machinery | AssetClass::MachineryEquipment => {
576                (Decimal::from(50_000), Decimal::from(1_000_000))
577            }
578            AssetClass::Vehicles => (Decimal::from(20_000), Decimal::from(100_000)),
579            AssetClass::Furniture | AssetClass::FurnitureFixtures => {
580                (Decimal::from(1_000), Decimal::from(50_000))
581            }
582            AssetClass::ItEquipment | AssetClass::ComputerHardware => {
583                (Decimal::from(2_000), Decimal::from(200_000))
584            }
585            AssetClass::Software | AssetClass::Intangibles => {
586                (Decimal::from(5_000), Decimal::from(500_000))
587            }
588            AssetClass::LeaseholdImprovements => (Decimal::from(10_000), Decimal::from(300_000)),
589            AssetClass::Land => (Decimal::from(100_000), Decimal::from(5_000_000)),
590            AssetClass::ConstructionInProgress => {
591                (Decimal::from(100_000), Decimal::from(2_000_000))
592            }
593            AssetClass::LowValueAssets => (Decimal::from(100), Decimal::from(5_000)),
594        };
595
596        let range = (max - min).to_string().parse::<f64>().unwrap_or(0.0);
597        let offset =
598            Decimal::from_f64_retain(self.rng.random::<f64>() * range).unwrap_or(Decimal::ZERO);
599        (min + offset).round_dp(2)
600    }
601
602    /// Generate serial number.
603    fn generate_serial_number(&mut self) -> String {
604        format!(
605            "SN-{:04}-{:08}",
606            self.rng.random_range(1000..9999),
607            self.rng.random_range(10000000..99999999)
608        )
609    }
610
611    /// Generate disposal values.
612    fn generate_disposal_values(&mut self, asset: &FixedAsset) -> (Decimal, Decimal) {
613        // Disposal proceeds typically 0-50% of acquisition cost
614        let proceeds_rate = self.rng.random::<f64>() * 0.5;
615        let proceeds = (asset.acquisition_cost
616            * Decimal::from_f64_retain(proceeds_rate).unwrap_or(Decimal::ZERO))
617        .round_dp(2);
618
619        // Gain/loss = proceeds - NBV (can be negative)
620        let nbv = asset.net_book_value;
621        let gain_loss = proceeds - nbv;
622
623        (proceeds, gain_loss)
624    }
625
626    /// Generate account determination for asset class.
627    fn generate_account_determination(
628        &self,
629        asset_class: &AssetClass,
630    ) -> AssetAccountDetermination {
631        match asset_class {
632            AssetClass::Buildings | AssetClass::BuildingImprovements => AssetAccountDetermination {
633                asset_account: "160000".to_string(),
634                accumulated_depreciation_account: "165000".to_string(),
635                depreciation_expense_account: "680000".to_string(),
636                gain_loss_account: "790000".to_string(),
637                gain_on_disposal_account: "790010".to_string(),
638                loss_on_disposal_account: "790020".to_string(),
639                acquisition_clearing_account: "199100".to_string(),
640            },
641            AssetClass::Machinery | AssetClass::MachineryEquipment => AssetAccountDetermination {
642                asset_account: "161000".to_string(),
643                accumulated_depreciation_account: "166000".to_string(),
644                depreciation_expense_account: "681000".to_string(),
645                gain_loss_account: "791000".to_string(),
646                gain_on_disposal_account: "791010".to_string(),
647                loss_on_disposal_account: "791020".to_string(),
648                acquisition_clearing_account: "199110".to_string(),
649            },
650            AssetClass::Vehicles => AssetAccountDetermination {
651                asset_account: "162000".to_string(),
652                accumulated_depreciation_account: "167000".to_string(),
653                depreciation_expense_account: "682000".to_string(),
654                gain_loss_account: "792000".to_string(),
655                gain_on_disposal_account: "792010".to_string(),
656                loss_on_disposal_account: "792020".to_string(),
657                acquisition_clearing_account: "199120".to_string(),
658            },
659            AssetClass::Furniture | AssetClass::FurnitureFixtures => AssetAccountDetermination {
660                asset_account: "163000".to_string(),
661                accumulated_depreciation_account: "168000".to_string(),
662                depreciation_expense_account: "683000".to_string(),
663                gain_loss_account: "793000".to_string(),
664                gain_on_disposal_account: "793010".to_string(),
665                loss_on_disposal_account: "793020".to_string(),
666                acquisition_clearing_account: "199130".to_string(),
667            },
668            AssetClass::ItEquipment | AssetClass::ComputerHardware => AssetAccountDetermination {
669                asset_account: "164000".to_string(),
670                accumulated_depreciation_account: "169000".to_string(),
671                depreciation_expense_account: "684000".to_string(),
672                gain_loss_account: "794000".to_string(),
673                gain_on_disposal_account: "794010".to_string(),
674                loss_on_disposal_account: "794020".to_string(),
675                acquisition_clearing_account: "199140".to_string(),
676            },
677            AssetClass::Software | AssetClass::Intangibles => AssetAccountDetermination {
678                asset_account: "170000".to_string(),
679                accumulated_depreciation_account: "175000".to_string(),
680                depreciation_expense_account: "685000".to_string(),
681                gain_loss_account: "795000".to_string(),
682                gain_on_disposal_account: "795010".to_string(),
683                loss_on_disposal_account: "795020".to_string(),
684                acquisition_clearing_account: "199150".to_string(),
685            },
686            AssetClass::LeaseholdImprovements => AssetAccountDetermination {
687                asset_account: "171000".to_string(),
688                accumulated_depreciation_account: "176000".to_string(),
689                depreciation_expense_account: "686000".to_string(),
690                gain_loss_account: "796000".to_string(),
691                gain_on_disposal_account: "796010".to_string(),
692                loss_on_disposal_account: "796020".to_string(),
693                acquisition_clearing_account: "199160".to_string(),
694            },
695            AssetClass::Land => {
696                AssetAccountDetermination {
697                    asset_account: "150000".to_string(),
698                    accumulated_depreciation_account: "".to_string(), // Land not depreciated
699                    depreciation_expense_account: "".to_string(),
700                    gain_loss_account: "790000".to_string(),
701                    gain_on_disposal_account: "790010".to_string(),
702                    loss_on_disposal_account: "790020".to_string(),
703                    acquisition_clearing_account: "199000".to_string(),
704                }
705            }
706            AssetClass::ConstructionInProgress => AssetAccountDetermination {
707                asset_account: "159000".to_string(),
708                accumulated_depreciation_account: "".to_string(),
709                depreciation_expense_account: "".to_string(),
710                gain_loss_account: "".to_string(),
711                gain_on_disposal_account: "".to_string(),
712                loss_on_disposal_account: "".to_string(),
713                acquisition_clearing_account: "199090".to_string(),
714            },
715            AssetClass::LowValueAssets => AssetAccountDetermination {
716                asset_account: "172000".to_string(),
717                accumulated_depreciation_account: "177000".to_string(),
718                depreciation_expense_account: "687000".to_string(),
719                gain_loss_account: "797000".to_string(),
720                gain_on_disposal_account: "797010".to_string(),
721                loss_on_disposal_account: "797020".to_string(),
722                acquisition_clearing_account: "199170".to_string(),
723            },
724        }
725    }
726
727    /// Generate German useful life based on AfA-Tabellen defaults.
728    fn get_useful_life_german(&self, asset_class: &AssetClass) -> u32 {
729        match asset_class {
730            AssetClass::Buildings | AssetClass::BuildingImprovements => 396, // 33 years (AfA §7(4))
731            AssetClass::Machinery | AssetClass::MachineryEquipment => 120,   // 10 years
732            AssetClass::Vehicles => 72,                                      // 6 years
733            AssetClass::Furniture | AssetClass::FurnitureFixtures => 156,    // 13 years
734            AssetClass::ItEquipment | AssetClass::ComputerHardware => 36,    // 3 years (digital)
735            AssetClass::Software | AssetClass::Intangibles => 36,            // 3 years (digital)
736            AssetClass::LeaseholdImprovements => 120,                        // 10 years
737            AssetClass::Land | AssetClass::ConstructionInProgress => 0,
738            AssetClass::LowValueAssets => 12,
739        }
740    }
741
742    /// Reset the generator.
743    pub fn reset(&mut self) {
744        self.rng = seeded_rng(self.seed, 0);
745        self.asset_counter = 0;
746    }
747}
748
749#[cfg(test)]
750#[allow(clippy::unwrap_used)]
751mod tests {
752    use super::*;
753
754    #[test]
755    fn test_asset_generation() {
756        let mut gen = AssetGenerator::new(42);
757        let asset = gen.generate_asset("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
758
759        assert!(!asset.asset_id.is_empty());
760        assert!(!asset.description.is_empty());
761        assert!(asset.acquisition_cost > Decimal::ZERO);
762        assert!(
763            asset.useful_life_months > 0
764                || matches!(
765                    asset.asset_class,
766                    AssetClass::Land | AssetClass::ConstructionInProgress
767                )
768        );
769    }
770
771    #[test]
772    fn test_asset_pool_generation() {
773        let mut gen = AssetGenerator::new(42);
774        let pool = gen.generate_asset_pool(
775            50,
776            "1000",
777            (
778                NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
779                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
780            ),
781        );
782
783        assert_eq!(pool.assets.len(), 50);
784    }
785
786    #[test]
787    fn test_aged_asset() {
788        let mut gen = AssetGenerator::new(42);
789        let asset = gen.generate_aged_asset(
790            "1000",
791            NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(),
792            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
793        );
794
795        // Should have accumulated depreciation
796        if asset.status == AssetStatus::Active && asset.useful_life_months > 0 {
797            assert!(asset.accumulated_depreciation > Decimal::ZERO);
798            assert!(asset.net_book_value < asset.acquisition_cost);
799        }
800    }
801
802    #[test]
803    fn test_diverse_pool() {
804        let mut gen = AssetGenerator::new(42);
805        let pool = gen.generate_diverse_pool(
806            100,
807            "1000",
808            (
809                NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
810                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
811            ),
812        );
813
814        // Should have various asset classes
815        let machinery_count = pool
816            .assets
817            .iter()
818            .filter(|a| a.asset_class == AssetClass::Machinery)
819            .count();
820        let it_count = pool
821            .assets
822            .iter()
823            .filter(|a| a.asset_class == AssetClass::ItEquipment)
824            .count();
825
826        assert!(machinery_count > 0);
827        assert!(it_count > 0);
828    }
829
830    #[test]
831    fn test_deterministic_generation() {
832        let mut gen1 = AssetGenerator::new(42);
833        let mut gen2 = AssetGenerator::new(42);
834
835        let asset1 = gen1.generate_asset("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
836        let asset2 = gen2.generate_asset("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
837
838        assert_eq!(asset1.asset_id, asset2.asset_id);
839        assert_eq!(asset1.description, asset2.description);
840        assert_eq!(asset1.acquisition_cost, asset2.acquisition_cost);
841    }
842
843    #[test]
844    fn test_depreciation_calculation() {
845        let mut gen = AssetGenerator::new(42);
846        let mut asset = gen.generate_asset_of_class(
847            AssetClass::ItEquipment,
848            "1000",
849            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
850        );
851
852        let initial_nbv = asset.net_book_value;
853
854        // Apply one month of depreciation
855        let depreciation =
856            asset.calculate_monthly_depreciation(NaiveDate::from_ymd_opt(2024, 2, 1).unwrap());
857        asset.apply_depreciation(depreciation);
858
859        assert!(asset.accumulated_depreciation > Decimal::ZERO);
860        assert!(asset.net_book_value < initial_nbv);
861    }
862
863    #[test]
864    fn test_german_gwg_assets() {
865        let mut gen = AssetGenerator::new(42);
866        gen.set_coa_framework(CoAFramework::GermanSkr04);
867
868        let mut gwg_count = 0;
869        for _ in 0..200 {
870            let asset = gen.generate_asset("DE01", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
871            if asset.is_gwg == Some(true) {
872                gwg_count += 1;
873                assert!(asset.acquisition_cost <= Decimal::from(800));
874                assert_eq!(
875                    asset.depreciation_method,
876                    DepreciationMethod::ImmediateExpense
877                );
878                assert_eq!(asset.useful_life_months, 1);
879                assert_eq!(asset.salvage_value, Decimal::ZERO);
880            }
881        }
882        // Should have some GWG assets (~15% of the ~15% that get low cost)
883        assert!(gwg_count > 0, "Expected at least one GWG asset");
884    }
885
886    #[test]
887    fn test_german_depreciation_methods() {
888        let mut gen = AssetGenerator::new(42);
889        gen.set_coa_framework(CoAFramework::GermanSkr04);
890
891        let mut sl_count = 0;
892        let mut degressiv_count = 0;
893        let mut immediate_count = 0;
894        for _ in 0..200 {
895            let asset = gen.generate_asset("DE01", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
896            match asset.depreciation_method {
897                DepreciationMethod::StraightLine => sl_count += 1,
898                DepreciationMethod::Degressiv => degressiv_count += 1,
899                DepreciationMethod::ImmediateExpense => immediate_count += 1,
900                other => panic!("Unexpected German depreciation method: {:?}", other),
901            }
902        }
903        // Should have a mix: mostly SL, some Degressiv, some ImmediateExpense (GWG)
904        assert!(sl_count > 0, "Expected some straight-line assets");
905        assert!(degressiv_count > 0, "Expected some Degressiv assets");
906        assert!(
907            immediate_count > 0,
908            "Expected some GWG immediate expense assets"
909        );
910        // No MACRS or DDB under German GAAP
911    }
912
913    #[test]
914    fn test_german_useful_life_afa() {
915        let mut gen = AssetGenerator::new(42);
916        gen.set_coa_framework(CoAFramework::GermanSkr04);
917
918        let vehicle = gen.generate_asset_of_class(
919            AssetClass::Vehicles,
920            "DE01",
921            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
922        );
923        // German AfA: vehicles = 72 months (6 years)
924        assert_eq!(vehicle.useful_life_months, 72);
925
926        let building = gen.generate_asset_of_class(
927            AssetClass::Buildings,
928            "DE01",
929            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
930        );
931        // German AfA: buildings = 396 months (33 years)
932        assert_eq!(building.useful_life_months, 396);
933    }
934
935    #[test]
936    fn test_asset_class_cost_ranges() {
937        let mut gen = AssetGenerator::new(42);
938
939        // Buildings should be more expensive than furniture
940        let building = gen.generate_asset_of_class(
941            AssetClass::Buildings,
942            "1000",
943            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
944        );
945        let furniture = gen.generate_asset_of_class(
946            AssetClass::Furniture,
947            "1000",
948            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
949        );
950
951        // Buildings min is 500k, furniture max is 50k
952        assert!(building.acquisition_cost >= Decimal::from(500_000));
953        assert!(furniture.acquisition_cost <= Decimal::from(50_000));
954    }
955}