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