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