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