1use chrono::NaiveDate;
4use datasynth_core::accounts::{
5 control_accounts, expense_accounts, inventory_accounts, manufacturing_accounts, tax_accounts,
6};
7use datasynth_core::utils::seeded_rng;
8use rand::RngExt;
9use rand_chacha::ChaCha8Rng;
10use rust_decimal::Decimal;
11use rust_decimal_macros::dec;
12
13use tracing::debug;
14
15use datasynth_core::models::subledger::inventory::{
16 InventoryMovement, InventoryPosition, MovementType, PositionValuation, ReferenceDocType,
17 ValuationMethod,
18};
19use datasynth_core::models::{JournalEntry, JournalEntryLine};
20
21#[derive(Debug, Clone)]
38pub struct InventoryGeneratorConfig {
39 pub default_valuation_method: ValuationMethod,
41 pub avg_unit_cost: Decimal,
43 pub cost_variation: Decimal,
45 pub avg_movement_quantity: Decimal,
47 pub quantity_variation: Decimal,
49}
50
51impl Default for InventoryGeneratorConfig {
52 fn default() -> Self {
53 Self {
54 default_valuation_method: ValuationMethod::MovingAverage,
55 avg_unit_cost: dec!(100),
56 cost_variation: dec!(0.5),
57 avg_movement_quantity: dec!(50),
58 quantity_variation: dec!(0.8),
59 }
60 }
61}
62
63pub struct InventoryGenerator {
65 config: InventoryGeneratorConfig,
66 rng: ChaCha8Rng,
67 movement_counter: u64,
68 currency: String,
70}
71
72impl InventoryGenerator {
73 pub fn new_with_currency(
75 config: InventoryGeneratorConfig,
76 rng: ChaCha8Rng,
77 currency: String,
78 ) -> Self {
79 Self {
80 config,
81 rng,
82 movement_counter: 0,
83 currency,
84 }
85 }
86
87 pub fn new(config: InventoryGeneratorConfig, rng: ChaCha8Rng) -> Self {
89 Self::new_with_currency(config, rng, "USD".to_string())
90 }
91
92 pub fn with_seed(config: InventoryGeneratorConfig, seed: u64) -> Self {
94 Self::new(config, seeded_rng(seed, 0))
95 }
96
97 pub fn generate_position(
99 &mut self,
100 company_code: &str,
101 plant: &str,
102 storage_location: &str,
103 material_id: &str,
104 description: &str,
105 initial_quantity: Decimal,
106 unit_cost: Option<Decimal>,
107 _currency: &str,
108 ) -> InventoryPosition {
109 debug!(company_code, plant, material_id, %initial_quantity, "Generating inventory position");
110 let cost = unit_cost.unwrap_or_else(|| self.generate_unit_cost());
111 let total_value = (initial_quantity * cost).round_dp(2);
112
113 let mut position = InventoryPosition::new(
114 material_id.to_string(),
115 description.to_string(),
116 plant.to_string(),
117 storage_location.to_string(),
118 company_code.to_string(),
119 "EA".to_string(),
120 );
121
122 position.quantity_on_hand = initial_quantity;
123 position.quantity_available = initial_quantity;
124 position.valuation = PositionValuation {
125 method: self.config.default_valuation_method,
126 standard_cost: cost,
127 unit_cost: cost,
128 total_value,
129 price_variance: Decimal::ZERO,
130 last_price_change: None,
131 };
132 position.min_stock = Some(dec!(10));
133 position.max_stock = Some(dec!(1000));
134 position.reorder_point = Some(dec!(50));
135
136 position
137 }
138
139 pub fn generate_goods_receipt(
141 &mut self,
142 position: &mut InventoryPosition,
143 receipt_date: NaiveDate,
144 quantity: Decimal,
145 unit_cost: Decimal,
146 po_number: Option<&str>,
147 ) -> (InventoryMovement, JournalEntry) {
148 self.movement_counter += 1;
149 let document_number = format!("INVMV{:08}", self.movement_counter);
150 let batch_number = format!("BATCH{:06}", self.rng.random::<u32>() % 1000000);
151
152 let mut movement = InventoryMovement::new(
153 document_number,
154 1, position.company_code.clone(),
156 receipt_date,
157 MovementType::GoodsReceipt,
158 position.material_id.clone(),
159 position.description.clone(),
160 position.plant.clone(),
161 position.storage_location.clone(),
162 quantity,
163 position.unit.clone(),
164 unit_cost,
165 self.currency.clone(),
166 "SYSTEM".to_string(),
167 );
168
169 movement.batch_number = Some(batch_number);
170 if let Some(po) = po_number {
171 movement.reference_doc_type = Some(ReferenceDocType::PurchaseOrder);
172 movement.reference_doc_number = Some(po.to_string());
173 }
174 movement.reason_code = Some("Goods Receipt from PO".to_string());
175
176 position.add_quantity(quantity, unit_cost, receipt_date);
181
182 let je = self.generate_goods_receipt_je(&movement);
183 (movement, je)
184 }
185
186 pub fn generate_goods_issue(
188 &mut self,
189 position: &InventoryPosition,
190 issue_date: NaiveDate,
191 quantity: Decimal,
192 cost_center: Option<&str>,
193 production_order: Option<&str>,
194 ) -> (InventoryMovement, JournalEntry) {
195 self.movement_counter += 1;
196 let document_number = format!("INVMV{:08}", self.movement_counter);
197
198 let unit_cost = position.valuation.unit_cost;
199
200 let mut movement = InventoryMovement::new(
201 document_number,
202 1, position.company_code.clone(),
204 issue_date,
205 MovementType::GoodsIssue,
206 position.material_id.clone(),
207 position.description.clone(),
208 position.plant.clone(),
209 position.storage_location.clone(),
210 quantity,
211 position.unit.clone(),
212 unit_cost,
213 self.currency.clone(),
214 "SYSTEM".to_string(),
215 );
216
217 movement.cost_center = cost_center.map(std::string::ToString::to_string);
218 if let Some(po) = production_order {
219 movement.reference_doc_type = Some(ReferenceDocType::ProductionOrder);
220 movement.reference_doc_number = Some(po.to_string());
221 }
222 movement.reason_code = Some("Goods Issue to Production".to_string());
223
224 let je = self.generate_goods_issue_je(&movement);
225 (movement, je)
226 }
227
228 pub fn generate_transfer(
230 &mut self,
231 position: &InventoryPosition,
232 transfer_date: NaiveDate,
233 quantity: Decimal,
234 to_plant: &str,
235 to_storage_location: &str,
236 ) -> (InventoryMovement, InventoryMovement, JournalEntry) {
237 self.movement_counter += 1;
239 let issue_id = format!("INVMV{:08}", self.movement_counter);
240
241 self.movement_counter += 1;
243 let receipt_id = format!("INVMV{:08}", self.movement_counter);
244
245 let unit_cost = position.valuation.unit_cost;
246
247 let mut issue = InventoryMovement::new(
248 issue_id,
249 1, position.company_code.clone(),
251 transfer_date,
252 MovementType::TransferOut,
253 position.material_id.clone(),
254 position.description.clone(),
255 position.plant.clone(),
256 position.storage_location.clone(),
257 quantity,
258 position.unit.clone(),
259 unit_cost,
260 self.currency.clone(),
261 "SYSTEM".to_string(),
262 );
263 issue.reference_doc_type = Some(ReferenceDocType::MaterialDocument);
264 issue.reference_doc_number = Some(receipt_id.clone());
265 issue.reason_code = Some(format!("Transfer to {to_plant}/{to_storage_location}"));
266
267 let mut receipt = InventoryMovement::new(
268 receipt_id,
269 1, position.company_code.clone(),
271 transfer_date,
272 MovementType::TransferIn,
273 position.material_id.clone(),
274 position.description.clone(),
275 to_plant.to_string(),
276 to_storage_location.to_string(),
277 quantity,
278 position.unit.clone(),
279 unit_cost,
280 self.currency.clone(),
281 "SYSTEM".to_string(),
282 );
283 receipt.reference_doc_type = Some(ReferenceDocType::MaterialDocument);
284 receipt.reference_doc_number = Some(issue.document_number.clone());
285 receipt.reason_code = Some(format!(
286 "Transfer from {}/{}",
287 position.plant, position.storage_location
288 ));
289
290 let je = self.generate_transfer_je(&issue, &receipt);
292
293 (issue, receipt, je)
294 }
295
296 pub fn generate_adjustment(
298 &mut self,
299 position: &InventoryPosition,
300 adjustment_date: NaiveDate,
301 quantity_change: Decimal,
302 reason: &str,
303 ) -> (InventoryMovement, JournalEntry) {
304 self.movement_counter += 1;
305 let document_number = format!("INVMV{:08}", self.movement_counter);
306
307 let movement_type = if quantity_change > Decimal::ZERO {
308 MovementType::InventoryAdjustmentIn
309 } else {
310 MovementType::InventoryAdjustmentOut
311 };
312
313 let unit_cost = position.valuation.unit_cost;
314
315 let mut movement = InventoryMovement::new(
316 document_number,
317 1, position.company_code.clone(),
319 adjustment_date,
320 movement_type,
321 position.material_id.clone(),
322 position.description.clone(),
323 position.plant.clone(),
324 position.storage_location.clone(),
325 quantity_change.abs(),
326 position.unit.clone(),
327 unit_cost,
328 self.currency.clone(),
329 "SYSTEM".to_string(),
330 );
331 movement.reference_doc_type = Some(ReferenceDocType::PhysicalInventoryDoc);
332 movement.reference_doc_number = Some(format!("PI{:08}", self.movement_counter));
333 movement.reason_code = Some(reason.to_string());
334
335 let je = self.generate_adjustment_je(&movement, quantity_change > Decimal::ZERO);
336 (movement, je)
337 }
338
339 fn generate_unit_cost(&mut self) -> Decimal {
340 let base = self.config.avg_unit_cost;
341 let variation = base * self.config.cost_variation;
342 let random: f64 = self.rng.random_range(-1.0..1.0);
343 (base + variation * Decimal::try_from(random).unwrap_or_default())
344 .max(dec!(1))
345 .round_dp(2)
346 }
347
348 fn generate_goods_receipt_je(&self, movement: &InventoryMovement) -> JournalEntry {
349 let mut je = JournalEntry::new_simple(
350 format!("JE-{}", movement.document_number),
351 movement.company_code.clone(),
352 movement.posting_date,
353 format!("Goods Receipt {}", movement.material_id),
354 );
355
356 je.add_line(JournalEntryLine {
358 line_number: 1,
359 gl_account: control_accounts::INVENTORY.to_string(),
360 debit_amount: movement.value,
361 cost_center: movement.cost_center.clone(),
362 profit_center: None,
363 reference: Some(movement.document_number.clone()),
364 assignment: Some(movement.material_id.clone()),
365 text: Some(movement.description.clone()),
366 quantity: Some(movement.quantity),
367 unit: Some(movement.unit.clone()),
368 ..Default::default()
369 });
370
371 je.add_line(JournalEntryLine {
373 line_number: 2,
374 gl_account: tax_accounts::SALES_TAX_PAYABLE.to_string(),
375 credit_amount: movement.value,
376 reference: movement.reference_doc_number.clone(),
377 ..Default::default()
378 });
379
380 je
381 }
382
383 fn generate_goods_issue_je(&self, movement: &InventoryMovement) -> JournalEntry {
384 let mut je = JournalEntry::new_simple(
385 format!("JE-{}", movement.document_number),
386 movement.company_code.clone(),
387 movement.posting_date,
388 format!("Goods Issue {}", movement.material_id),
389 );
390
391 let debit_account =
393 if movement.reference_doc_type == Some(ReferenceDocType::ProductionOrder) {
394 manufacturing_accounts::WIP.to_string()
395 } else {
396 expense_accounts::RAW_MATERIALS.to_string()
397 };
398
399 je.add_line(JournalEntryLine {
400 line_number: 1,
401 gl_account: debit_account,
402 debit_amount: movement.value,
403 cost_center: movement.cost_center.clone(),
404 profit_center: None,
405 reference: Some(movement.document_number.clone()),
406 assignment: Some(movement.material_id.clone()),
407 text: Some(movement.description.clone()),
408 quantity: Some(movement.quantity),
409 unit: Some(movement.unit.clone()),
410 ..Default::default()
411 });
412
413 je.add_line(JournalEntryLine {
415 line_number: 2,
416 gl_account: control_accounts::INVENTORY.to_string(),
417 credit_amount: movement.value,
418 reference: Some(movement.document_number.clone()),
419 assignment: Some(movement.material_id.clone()),
420 quantity: Some(movement.quantity),
421 unit: Some(movement.unit.clone()),
422 ..Default::default()
423 });
424
425 je
426 }
427
428 fn generate_transfer_je(
429 &self,
430 issue: &InventoryMovement,
431 _receipt: &InventoryMovement,
432 ) -> JournalEntry {
433 let mut je = JournalEntry::new_simple(
436 format!("JE-XFER-{}", issue.document_number),
437 issue.company_code.clone(),
438 issue.posting_date,
439 format!("Stock Transfer {}", issue.material_id),
440 );
441
442 je.add_line(JournalEntryLine {
444 line_number: 1,
445 gl_account: control_accounts::INVENTORY.to_string(),
446 debit_amount: issue.value,
447 reference: Some(issue.document_number.clone()),
448 assignment: Some(issue.material_id.clone()),
449 quantity: Some(issue.quantity),
450 unit: Some(issue.unit.clone()),
451 ..Default::default()
452 });
453
454 je.add_line(JournalEntryLine {
456 line_number: 2,
457 gl_account: control_accounts::INVENTORY.to_string(),
458 credit_amount: issue.value,
459 reference: Some(issue.document_number.clone()),
460 assignment: Some(issue.material_id.clone()),
461 quantity: Some(issue.quantity),
462 unit: Some(issue.unit.clone()),
463 ..Default::default()
464 });
465
466 je
467 }
468
469 fn generate_adjustment_je(
470 &self,
471 movement: &InventoryMovement,
472 is_increase: bool,
473 ) -> JournalEntry {
474 let mut je = JournalEntry::new_simple(
475 format!("JE-{}", movement.document_number),
476 movement.company_code.clone(),
477 movement.posting_date,
478 format!("Inventory Adjustment {}", movement.material_id),
479 );
480
481 if is_increase {
482 je.add_line(JournalEntryLine {
484 line_number: 1,
485 gl_account: control_accounts::INVENTORY.to_string(),
486 debit_amount: movement.value,
487 reference: Some(movement.document_number.clone()),
488 assignment: Some(movement.material_id.clone()),
489 text: Some(movement.reason_code.clone().unwrap_or_default()),
490 quantity: Some(movement.quantity),
491 unit: Some(movement.unit.clone()),
492 ..Default::default()
493 });
494
495 je.add_line(JournalEntryLine {
497 line_number: 2,
498 gl_account: inventory_accounts::WRITEUP_INCOME.to_string(),
499 credit_amount: movement.value,
500 cost_center: movement.cost_center.clone(),
501 reference: Some(movement.document_number.clone()),
502 ..Default::default()
503 });
504 } else {
505 je.add_line(JournalEntryLine {
507 line_number: 1,
508 gl_account: inventory_accounts::WRITEDOWN_EXPENSE.to_string(),
509 debit_amount: movement.value,
510 cost_center: movement.cost_center.clone(),
511 reference: Some(movement.document_number.clone()),
512 text: Some(movement.reason_code.clone().unwrap_or_default()),
513 ..Default::default()
514 });
515
516 je.add_line(JournalEntryLine {
518 line_number: 2,
519 gl_account: control_accounts::INVENTORY.to_string(),
520 credit_amount: movement.value,
521 reference: Some(movement.document_number.clone()),
522 assignment: Some(movement.material_id.clone()),
523 quantity: Some(movement.quantity),
524 unit: Some(movement.unit.clone()),
525 ..Default::default()
526 });
527 }
528
529 je
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use rand::SeedableRng;
537
538 #[test]
539 fn test_generate_position() {
540 let rng = ChaCha8Rng::seed_from_u64(12345);
541 let mut generator = InventoryGenerator::new(InventoryGeneratorConfig::default(), rng);
542
543 let position = generator.generate_position(
544 "1000",
545 "PLANT01",
546 "WH01",
547 "MAT001",
548 "Raw Material A",
549 dec!(100),
550 None,
551 "USD",
552 );
553
554 assert_eq!(position.quantity_on_hand, dec!(100));
555 assert!(position.valuation.unit_cost > Decimal::ZERO);
556 }
557
558 #[test]
559 fn test_generate_goods_receipt() {
560 let rng = ChaCha8Rng::seed_from_u64(12345);
561 let mut generator = InventoryGenerator::new(InventoryGeneratorConfig::default(), rng);
562
563 let mut position = generator.generate_position(
564 "1000",
565 "PLANT01",
566 "WH01",
567 "MAT001",
568 "Raw Material A",
569 dec!(100),
570 Some(dec!(50)),
571 "USD",
572 );
573
574 let (movement, je) = generator.generate_goods_receipt(
575 &mut position,
576 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
577 dec!(50),
578 dec!(50),
579 Some("PO001"),
580 );
581
582 assert_eq!(movement.movement_type, MovementType::GoodsReceipt);
583 assert_eq!(movement.quantity, dec!(50));
584 assert!(je.is_balanced());
585 }
586
587 #[test]
588 fn test_generate_goods_issue() {
589 let rng = ChaCha8Rng::seed_from_u64(12345);
590 let mut generator = InventoryGenerator::new(InventoryGeneratorConfig::default(), rng);
591
592 let position = generator.generate_position(
593 "1000",
594 "PLANT01",
595 "WH01",
596 "MAT001",
597 "Raw Material A",
598 dec!(100),
599 Some(dec!(50)),
600 "USD",
601 );
602
603 let (movement, je) = generator.generate_goods_issue(
604 &position,
605 NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
606 dec!(30),
607 Some("CC100"),
608 None,
609 );
610
611 assert_eq!(movement.movement_type, MovementType::GoodsIssue);
612 assert_eq!(movement.quantity, dec!(30));
613 assert!(je.is_balanced());
614 }
615}