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