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 mut je = JournalEntry::new_simple(
253 format!("JE-ACQ-{}", asset.asset_number),
254 asset.company_code.clone(),
255 asset.acquisition_date,
256 format!("Asset Acquisition {}", asset.asset_number),
257 );
258
259 je.add_line(JournalEntryLine {
261 line_number: 1,
262 gl_account: asset.account_determination.acquisition_account.clone(),
263 debit_amount: asset.acquisition_cost,
264 cost_center: asset.cost_center.clone(),
265 profit_center: asset.profit_center.clone(),
266 reference: Some(asset.asset_number.clone()),
267 text: Some(asset.description.clone()),
268 quantity: Some(dec!(1)),
269 unit: Some("EA".to_string()),
270 ..Default::default()
271 });
272
273 je.add_line(JournalEntryLine {
275 line_number: 2,
276 gl_account: asset.account_determination.clearing_account.clone(),
277 credit_amount: asset.acquisition_cost,
278 reference: Some(asset.asset_number.clone()),
279 ..Default::default()
280 });
281
282 je
283 }
284
285 fn generate_depreciation_je(
286 &self,
287 asset: &FixedAssetRecord,
288 entry: &DepreciationEntry,
289 posting_date: NaiveDate,
290 ) -> JournalEntry {
291 let mut je = JournalEntry::new_simple(
292 format!("JE-DEP-{}", asset.asset_number),
293 asset.company_code.clone(),
294 posting_date,
295 format!("Depreciation {}", asset.asset_number),
296 );
297
298 je.add_line(JournalEntryLine {
300 line_number: 1,
301 gl_account: entry.expense_account.clone(),
302 debit_amount: entry.depreciation_amount,
303 cost_center: asset.cost_center.clone(),
304 profit_center: asset.profit_center.clone(),
305 reference: Some(asset.asset_number.clone()),
306 ..Default::default()
307 });
308
309 je.add_line(JournalEntryLine {
311 line_number: 2,
312 gl_account: entry.accum_depr_account.clone(),
313 credit_amount: entry.depreciation_amount,
314 reference: Some(asset.asset_number.clone()),
315 ..Default::default()
316 });
317
318 je
319 }
320
321 fn generate_disposal_je(
322 &self,
323 asset: &FixedAssetRecord,
324 disposal: &AssetDisposal,
325 ) -> JournalEntry {
326 let mut je = JournalEntry::new_simple(
327 format!("JE-{}", disposal.disposal_id),
328 asset.company_code.clone(),
329 disposal.disposal_date,
330 format!("Asset Disposal {}", asset.asset_number),
331 );
332
333 let mut line_num = 1;
334
335 if disposal.sale_proceeds > Decimal::ZERO {
337 je.add_line(JournalEntryLine {
338 line_number: line_num,
339 gl_account: cash_accounts::OPERATING_CASH.to_string(),
340 debit_amount: disposal.sale_proceeds,
341 reference: Some(disposal.disposal_id.clone()),
342 ..Default::default()
343 });
344 line_num += 1;
345 }
346
347 je.add_line(JournalEntryLine {
349 line_number: line_num,
350 gl_account: asset
351 .account_determination
352 .accumulated_depreciation_account
353 .clone(),
354 debit_amount: disposal.accumulated_depreciation,
355 reference: Some(disposal.disposal_id.clone()),
356 ..Default::default()
357 });
358 line_num += 1;
359
360 if !disposal.is_gain {
362 je.add_line(JournalEntryLine {
363 line_number: line_num,
364 gl_account: asset.account_determination.loss_on_disposal_account.clone(),
365 debit_amount: disposal.loss(),
366 cost_center: asset.cost_center.clone(),
367 profit_center: asset.profit_center.clone(),
368 reference: Some(disposal.disposal_id.clone()),
369 ..Default::default()
370 });
371 line_num += 1;
372 }
373
374 je.add_line(JournalEntryLine {
376 line_number: line_num,
377 gl_account: asset.account_determination.acquisition_account.clone(),
378 credit_amount: asset.acquisition_cost,
379 reference: Some(disposal.disposal_id.clone()),
380 ..Default::default()
381 });
382 line_num += 1;
383
384 if disposal.is_gain && disposal.gain() > Decimal::ZERO {
386 je.add_line(JournalEntryLine {
387 line_number: line_num,
388 gl_account: asset.account_determination.gain_on_disposal_account.clone(),
389 credit_amount: disposal.gain(),
390 cost_center: asset.cost_center.clone(),
391 profit_center: asset.profit_center.clone(),
392 reference: Some(disposal.disposal_id.clone()),
393 ..Default::default()
394 });
395 }
396
397 je
398 }
399}
400
401#[cfg(test)]
402#[allow(clippy::unwrap_used)]
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}