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)]
750mod tests {
751    use super::*;
752
753    #[test]
754    fn test_asset_generation() {
755        let mut gen = AssetGenerator::new(42);
756        let asset = gen.generate_asset("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
757
758        assert!(!asset.asset_id.is_empty());
759        assert!(!asset.description.is_empty());
760        assert!(asset.acquisition_cost > Decimal::ZERO);
761        assert!(
762            asset.useful_life_months > 0
763                || matches!(
764                    asset.asset_class,
765                    AssetClass::Land | AssetClass::ConstructionInProgress
766                )
767        );
768    }
769
770    #[test]
771    fn test_asset_pool_generation() {
772        let mut gen = AssetGenerator::new(42);
773        let pool = gen.generate_asset_pool(
774            50,
775            "1000",
776            (
777                NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
778                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
779            ),
780        );
781
782        assert_eq!(pool.assets.len(), 50);
783    }
784
785    #[test]
786    fn test_aged_asset() {
787        let mut gen = AssetGenerator::new(42);
788        let asset = gen.generate_aged_asset(
789            "1000",
790            NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(),
791            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
792        );
793
794        // Should have accumulated depreciation
795        if asset.status == AssetStatus::Active && asset.useful_life_months > 0 {
796            assert!(asset.accumulated_depreciation > Decimal::ZERO);
797            assert!(asset.net_book_value < asset.acquisition_cost);
798        }
799    }
800
801    #[test]
802    fn test_diverse_pool() {
803        let mut gen = AssetGenerator::new(42);
804        let pool = gen.generate_diverse_pool(
805            100,
806            "1000",
807            (
808                NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
809                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
810            ),
811        );
812
813        // Should have various asset classes
814        let machinery_count = pool
815            .assets
816            .iter()
817            .filter(|a| a.asset_class == AssetClass::Machinery)
818            .count();
819        let it_count = pool
820            .assets
821            .iter()
822            .filter(|a| a.asset_class == AssetClass::ItEquipment)
823            .count();
824
825        assert!(machinery_count > 0);
826        assert!(it_count > 0);
827    }
828
829    #[test]
830    fn test_deterministic_generation() {
831        let mut gen1 = AssetGenerator::new(42);
832        let mut gen2 = AssetGenerator::new(42);
833
834        let asset1 = gen1.generate_asset("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
835        let asset2 = gen2.generate_asset("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
836
837        assert_eq!(asset1.asset_id, asset2.asset_id);
838        assert_eq!(asset1.description, asset2.description);
839        assert_eq!(asset1.acquisition_cost, asset2.acquisition_cost);
840    }
841
842    #[test]
843    fn test_depreciation_calculation() {
844        let mut gen = AssetGenerator::new(42);
845        let mut asset = gen.generate_asset_of_class(
846            AssetClass::ItEquipment,
847            "1000",
848            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
849        );
850
851        let initial_nbv = asset.net_book_value;
852
853        // Apply one month of depreciation
854        let depreciation =
855            asset.calculate_monthly_depreciation(NaiveDate::from_ymd_opt(2024, 2, 1).unwrap());
856        asset.apply_depreciation(depreciation);
857
858        assert!(asset.accumulated_depreciation > Decimal::ZERO);
859        assert!(asset.net_book_value < initial_nbv);
860    }
861
862    #[test]
863    fn test_german_gwg_assets() {
864        let mut gen = AssetGenerator::new(42);
865        gen.set_coa_framework(CoAFramework::GermanSkr04);
866
867        let mut gwg_count = 0;
868        for _ in 0..200 {
869            let asset = gen.generate_asset("DE01", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
870            if asset.is_gwg == Some(true) {
871                gwg_count += 1;
872                assert!(asset.acquisition_cost <= Decimal::from(800));
873                assert_eq!(
874                    asset.depreciation_method,
875                    DepreciationMethod::ImmediateExpense
876                );
877                assert_eq!(asset.useful_life_months, 1);
878                assert_eq!(asset.salvage_value, Decimal::ZERO);
879            }
880        }
881        // Should have some GWG assets (~15% of the ~15% that get low cost)
882        assert!(gwg_count > 0, "Expected at least one GWG asset");
883    }
884
885    #[test]
886    fn test_german_depreciation_methods() {
887        let mut gen = AssetGenerator::new(42);
888        gen.set_coa_framework(CoAFramework::GermanSkr04);
889
890        let mut sl_count = 0;
891        let mut degressiv_count = 0;
892        let mut immediate_count = 0;
893        for _ in 0..200 {
894            let asset = gen.generate_asset("DE01", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
895            match asset.depreciation_method {
896                DepreciationMethod::StraightLine => sl_count += 1,
897                DepreciationMethod::Degressiv => degressiv_count += 1,
898                DepreciationMethod::ImmediateExpense => immediate_count += 1,
899                other => panic!("Unexpected German depreciation method: {:?}", other),
900            }
901        }
902        // Should have a mix: mostly SL, some Degressiv, some ImmediateExpense (GWG)
903        assert!(sl_count > 0, "Expected some straight-line assets");
904        assert!(degressiv_count > 0, "Expected some Degressiv assets");
905        assert!(
906            immediate_count > 0,
907            "Expected some GWG immediate expense assets"
908        );
909        // No MACRS or DDB under German GAAP
910    }
911
912    #[test]
913    fn test_german_useful_life_afa() {
914        let mut gen = AssetGenerator::new(42);
915        gen.set_coa_framework(CoAFramework::GermanSkr04);
916
917        let vehicle = gen.generate_asset_of_class(
918            AssetClass::Vehicles,
919            "DE01",
920            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
921        );
922        // German AfA: vehicles = 72 months (6 years)
923        assert_eq!(vehicle.useful_life_months, 72);
924
925        let building = gen.generate_asset_of_class(
926            AssetClass::Buildings,
927            "DE01",
928            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
929        );
930        // German AfA: buildings = 396 months (33 years)
931        assert_eq!(building.useful_life_months, 396);
932    }
933
934    #[test]
935    fn test_asset_class_cost_ranges() {
936        let mut gen = AssetGenerator::new(42);
937
938        // Buildings should be more expensive than furniture
939        let building = gen.generate_asset_of_class(
940            AssetClass::Buildings,
941            "1000",
942            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
943        );
944        let furniture = gen.generate_asset_of_class(
945            AssetClass::Furniture,
946            "1000",
947            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
948        );
949
950        // Buildings min is 500k, furniture max is 50k
951        assert!(building.acquisition_cost >= Decimal::from(500_000));
952        assert!(furniture.acquisition_cost <= Decimal::from(50_000));
953    }
954}