1use chrono::NaiveDate;
4use rand::Rng;
5use rand_chacha::ChaCha8Rng;
6use rust_decimal::Decimal;
7use rust_decimal_macros::dec;
8
9use datasynth_core::models::subledger::fa::{
10 AssetClass, AssetDisposal, AssetStatus, DepreciationArea, DepreciationAreaType,
11 DepreciationEntry, DepreciationMethod, DepreciationRun, DisposalReason, DisposalType,
12 FixedAssetRecord,
13};
14use datasynth_core::models::{JournalEntry, JournalEntryLine};
15
16#[derive(Debug, Clone)]
18pub struct FAGeneratorConfig {
19 pub default_depreciation_method: DepreciationMethod,
21 pub default_useful_life_months: u32,
23 pub salvage_value_percent: Decimal,
25 pub avg_acquisition_cost: Decimal,
27 pub cost_variation: Decimal,
29 pub annual_disposal_rate: Decimal,
31}
32
33impl Default for FAGeneratorConfig {
34 fn default() -> Self {
35 Self {
36 default_depreciation_method: DepreciationMethod::StraightLine,
37 default_useful_life_months: 60,
38 salvage_value_percent: dec!(0.10),
39 avg_acquisition_cost: dec!(50000),
40 cost_variation: dec!(0.7),
41 annual_disposal_rate: dec!(0.05),
42 }
43 }
44}
45
46pub struct FAGenerator {
48 config: FAGeneratorConfig,
49 rng: ChaCha8Rng,
50 asset_counter: u64,
51 depreciation_run_counter: u64,
52 disposal_counter: u64,
53}
54
55impl FAGenerator {
56 pub fn new(config: FAGeneratorConfig, rng: ChaCha8Rng) -> Self {
58 Self {
59 config,
60 rng,
61 asset_counter: 0,
62 depreciation_run_counter: 0,
63 disposal_counter: 0,
64 }
65 }
66
67 fn parse_asset_class(class_str: &str) -> AssetClass {
69 match class_str.to_uppercase().as_str() {
70 "LAND" => AssetClass::Land,
71 "BUILDINGS" | "BUILDING" => AssetClass::Buildings,
72 "MACHINERY" | "EQUIPMENT" | "MACHINERY_EQUIPMENT" => AssetClass::MachineryEquipment,
73 "VEHICLES" | "VEHICLE" => AssetClass::Vehicles,
74 "FURNITURE" | "FIXTURES" => AssetClass::FurnitureFixtures,
75 "COMPUTER" | "IT" | "IT_EQUIPMENT" => AssetClass::ComputerEquipment,
76 "SOFTWARE" => AssetClass::Software,
77 "LEASEHOLD" | "LEASEHOLD_IMPROVEMENTS" => AssetClass::LeaseholdImprovements,
78 _ => AssetClass::Other,
79 }
80 }
81
82 pub fn generate_asset_acquisition(
84 &mut self,
85 company_code: &str,
86 asset_class_str: &str,
87 description: &str,
88 acquisition_date: NaiveDate,
89 currency: &str,
90 cost_center: Option<&str>,
91 ) -> (FixedAssetRecord, JournalEntry) {
92 self.asset_counter += 1;
93 let asset_number = format!("FA{:08}", self.asset_counter);
94 let asset_class = Self::parse_asset_class(asset_class_str);
95
96 let acquisition_cost = self.generate_acquisition_cost();
97 let salvage_value = (acquisition_cost * self.config.salvage_value_percent).round_dp(2);
98
99 let mut asset = FixedAssetRecord::new(
101 asset_number,
102 company_code.to_string(),
103 asset_class,
104 description.to_string(),
105 acquisition_date,
106 acquisition_cost,
107 currency.to_string(),
108 );
109
110 asset.serial_number = Some(format!("SN-{:010}", self.rng.gen::<u32>()));
112 asset.inventory_number = Some(format!("INV-{:08}", self.asset_counter));
113 asset.cost_center = cost_center.map(|s| s.to_string());
114
115 let mut depreciation_area = DepreciationArea::new(
117 DepreciationAreaType::Book,
118 self.config.default_depreciation_method,
119 self.config.default_useful_life_months,
120 acquisition_cost,
121 );
122 depreciation_area.salvage_value = salvage_value;
123 asset.add_depreciation_area(depreciation_area);
124
125 let je = self.generate_acquisition_je(&asset);
126 (asset, je)
127 }
128
129 pub fn run_depreciation(
131 &mut self,
132 company_code: &str,
133 assets: &[&FixedAssetRecord],
134 period_date: NaiveDate,
135 fiscal_year: i32,
136 fiscal_period: u32,
137 ) -> (DepreciationRun, Vec<JournalEntry>) {
138 self.depreciation_run_counter += 1;
139 let run_id = format!("DEPR{:08}", self.depreciation_run_counter);
140
141 let mut run = DepreciationRun::new(
142 run_id,
143 company_code.to_string(),
144 fiscal_year,
145 fiscal_period,
146 DepreciationAreaType::Book,
147 period_date,
148 "FAGenerator".to_string(),
149 );
150
151 run.start();
152 let mut journal_entries = Vec::new();
153
154 for asset in assets {
155 if asset.status != AssetStatus::Active {
156 continue;
157 }
158
159 if let Some(entry) = DepreciationEntry::from_asset(asset, DepreciationAreaType::Book) {
161 if entry.depreciation_amount <= Decimal::ZERO {
162 continue;
163 }
164
165 let je = self.generate_depreciation_je(asset, &entry, period_date);
166 run.add_entry(entry);
167 journal_entries.push(je);
168 }
169 }
170
171 run.complete();
172 (run, journal_entries)
173 }
174
175 pub fn generate_disposal(
177 &mut self,
178 asset: &FixedAssetRecord,
179 disposal_date: NaiveDate,
180 disposal_type: DisposalType,
181 proceeds: Decimal,
182 ) -> (AssetDisposal, JournalEntry) {
183 self.disposal_counter += 1;
184 let disposal_id = format!("DISP{:08}", self.disposal_counter);
185
186 let disposal_reason = self.random_disposal_reason();
187
188 let mut disposal = if disposal_type == DisposalType::Sale && proceeds > Decimal::ZERO {
190 AssetDisposal::sale(
191 disposal_id,
192 asset,
193 disposal_date,
194 proceeds,
195 format!("CUST-{}", self.disposal_counter),
196 "FAGenerator".to_string(),
197 )
198 } else {
199 let mut d = AssetDisposal::new(
200 disposal_id,
201 asset,
202 disposal_date,
203 disposal_type,
204 disposal_reason,
205 "FAGenerator".to_string(),
206 );
207 if proceeds > Decimal::ZERO {
208 d = d.with_sale_proceeds(proceeds);
209 } else {
210 d.calculate_gain_loss();
211 }
212 d
213 };
214
215 disposal.approve("SYSTEM".to_string(), disposal_date);
217
218 let je = self.generate_disposal_je(asset, &disposal);
219 (disposal, je)
220 }
221
222 fn generate_acquisition_cost(&mut self) -> Decimal {
223 let base = self.config.avg_acquisition_cost;
224 let variation = base * self.config.cost_variation;
225 let random: f64 = self.rng.gen_range(-1.0..1.0);
226 (base + variation * Decimal::try_from(random).unwrap_or_default())
227 .max(dec!(1000))
228 .round_dp(2)
229 }
230
231 fn calculate_monthly_depreciation(&self, asset: &FixedAssetRecord) -> Decimal {
232 let area = match asset.depreciation_areas.first() {
234 Some(a) => a,
235 None => return Decimal::ZERO,
236 };
237
238 area.calculate_monthly_depreciation()
240 }
241
242 fn random_disposal_reason(&mut self) -> DisposalReason {
243 match self.rng.gen_range(0..5) {
244 0 => DisposalReason::Sale,
245 1 => DisposalReason::EndOfLife,
246 2 => DisposalReason::Obsolescence,
247 3 => DisposalReason::Donated,
248 _ => DisposalReason::Replacement,
249 }
250 }
251
252 fn generate_acquisition_je(&self, asset: &FixedAssetRecord) -> JournalEntry {
253 let mut je = JournalEntry::new_simple(
254 format!("JE-ACQ-{}", asset.asset_number),
255 asset.company_code.clone(),
256 asset.acquisition_date,
257 format!("Asset Acquisition {}", asset.asset_number),
258 );
259
260 je.add_line(JournalEntryLine {
262 line_number: 1,
263 gl_account: asset.account_determination.acquisition_account.clone(),
264 debit_amount: asset.acquisition_cost,
265 cost_center: asset.cost_center.clone(),
266 profit_center: asset.profit_center.clone(),
267 reference: Some(asset.asset_number.clone()),
268 text: Some(asset.description.clone()),
269 quantity: Some(dec!(1)),
270 unit: Some("EA".to_string()),
271 ..Default::default()
272 });
273
274 je.add_line(JournalEntryLine {
276 line_number: 2,
277 gl_account: asset.account_determination.clearing_account.clone(),
278 credit_amount: asset.acquisition_cost,
279 reference: Some(asset.asset_number.clone()),
280 ..Default::default()
281 });
282
283 je
284 }
285
286 fn generate_depreciation_je(
287 &self,
288 asset: &FixedAssetRecord,
289 entry: &DepreciationEntry,
290 posting_date: NaiveDate,
291 ) -> JournalEntry {
292 let mut je = JournalEntry::new_simple(
293 format!("JE-DEP-{}", asset.asset_number),
294 asset.company_code.clone(),
295 posting_date,
296 format!("Depreciation {}", asset.asset_number),
297 );
298
299 je.add_line(JournalEntryLine {
301 line_number: 1,
302 gl_account: entry.expense_account.clone(),
303 debit_amount: entry.depreciation_amount,
304 cost_center: asset.cost_center.clone(),
305 profit_center: asset.profit_center.clone(),
306 reference: Some(asset.asset_number.clone()),
307 ..Default::default()
308 });
309
310 je.add_line(JournalEntryLine {
312 line_number: 2,
313 gl_account: entry.accum_depr_account.clone(),
314 credit_amount: entry.depreciation_amount,
315 reference: Some(asset.asset_number.clone()),
316 ..Default::default()
317 });
318
319 je
320 }
321
322 fn generate_disposal_je(
323 &self,
324 asset: &FixedAssetRecord,
325 disposal: &AssetDisposal,
326 ) -> JournalEntry {
327 let mut je = JournalEntry::new_simple(
328 format!("JE-{}", disposal.disposal_id),
329 asset.company_code.clone(),
330 disposal.disposal_date,
331 format!("Asset Disposal {}", asset.asset_number),
332 );
333
334 let mut line_num = 1;
335
336 if disposal.sale_proceeds > Decimal::ZERO {
338 je.add_line(JournalEntryLine {
339 line_number: line_num,
340 gl_account: "1000".to_string(),
341 debit_amount: disposal.sale_proceeds,
342 reference: Some(disposal.disposal_id.clone()),
343 ..Default::default()
344 });
345 line_num += 1;
346 }
347
348 je.add_line(JournalEntryLine {
350 line_number: line_num,
351 gl_account: asset
352 .account_determination
353 .accumulated_depreciation_account
354 .clone(),
355 debit_amount: disposal.accumulated_depreciation,
356 reference: Some(disposal.disposal_id.clone()),
357 ..Default::default()
358 });
359 line_num += 1;
360
361 if !disposal.is_gain {
363 je.add_line(JournalEntryLine {
364 line_number: line_num,
365 gl_account: asset.account_determination.loss_on_disposal_account.clone(),
366 debit_amount: disposal.loss(),
367 cost_center: asset.cost_center.clone(),
368 profit_center: asset.profit_center.clone(),
369 reference: Some(disposal.disposal_id.clone()),
370 ..Default::default()
371 });
372 line_num += 1;
373 }
374
375 je.add_line(JournalEntryLine {
377 line_number: line_num,
378 gl_account: asset.account_determination.acquisition_account.clone(),
379 credit_amount: asset.acquisition_cost,
380 reference: Some(disposal.disposal_id.clone()),
381 ..Default::default()
382 });
383 line_num += 1;
384
385 if disposal.is_gain && disposal.gain() > Decimal::ZERO {
387 je.add_line(JournalEntryLine {
388 line_number: line_num,
389 gl_account: asset.account_determination.gain_on_disposal_account.clone(),
390 credit_amount: disposal.gain(),
391 cost_center: asset.cost_center.clone(),
392 profit_center: asset.profit_center.clone(),
393 reference: Some(disposal.disposal_id.clone()),
394 ..Default::default()
395 });
396 }
397
398 je
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use datasynth_core::models::subledger::fa::DepreciationRunStatus;
406 use rand::SeedableRng;
407
408 #[test]
409 fn test_generate_asset_acquisition() {
410 let rng = ChaCha8Rng::seed_from_u64(12345);
411 let mut generator = FAGenerator::new(FAGeneratorConfig::default(), rng);
412
413 let (asset, je) = generator.generate_asset_acquisition(
414 "1000",
415 "MACHINERY",
416 "CNC Machine",
417 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
418 "USD",
419 Some("CC100"),
420 );
421
422 assert_eq!(asset.status, AssetStatus::Active);
423 assert!(asset.acquisition_cost > Decimal::ZERO);
424 assert!(je.is_balanced());
425 }
426
427 #[test]
428 fn test_run_depreciation() {
429 let rng = ChaCha8Rng::seed_from_u64(12345);
430 let mut generator = FAGenerator::new(FAGeneratorConfig::default(), rng);
431
432 let (asset, _) = generator.generate_asset_acquisition(
433 "1000",
434 "MACHINERY",
435 "CNC Machine",
436 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
437 "USD",
438 None,
439 );
440
441 let (run, jes) = generator.run_depreciation(
442 "1000",
443 &[&asset],
444 NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
445 2024,
446 1,
447 );
448
449 assert_eq!(run.status, DepreciationRunStatus::Completed);
450 assert!(run.asset_count > 0);
451 assert!(jes.iter().all(|je| je.is_balanced()));
452 }
453}