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