Skip to main content

datasynth_generators/subledger/
inventory_generator.rs

1//! Inventory generator.
2
3use 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/// Configuration for inventory generation.
16#[derive(Debug, Clone)]
17pub struct InventoryGeneratorConfig {
18    /// Default valuation method.
19    pub default_valuation_method: ValuationMethod,
20    /// Average unit cost.
21    pub avg_unit_cost: Decimal,
22    /// Unit cost variation.
23    pub cost_variation: Decimal,
24    /// Average movement quantity.
25    pub avg_movement_quantity: Decimal,
26    /// Quantity variation.
27    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
42/// Generator for inventory transactions.
43pub 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    /// Creates a new inventory generator.
53    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    /// Generates an initial inventory position.
63    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    /// Generates a goods receipt (inventory increase).
104    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, // item_number
119            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    /// Generates a goods issue (inventory decrease).
145    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, // item_number
161            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    /// Generates a stock transfer between locations.
187    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        // Issue from source
196        self.movement_counter += 1;
197        let issue_id = format!("INVMV{:08}", self.movement_counter);
198
199        // Receipt at destination
200        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, // item_number
208            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, // item_number
228            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        // For intra-company transfer, no GL impact unless different plants have different valuations
249        let je = self.generate_transfer_je(&issue, &receipt);
250
251        (issue, receipt, je)
252    }
253
254    /// Generates an inventory adjustment.
255    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, // item_number
276            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        // Debit Inventory
315        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        // Credit GR/IR Clearing
330        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        // Debit Cost of Goods Sold or WIP
350        let debit_account =
351            if movement.reference_doc_type == Some(ReferenceDocType::ProductionOrder) {
352                "1350".to_string() // WIP
353            } else {
354                "5100".to_string() // COGS
355            };
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        // Credit Inventory
372        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        // For intra-company transfer with same valuation, this might be a memo entry
392        // or could involve plant-specific inventory accounts
393        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        // Debit Inventory at destination (using same account for simplicity)
401        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        // Credit Inventory at source
413        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            // Debit Inventory
441            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            // Credit Inventory Adjustment Account
454            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            // Debit Inventory Adjustment Account (expense)
464            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            // Credit Inventory
475            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}