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