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        asset.cost_center = Some(format!("CC-{company_code}-ADMIN"));
256
257        // Generate serial number for equipment
258        if matches!(
259            asset_class,
260            AssetClass::Machinery | AssetClass::Vehicles | AssetClass::ItEquipment
261        ) {
262            asset.serial_number = Some(self.generate_serial_number());
263        }
264
265        // Handle fully depreciated or disposed assets
266        if self.rng.random::<f64>() < self.config.disposed_rate {
267            let disposal_date =
268                acquisition_date + chrono::Duration::days(self.rng.random_range(365..1825) as i64);
269            let (proceeds, _gain_loss) = self.generate_disposal_values(&asset);
270            asset.dispose(disposal_date, proceeds);
271        } else if self.rng.random::<f64>() < self.config.fully_depreciated_rate {
272            asset.accumulated_depreciation = asset.acquisition_cost - asset.salvage_value;
273            asset.net_book_value = asset.salvage_value;
274        }
275
276        asset
277    }
278
279    /// Generate an asset with specific class.
280    pub fn generate_asset_of_class(
281        &mut self,
282        asset_class: AssetClass,
283        company_code: &str,
284        acquisition_date: NaiveDate,
285    ) -> FixedAsset {
286        self.asset_counter += 1;
287
288        let asset_id = format!("FA-{}-{:06}", company_code, self.asset_counter);
289        let description = self.select_description(&asset_class);
290
291        let mut asset = FixedAsset::new(
292            asset_id,
293            description.to_string(),
294            asset_class,
295            company_code,
296            acquisition_date,
297            self.generate_acquisition_cost_for_class(&asset_class),
298        );
299
300        asset.depreciation_method = self.select_depreciation_method(&asset_class);
301        asset.useful_life_months = self.get_useful_life(&asset_class);
302        asset.salvage_value = (asset.acquisition_cost
303            * Decimal::from_f64_retain(self.config.salvage_value_percent)
304                .unwrap_or(Decimal::from_f64_retain(0.05).expect("valid decimal literal")))
305        .round_dp(2);
306
307        asset.account_determination = self.generate_account_determination(&asset_class);
308        asset.location = Some(format!("P{company_code}"));
309        asset.cost_center = Some(format!("CC-{company_code}-ADMIN"));
310
311        if matches!(
312            asset_class,
313            AssetClass::Machinery | AssetClass::Vehicles | AssetClass::ItEquipment
314        ) {
315            asset.serial_number = Some(self.generate_serial_number());
316        }
317
318        asset
319    }
320
321    /// Generate an asset with depreciation already applied.
322    pub fn generate_aged_asset(
323        &mut self,
324        company_code: &str,
325        acquisition_date: NaiveDate,
326        as_of_date: NaiveDate,
327    ) -> FixedAsset {
328        let mut asset = self.generate_asset(company_code, acquisition_date);
329
330        // Calculate months elapsed
331        let months_elapsed = ((as_of_date - acquisition_date).num_days() / 30) as u32;
332
333        // Apply depreciation for each month
334        for month_offset in 0..months_elapsed {
335            if asset.status == AssetStatus::Active {
336                // Calculate the depreciation date for this month
337                let dep_date =
338                    acquisition_date + chrono::Duration::days((month_offset as i64 + 1) * 30);
339                let depreciation = asset.calculate_monthly_depreciation(dep_date);
340                asset.apply_depreciation(depreciation);
341            }
342        }
343
344        asset
345    }
346
347    /// Generate an asset pool with specified count.
348    pub fn generate_asset_pool(
349        &mut self,
350        count: usize,
351        company_code: &str,
352        date_range: (NaiveDate, NaiveDate),
353    ) -> FixedAssetPool {
354        debug!(count, company_code, "Generating fixed asset pool");
355        let mut pool = FixedAssetPool::new();
356
357        let (start_date, end_date) = date_range;
358        let days_range = (end_date - start_date).num_days() as u64;
359
360        for _ in 0..count {
361            let acquisition_date =
362                start_date + chrono::Duration::days(self.rng.random_range(0..=days_range) as i64);
363            let asset = self.generate_asset(company_code, acquisition_date);
364            pool.add_asset(asset);
365        }
366
367        pool
368    }
369
370    /// Generate an asset pool with aged assets (depreciation applied).
371    pub fn generate_aged_asset_pool(
372        &mut self,
373        count: usize,
374        company_code: &str,
375        acquisition_date_range: (NaiveDate, NaiveDate),
376        as_of_date: NaiveDate,
377    ) -> FixedAssetPool {
378        let mut pool = FixedAssetPool::new();
379
380        let (start_date, end_date) = acquisition_date_range;
381        let days_range = (end_date - start_date).num_days() as u64;
382
383        for _ in 0..count {
384            let acquisition_date =
385                start_date + chrono::Duration::days(self.rng.random_range(0..=days_range) as i64);
386            let asset = self.generate_aged_asset(company_code, acquisition_date, as_of_date);
387            pool.add_asset(asset);
388        }
389
390        pool
391    }
392
393    /// Generate a diverse asset pool with various classes.
394    pub fn generate_diverse_pool(
395        &mut self,
396        count: usize,
397        company_code: &str,
398        date_range: (NaiveDate, NaiveDate),
399    ) -> FixedAssetPool {
400        let mut pool = FixedAssetPool::new();
401
402        let (start_date, end_date) = date_range;
403        let days_range = (end_date - start_date).num_days() as u64;
404
405        // Define class distribution
406        let class_counts = [
407            (AssetClass::Buildings, (count as f64 * 0.05) as usize),
408            (AssetClass::Machinery, (count as f64 * 0.25) as usize),
409            (AssetClass::Vehicles, (count as f64 * 0.15) as usize),
410            (AssetClass::Furniture, (count as f64 * 0.15) as usize),
411            (AssetClass::ItEquipment, (count as f64 * 0.25) as usize),
412            (AssetClass::Software, (count as f64 * 0.10) as usize),
413            (
414                AssetClass::LeaseholdImprovements,
415                (count as f64 * 0.05) as usize,
416            ),
417        ];
418
419        for (class, class_count) in class_counts {
420            for _ in 0..class_count {
421                let acquisition_date = start_date
422                    + chrono::Duration::days(self.rng.random_range(0..=days_range) as i64);
423                let asset = self.generate_asset_of_class(class, company_code, acquisition_date);
424                pool.add_asset(asset);
425            }
426        }
427
428        // Fill remaining slots
429        while pool.assets.len() < count {
430            let acquisition_date =
431                start_date + chrono::Duration::days(self.rng.random_range(0..=days_range) as i64);
432            let asset = self.generate_asset(company_code, acquisition_date);
433            pool.add_asset(asset);
434        }
435
436        pool
437    }
438
439    /// Select asset class based on distribution.
440    fn select_asset_class(&mut self) -> AssetClass {
441        let roll: f64 = self.rng.random();
442        let mut cumulative = 0.0;
443
444        for (class, prob) in &self.config.asset_class_distribution {
445            cumulative += prob;
446            if roll < cumulative {
447                return *class;
448            }
449        }
450
451        AssetClass::ItEquipment
452    }
453
454    /// Select depreciation method based on distribution and asset class.
455    fn select_depreciation_method(&mut self, asset_class: &AssetClass) -> DepreciationMethod {
456        // Land and CIP are not depreciated
457        if matches!(
458            asset_class,
459            AssetClass::Land | AssetClass::ConstructionInProgress
460        ) {
461            return DepreciationMethod::StraightLine; // Won't be used but needs a value
462        }
463
464        // German GAAP: use Degressiv instead of DDB/MACRS, with higher SL proportion
465        if self.coa_framework == CoAFramework::GermanSkr04 {
466            let roll: f64 = self.rng.random();
467            return if roll < 0.75 {
468                DepreciationMethod::StraightLine
469            } else {
470                DepreciationMethod::Degressiv
471            };
472        }
473
474        let roll: f64 = self.rng.random();
475        let mut cumulative = 0.0;
476
477        for (method, prob) in &self.config.depreciation_method_distribution {
478            cumulative += prob;
479            if roll < cumulative {
480                return *method;
481            }
482        }
483
484        DepreciationMethod::StraightLine
485    }
486
487    /// Get useful life for asset class.
488    fn get_useful_life(&self, asset_class: &AssetClass) -> u32 {
489        // German GAAP: use AfA-Tabellen defaults
490        if self.coa_framework == CoAFramework::GermanSkr04 {
491            return self.get_useful_life_german(asset_class);
492        }
493
494        for (class, months) in &self.config.useful_life_by_class {
495            if class == asset_class {
496                return *months;
497            }
498        }
499        60 // Default 5 years
500    }
501
502    /// Select description for asset class.
503    ///
504    /// v3.2.1+: prefer the user's [`TemplateProvider`] pool; fall back
505    /// to the embedded `ASSET_DESCRIPTIONS` when the provider returns
506    /// the generic fallback (i.e. no file-backed entries for this
507    /// class). Byte-identical to v3.2.0 when `templates.path` is unset.
508    fn select_description(&mut self, asset_class: &AssetClass) -> String {
509        let class_key = Self::asset_class_to_key(asset_class);
510
511        if let Some(ref provider) = self.template_provider {
512            let candidate = provider.get_asset_description(class_key, &mut self.rng);
513            let generic_fallback = format!("{class_key} asset");
514            if candidate != generic_fallback {
515                return candidate;
516            }
517        }
518
519        for (class, descriptions) in ASSET_DESCRIPTIONS {
520            if class == asset_class {
521                let idx = self.rng.random_range(0..descriptions.len());
522                return descriptions[idx].to_string();
523            }
524        }
525        "Fixed Asset".to_string()
526    }
527
528    /// Map an `AssetClass` variant to its lowercase/snake_case YAML key.
529    fn asset_class_to_key(asset_class: &AssetClass) -> &'static str {
530        match asset_class {
531            AssetClass::Buildings => "buildings",
532            AssetClass::BuildingImprovements => "building_improvements",
533            AssetClass::Machinery => "machinery",
534            AssetClass::Vehicles => "vehicles",
535            AssetClass::Furniture => "furniture",
536            AssetClass::ItEquipment => "it_equipment",
537            AssetClass::Software => "software",
538            AssetClass::LeaseholdImprovements => "leasehold_improvements",
539            AssetClass::Land => "land",
540            _ => "other",
541        }
542    }
543
544    /// Generate acquisition cost.
545    fn generate_acquisition_cost(&mut self) -> Decimal {
546        // German GAAP: ~15% of assets are GWG-eligible (≤ 800 EUR)
547        if self.coa_framework == CoAFramework::GermanSkr04 && self.rng.random::<f64>() < 0.15 {
548            let gwg_cost = Decimal::from(100)
549                + Decimal::from_f64_retain(self.rng.random::<f64>() * 700.0)
550                    .unwrap_or(Decimal::ZERO);
551            return gwg_cost.round_dp(2);
552        }
553
554        let min = self.config.acquisition_cost_range.0;
555        let max = self.config.acquisition_cost_range.1;
556        let range = (max - min).to_string().parse::<f64>().unwrap_or(0.0);
557        let offset =
558            Decimal::from_f64_retain(self.rng.random::<f64>() * range).unwrap_or(Decimal::ZERO);
559        (min + offset).round_dp(2)
560    }
561
562    /// Generate acquisition cost for specific asset class.
563    fn generate_acquisition_cost_for_class(&mut self, asset_class: &AssetClass) -> Decimal {
564        let (min, max) = match asset_class {
565            AssetClass::Buildings => (Decimal::from(500_000), Decimal::from(10_000_000)),
566            AssetClass::BuildingImprovements => (Decimal::from(50_000), Decimal::from(500_000)),
567            AssetClass::Machinery | AssetClass::MachineryEquipment => {
568                (Decimal::from(50_000), Decimal::from(1_000_000))
569            }
570            AssetClass::Vehicles => (Decimal::from(20_000), Decimal::from(100_000)),
571            AssetClass::Furniture | AssetClass::FurnitureFixtures => {
572                (Decimal::from(1_000), Decimal::from(50_000))
573            }
574            AssetClass::ItEquipment | AssetClass::ComputerHardware => {
575                (Decimal::from(2_000), Decimal::from(200_000))
576            }
577            AssetClass::Software | AssetClass::Intangibles => {
578                (Decimal::from(5_000), Decimal::from(500_000))
579            }
580            AssetClass::LeaseholdImprovements => (Decimal::from(10_000), Decimal::from(300_000)),
581            AssetClass::Land => (Decimal::from(100_000), Decimal::from(5_000_000)),
582            AssetClass::ConstructionInProgress => {
583                (Decimal::from(100_000), Decimal::from(2_000_000))
584            }
585            AssetClass::LowValueAssets => (Decimal::from(100), Decimal::from(5_000)),
586        };
587
588        let range = (max - min).to_string().parse::<f64>().unwrap_or(0.0);
589        let offset =
590            Decimal::from_f64_retain(self.rng.random::<f64>() * range).unwrap_or(Decimal::ZERO);
591        (min + offset).round_dp(2)
592    }
593
594    /// Generate serial number.
595    fn generate_serial_number(&mut self) -> String {
596        format!(
597            "SN-{:04}-{:08}",
598            self.rng.random_range(1000..9999),
599            self.rng.random_range(10000000..99999999)
600        )
601    }
602
603    /// Generate disposal values.
604    fn generate_disposal_values(&mut self, asset: &FixedAsset) -> (Decimal, Decimal) {
605        // Disposal proceeds typically 0-50% of acquisition cost
606        let proceeds_rate = self.rng.random::<f64>() * 0.5;
607        let proceeds = (asset.acquisition_cost
608            * Decimal::from_f64_retain(proceeds_rate).unwrap_or(Decimal::ZERO))
609        .round_dp(2);
610
611        // Gain/loss = proceeds - NBV (can be negative)
612        let nbv = asset.net_book_value;
613        let gain_loss = proceeds - nbv;
614
615        (proceeds, gain_loss)
616    }
617
618    /// Generate account determination for asset class.
619    fn generate_account_determination(
620        &self,
621        asset_class: &AssetClass,
622    ) -> AssetAccountDetermination {
623        match asset_class {
624            AssetClass::Buildings | AssetClass::BuildingImprovements => AssetAccountDetermination {
625                asset_account: "160000".to_string(),
626                accumulated_depreciation_account: "165000".to_string(),
627                depreciation_expense_account: "680000".to_string(),
628                gain_loss_account: "790000".to_string(),
629                gain_on_disposal_account: "790010".to_string(),
630                loss_on_disposal_account: "790020".to_string(),
631                acquisition_clearing_account: "199100".to_string(),
632            },
633            AssetClass::Machinery | AssetClass::MachineryEquipment => AssetAccountDetermination {
634                asset_account: "161000".to_string(),
635                accumulated_depreciation_account: "166000".to_string(),
636                depreciation_expense_account: "681000".to_string(),
637                gain_loss_account: "791000".to_string(),
638                gain_on_disposal_account: "791010".to_string(),
639                loss_on_disposal_account: "791020".to_string(),
640                acquisition_clearing_account: "199110".to_string(),
641            },
642            AssetClass::Vehicles => AssetAccountDetermination {
643                asset_account: "162000".to_string(),
644                accumulated_depreciation_account: "167000".to_string(),
645                depreciation_expense_account: "682000".to_string(),
646                gain_loss_account: "792000".to_string(),
647                gain_on_disposal_account: "792010".to_string(),
648                loss_on_disposal_account: "792020".to_string(),
649                acquisition_clearing_account: "199120".to_string(),
650            },
651            AssetClass::Furniture | AssetClass::FurnitureFixtures => AssetAccountDetermination {
652                asset_account: "163000".to_string(),
653                accumulated_depreciation_account: "168000".to_string(),
654                depreciation_expense_account: "683000".to_string(),
655                gain_loss_account: "793000".to_string(),
656                gain_on_disposal_account: "793010".to_string(),
657                loss_on_disposal_account: "793020".to_string(),
658                acquisition_clearing_account: "199130".to_string(),
659            },
660            AssetClass::ItEquipment | AssetClass::ComputerHardware => AssetAccountDetermination {
661                asset_account: "164000".to_string(),
662                accumulated_depreciation_account: "169000".to_string(),
663                depreciation_expense_account: "684000".to_string(),
664                gain_loss_account: "794000".to_string(),
665                gain_on_disposal_account: "794010".to_string(),
666                loss_on_disposal_account: "794020".to_string(),
667                acquisition_clearing_account: "199140".to_string(),
668            },
669            AssetClass::Software | AssetClass::Intangibles => AssetAccountDetermination {
670                asset_account: "170000".to_string(),
671                accumulated_depreciation_account: "175000".to_string(),
672                depreciation_expense_account: "685000".to_string(),
673                gain_loss_account: "795000".to_string(),
674                gain_on_disposal_account: "795010".to_string(),
675                loss_on_disposal_account: "795020".to_string(),
676                acquisition_clearing_account: "199150".to_string(),
677            },
678            AssetClass::LeaseholdImprovements => AssetAccountDetermination {
679                asset_account: "171000".to_string(),
680                accumulated_depreciation_account: "176000".to_string(),
681                depreciation_expense_account: "686000".to_string(),
682                gain_loss_account: "796000".to_string(),
683                gain_on_disposal_account: "796010".to_string(),
684                loss_on_disposal_account: "796020".to_string(),
685                acquisition_clearing_account: "199160".to_string(),
686            },
687            AssetClass::Land => {
688                AssetAccountDetermination {
689                    asset_account: "150000".to_string(),
690                    accumulated_depreciation_account: "".to_string(), // Land not depreciated
691                    depreciation_expense_account: "".to_string(),
692                    gain_loss_account: "790000".to_string(),
693                    gain_on_disposal_account: "790010".to_string(),
694                    loss_on_disposal_account: "790020".to_string(),
695                    acquisition_clearing_account: "199000".to_string(),
696                }
697            }
698            AssetClass::ConstructionInProgress => AssetAccountDetermination {
699                asset_account: "159000".to_string(),
700                accumulated_depreciation_account: "".to_string(),
701                depreciation_expense_account: "".to_string(),
702                gain_loss_account: "".to_string(),
703                gain_on_disposal_account: "".to_string(),
704                loss_on_disposal_account: "".to_string(),
705                acquisition_clearing_account: "199090".to_string(),
706            },
707            AssetClass::LowValueAssets => AssetAccountDetermination {
708                asset_account: "172000".to_string(),
709                accumulated_depreciation_account: "177000".to_string(),
710                depreciation_expense_account: "687000".to_string(),
711                gain_loss_account: "797000".to_string(),
712                gain_on_disposal_account: "797010".to_string(),
713                loss_on_disposal_account: "797020".to_string(),
714                acquisition_clearing_account: "199170".to_string(),
715            },
716        }
717    }
718
719    /// Generate German useful life based on AfA-Tabellen defaults.
720    fn get_useful_life_german(&self, asset_class: &AssetClass) -> u32 {
721        match asset_class {
722            AssetClass::Buildings | AssetClass::BuildingImprovements => 396, // 33 years (AfA §7(4))
723            AssetClass::Machinery | AssetClass::MachineryEquipment => 120,   // 10 years
724            AssetClass::Vehicles => 72,                                      // 6 years
725            AssetClass::Furniture | AssetClass::FurnitureFixtures => 156,    // 13 years
726            AssetClass::ItEquipment | AssetClass::ComputerHardware => 36,    // 3 years (digital)
727            AssetClass::Software | AssetClass::Intangibles => 36,            // 3 years (digital)
728            AssetClass::LeaseholdImprovements => 120,                        // 10 years
729            AssetClass::Land | AssetClass::ConstructionInProgress => 0,
730            AssetClass::LowValueAssets => 12,
731        }
732    }
733
734    /// Reset the generator.
735    pub fn reset(&mut self) {
736        self.rng = seeded_rng(self.seed, 0);
737        self.asset_counter = 0;
738    }
739}
740
741#[cfg(test)]
742#[allow(clippy::unwrap_used)]
743mod tests {
744    use super::*;
745
746    #[test]
747    fn test_asset_generation() {
748        let mut gen = AssetGenerator::new(42);
749        let asset = gen.generate_asset("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
750
751        assert!(!asset.asset_id.is_empty());
752        assert!(!asset.description.is_empty());
753        assert!(asset.acquisition_cost > Decimal::ZERO);
754        assert!(
755            asset.useful_life_months > 0
756                || matches!(
757                    asset.asset_class,
758                    AssetClass::Land | AssetClass::ConstructionInProgress
759                )
760        );
761    }
762
763    #[test]
764    fn test_asset_pool_generation() {
765        let mut gen = AssetGenerator::new(42);
766        let pool = gen.generate_asset_pool(
767            50,
768            "1000",
769            (
770                NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
771                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
772            ),
773        );
774
775        assert_eq!(pool.assets.len(), 50);
776    }
777
778    #[test]
779    fn test_aged_asset() {
780        let mut gen = AssetGenerator::new(42);
781        let asset = gen.generate_aged_asset(
782            "1000",
783            NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(),
784            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
785        );
786
787        // Should have accumulated depreciation
788        if asset.status == AssetStatus::Active && asset.useful_life_months > 0 {
789            assert!(asset.accumulated_depreciation > Decimal::ZERO);
790            assert!(asset.net_book_value < asset.acquisition_cost);
791        }
792    }
793
794    #[test]
795    fn test_diverse_pool() {
796        let mut gen = AssetGenerator::new(42);
797        let pool = gen.generate_diverse_pool(
798            100,
799            "1000",
800            (
801                NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
802                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
803            ),
804        );
805
806        // Should have various asset classes
807        let machinery_count = pool
808            .assets
809            .iter()
810            .filter(|a| a.asset_class == AssetClass::Machinery)
811            .count();
812        let it_count = pool
813            .assets
814            .iter()
815            .filter(|a| a.asset_class == AssetClass::ItEquipment)
816            .count();
817
818        assert!(machinery_count > 0);
819        assert!(it_count > 0);
820    }
821
822    #[test]
823    fn test_deterministic_generation() {
824        let mut gen1 = AssetGenerator::new(42);
825        let mut gen2 = AssetGenerator::new(42);
826
827        let asset1 = gen1.generate_asset("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
828        let asset2 = gen2.generate_asset("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
829
830        assert_eq!(asset1.asset_id, asset2.asset_id);
831        assert_eq!(asset1.description, asset2.description);
832        assert_eq!(asset1.acquisition_cost, asset2.acquisition_cost);
833    }
834
835    #[test]
836    fn test_depreciation_calculation() {
837        let mut gen = AssetGenerator::new(42);
838        let mut asset = gen.generate_asset_of_class(
839            AssetClass::ItEquipment,
840            "1000",
841            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
842        );
843
844        let initial_nbv = asset.net_book_value;
845
846        // Apply one month of depreciation
847        let depreciation =
848            asset.calculate_monthly_depreciation(NaiveDate::from_ymd_opt(2024, 2, 1).unwrap());
849        asset.apply_depreciation(depreciation);
850
851        assert!(asset.accumulated_depreciation > Decimal::ZERO);
852        assert!(asset.net_book_value < initial_nbv);
853    }
854
855    #[test]
856    fn test_german_gwg_assets() {
857        let mut gen = AssetGenerator::new(42);
858        gen.set_coa_framework(CoAFramework::GermanSkr04);
859
860        let mut gwg_count = 0;
861        for _ in 0..200 {
862            let asset = gen.generate_asset("DE01", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
863            if asset.is_gwg == Some(true) {
864                gwg_count += 1;
865                assert!(asset.acquisition_cost <= Decimal::from(800));
866                assert_eq!(
867                    asset.depreciation_method,
868                    DepreciationMethod::ImmediateExpense
869                );
870                assert_eq!(asset.useful_life_months, 1);
871                assert_eq!(asset.salvage_value, Decimal::ZERO);
872            }
873        }
874        // Should have some GWG assets (~15% of the ~15% that get low cost)
875        assert!(gwg_count > 0, "Expected at least one GWG asset");
876    }
877
878    #[test]
879    fn test_german_depreciation_methods() {
880        let mut gen = AssetGenerator::new(42);
881        gen.set_coa_framework(CoAFramework::GermanSkr04);
882
883        let mut sl_count = 0;
884        let mut degressiv_count = 0;
885        let mut immediate_count = 0;
886        for _ in 0..200 {
887            let asset = gen.generate_asset("DE01", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
888            match asset.depreciation_method {
889                DepreciationMethod::StraightLine => sl_count += 1,
890                DepreciationMethod::Degressiv => degressiv_count += 1,
891                DepreciationMethod::ImmediateExpense => immediate_count += 1,
892                other => panic!("Unexpected German depreciation method: {:?}", other),
893            }
894        }
895        // Should have a mix: mostly SL, some Degressiv, some ImmediateExpense (GWG)
896        assert!(sl_count > 0, "Expected some straight-line assets");
897        assert!(degressiv_count > 0, "Expected some Degressiv assets");
898        assert!(
899            immediate_count > 0,
900            "Expected some GWG immediate expense assets"
901        );
902        // No MACRS or DDB under German GAAP
903    }
904
905    #[test]
906    fn test_german_useful_life_afa() {
907        let mut gen = AssetGenerator::new(42);
908        gen.set_coa_framework(CoAFramework::GermanSkr04);
909
910        let vehicle = gen.generate_asset_of_class(
911            AssetClass::Vehicles,
912            "DE01",
913            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
914        );
915        // German AfA: vehicles = 72 months (6 years)
916        assert_eq!(vehicle.useful_life_months, 72);
917
918        let building = gen.generate_asset_of_class(
919            AssetClass::Buildings,
920            "DE01",
921            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
922        );
923        // German AfA: buildings = 396 months (33 years)
924        assert_eq!(building.useful_life_months, 396);
925    }
926
927    #[test]
928    fn test_asset_class_cost_ranges() {
929        let mut gen = AssetGenerator::new(42);
930
931        // Buildings should be more expensive than furniture
932        let building = gen.generate_asset_of_class(
933            AssetClass::Buildings,
934            "1000",
935            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
936        );
937        let furniture = gen.generate_asset_of_class(
938            AssetClass::Furniture,
939            "1000",
940            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
941        );
942
943        // Buildings min is 500k, furniture max is 50k
944        assert!(building.acquisition_cost >= Decimal::from(500_000));
945        assert!(furniture.acquisition_cost <= Decimal::from(50_000));
946    }
947}