Skip to main content

datasynth_generators/subledger/
inventory_generator.rs

1//! Inventory generator.
2
3use chrono::NaiveDate;
4use datasynth_core::accounts::{
5    control_accounts, expense_accounts, inventory_accounts, manufacturing_accounts, tax_accounts,
6};
7use datasynth_core::utils::seeded_rng;
8use rand::RngExt;
9use rand_chacha::ChaCha8Rng;
10use rust_decimal::Decimal;
11use rust_decimal_macros::dec;
12
13use tracing::debug;
14
15use datasynth_core::models::subledger::inventory::{
16    InventoryMovement, InventoryPosition, MovementType, PositionValuation, ReferenceDocType,
17    ValuationMethod,
18};
19use datasynth_core::models::{JournalEntry, JournalEntryLine};
20
21/// Configuration for inventory generation.
22///
23/// ## Moving-average valuation note
24///
25/// When `default_valuation_method` is [`ValuationMethod::MovingAverage`], the
26/// generator updates the weighted-average unit cost after each goods receipt via
27/// `InventoryPosition::add_quantity`. The moving-average formula is:
28///
29/// ```text
30/// new_avg_cost = (existing_qty × existing_cost + receipt_qty × receipt_cost)
31///                / (existing_qty + receipt_qty)
32/// ```
33///
34/// `generate_goods_receipt` now accepts `&mut InventoryPosition` so it can update
35/// the position in-place, ensuring `valuation.unit_cost` reflects the true
36/// moving-average after each receipt.
37#[derive(Debug, Clone)]
38pub struct InventoryGeneratorConfig {
39    /// Default valuation method.
40    pub default_valuation_method: ValuationMethod,
41    /// Average unit cost.
42    pub avg_unit_cost: Decimal,
43    /// Unit cost variation.
44    pub cost_variation: Decimal,
45    /// Average movement quantity.
46    pub avg_movement_quantity: Decimal,
47    /// Quantity variation.
48    pub quantity_variation: Decimal,
49}
50
51impl Default for InventoryGeneratorConfig {
52    fn default() -> Self {
53        Self {
54            default_valuation_method: ValuationMethod::MovingAverage,
55            avg_unit_cost: dec!(100),
56            cost_variation: dec!(0.5),
57            avg_movement_quantity: dec!(50),
58            quantity_variation: dec!(0.8),
59        }
60    }
61}
62
63/// Generator for inventory transactions.
64pub struct InventoryGenerator {
65    config: InventoryGeneratorConfig,
66    rng: ChaCha8Rng,
67    movement_counter: u64,
68    /// Currency code for generated movements and journal entries.
69    currency: String,
70}
71
72impl InventoryGenerator {
73    /// Creates a new inventory generator with the specified currency.
74    pub fn new_with_currency(
75        config: InventoryGeneratorConfig,
76        rng: ChaCha8Rng,
77        currency: String,
78    ) -> Self {
79        Self {
80            config,
81            rng,
82            movement_counter: 0,
83            currency,
84        }
85    }
86
87    /// Creates a new inventory generator (defaults to USD).
88    pub fn new(config: InventoryGeneratorConfig, rng: ChaCha8Rng) -> Self {
89        Self::new_with_currency(config, rng, "USD".to_string())
90    }
91
92    /// Creates a new inventory generator from a seed, constructing the RNG internally.
93    pub fn with_seed(config: InventoryGeneratorConfig, seed: u64) -> Self {
94        Self::new(config, seeded_rng(seed, 0))
95    }
96
97    /// Generates an initial inventory position.
98    pub fn generate_position(
99        &mut self,
100        company_code: &str,
101        plant: &str,
102        storage_location: &str,
103        material_id: &str,
104        description: &str,
105        initial_quantity: Decimal,
106        unit_cost: Option<Decimal>,
107        _currency: &str,
108    ) -> InventoryPosition {
109        debug!(company_code, plant, material_id, %initial_quantity, "Generating inventory position");
110        let cost = unit_cost.unwrap_or_else(|| self.generate_unit_cost());
111        let total_value = (initial_quantity * cost).round_dp(2);
112
113        let mut position = InventoryPosition::new(
114            material_id.to_string(),
115            description.to_string(),
116            plant.to_string(),
117            storage_location.to_string(),
118            company_code.to_string(),
119            "EA".to_string(),
120        );
121
122        position.quantity_on_hand = initial_quantity;
123        position.quantity_available = initial_quantity;
124        position.valuation = PositionValuation {
125            method: self.config.default_valuation_method,
126            standard_cost: cost,
127            unit_cost: cost,
128            total_value,
129            price_variance: Decimal::ZERO,
130            last_price_change: None,
131        };
132        position.min_stock = Some(dec!(10));
133        position.max_stock = Some(dec!(1000));
134        position.reorder_point = Some(dec!(50));
135
136        position
137    }
138
139    /// Generates a goods receipt (inventory increase).
140    pub fn generate_goods_receipt(
141        &mut self,
142        position: &mut InventoryPosition,
143        receipt_date: NaiveDate,
144        quantity: Decimal,
145        unit_cost: Decimal,
146        po_number: Option<&str>,
147    ) -> (InventoryMovement, JournalEntry) {
148        self.movement_counter += 1;
149        let document_number = format!("INVMV{:08}", self.movement_counter);
150        let batch_number = format!("BATCH{:06}", self.rng.random::<u32>() % 1000000);
151
152        let mut movement = InventoryMovement::new(
153            document_number,
154            1, // item_number
155            position.company_code.clone(),
156            receipt_date,
157            MovementType::GoodsReceipt,
158            position.material_id.clone(),
159            position.description.clone(),
160            position.plant.clone(),
161            position.storage_location.clone(),
162            quantity,
163            position.unit.clone(),
164            unit_cost,
165            self.currency.clone(),
166            "SYSTEM".to_string(),
167        );
168
169        movement.batch_number = Some(batch_number);
170        if let Some(po) = po_number {
171            movement.reference_doc_type = Some(ReferenceDocType::PurchaseOrder);
172            movement.reference_doc_number = Some(po.to_string());
173        }
174        movement.reason_code = Some("Goods Receipt from PO".to_string());
175
176        // Update the position's moving-average unit cost and quantity.
177        // For MovingAverage valuation this recalculates:
178        //   new_avg_cost = (old_qty × old_cost + receipt_qty × receipt_cost)
179        //                  / (old_qty + receipt_qty)
180        position.add_quantity(quantity, unit_cost, receipt_date);
181
182        let je = self.generate_goods_receipt_je(&movement);
183        (movement, je)
184    }
185
186    /// Generates a goods issue (inventory decrease).
187    pub fn generate_goods_issue(
188        &mut self,
189        position: &InventoryPosition,
190        issue_date: NaiveDate,
191        quantity: Decimal,
192        cost_center: Option<&str>,
193        production_order: Option<&str>,
194    ) -> (InventoryMovement, JournalEntry) {
195        self.movement_counter += 1;
196        let document_number = format!("INVMV{:08}", self.movement_counter);
197
198        let unit_cost = position.valuation.unit_cost;
199
200        let mut movement = InventoryMovement::new(
201            document_number,
202            1, // item_number
203            position.company_code.clone(),
204            issue_date,
205            MovementType::GoodsIssue,
206            position.material_id.clone(),
207            position.description.clone(),
208            position.plant.clone(),
209            position.storage_location.clone(),
210            quantity,
211            position.unit.clone(),
212            unit_cost,
213            self.currency.clone(),
214            "SYSTEM".to_string(),
215        );
216
217        movement.cost_center = cost_center.map(std::string::ToString::to_string);
218        if let Some(po) = production_order {
219            movement.reference_doc_type = Some(ReferenceDocType::ProductionOrder);
220            movement.reference_doc_number = Some(po.to_string());
221        }
222        movement.reason_code = Some("Goods Issue to Production".to_string());
223
224        let je = self.generate_goods_issue_je(&movement);
225        (movement, je)
226    }
227
228    /// Generates a stock transfer between locations.
229    pub fn generate_transfer(
230        &mut self,
231        position: &InventoryPosition,
232        transfer_date: NaiveDate,
233        quantity: Decimal,
234        to_plant: &str,
235        to_storage_location: &str,
236    ) -> (InventoryMovement, InventoryMovement, JournalEntry) {
237        // Issue from source
238        self.movement_counter += 1;
239        let issue_id = format!("INVMV{:08}", self.movement_counter);
240
241        // Receipt at destination
242        self.movement_counter += 1;
243        let receipt_id = format!("INVMV{:08}", self.movement_counter);
244
245        let unit_cost = position.valuation.unit_cost;
246
247        let mut issue = InventoryMovement::new(
248            issue_id,
249            1, // item_number
250            position.company_code.clone(),
251            transfer_date,
252            MovementType::TransferOut,
253            position.material_id.clone(),
254            position.description.clone(),
255            position.plant.clone(),
256            position.storage_location.clone(),
257            quantity,
258            position.unit.clone(),
259            unit_cost,
260            self.currency.clone(),
261            "SYSTEM".to_string(),
262        );
263        issue.reference_doc_type = Some(ReferenceDocType::MaterialDocument);
264        issue.reference_doc_number = Some(receipt_id.clone());
265        issue.reason_code = Some(format!("Transfer to {to_plant}/{to_storage_location}"));
266
267        let mut receipt = InventoryMovement::new(
268            receipt_id,
269            1, // item_number
270            position.company_code.clone(),
271            transfer_date,
272            MovementType::TransferIn,
273            position.material_id.clone(),
274            position.description.clone(),
275            to_plant.to_string(),
276            to_storage_location.to_string(),
277            quantity,
278            position.unit.clone(),
279            unit_cost,
280            self.currency.clone(),
281            "SYSTEM".to_string(),
282        );
283        receipt.reference_doc_type = Some(ReferenceDocType::MaterialDocument);
284        receipt.reference_doc_number = Some(issue.document_number.clone());
285        receipt.reason_code = Some(format!(
286            "Transfer from {}/{}",
287            position.plant, position.storage_location
288        ));
289
290        // For intra-company transfer, no GL impact unless different plants have different valuations
291        let je = self.generate_transfer_je(&issue, &receipt);
292
293        (issue, receipt, je)
294    }
295
296    /// Generates an inventory adjustment.
297    pub fn generate_adjustment(
298        &mut self,
299        position: &InventoryPosition,
300        adjustment_date: NaiveDate,
301        quantity_change: Decimal,
302        reason: &str,
303    ) -> (InventoryMovement, JournalEntry) {
304        self.movement_counter += 1;
305        let document_number = format!("INVMV{:08}", self.movement_counter);
306
307        let movement_type = if quantity_change > Decimal::ZERO {
308            MovementType::InventoryAdjustmentIn
309        } else {
310            MovementType::InventoryAdjustmentOut
311        };
312
313        let unit_cost = position.valuation.unit_cost;
314
315        let mut movement = InventoryMovement::new(
316            document_number,
317            1, // item_number
318            position.company_code.clone(),
319            adjustment_date,
320            movement_type,
321            position.material_id.clone(),
322            position.description.clone(),
323            position.plant.clone(),
324            position.storage_location.clone(),
325            quantity_change.abs(),
326            position.unit.clone(),
327            unit_cost,
328            self.currency.clone(),
329            "SYSTEM".to_string(),
330        );
331        movement.reference_doc_type = Some(ReferenceDocType::PhysicalInventoryDoc);
332        movement.reference_doc_number = Some(format!("PI{:08}", self.movement_counter));
333        movement.reason_code = Some(reason.to_string());
334
335        let je = self.generate_adjustment_je(&movement, quantity_change > Decimal::ZERO);
336        (movement, je)
337    }
338
339    fn generate_unit_cost(&mut self) -> Decimal {
340        let base = self.config.avg_unit_cost;
341        let variation = base * self.config.cost_variation;
342        let random: f64 = self.rng.random_range(-1.0..1.0);
343        (base + variation * Decimal::try_from(random).unwrap_or_default())
344            .max(dec!(1))
345            .round_dp(2)
346    }
347
348    fn generate_goods_receipt_je(&self, movement: &InventoryMovement) -> JournalEntry {
349        let mut je = JournalEntry::new_simple(
350            format!("JE-{}", movement.document_number),
351            movement.company_code.clone(),
352            movement.posting_date,
353            format!("Goods Receipt {}", movement.material_id),
354        );
355
356        // Debit Inventory
357        je.add_line(JournalEntryLine {
358            line_number: 1,
359            gl_account: control_accounts::INVENTORY.to_string(),
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 GR/IR Clearing
372        je.add_line(JournalEntryLine {
373            line_number: 2,
374            gl_account: tax_accounts::SALES_TAX_PAYABLE.to_string(),
375            credit_amount: movement.value,
376            reference: movement.reference_doc_number.clone(),
377            ..Default::default()
378        });
379
380        je
381    }
382
383    fn generate_goods_issue_je(&self, movement: &InventoryMovement) -> JournalEntry {
384        let mut je = JournalEntry::new_simple(
385            format!("JE-{}", movement.document_number),
386            movement.company_code.clone(),
387            movement.posting_date,
388            format!("Goods Issue {}", movement.material_id),
389        );
390
391        // Debit Cost of Goods Sold or WIP
392        let debit_account =
393            if movement.reference_doc_type == Some(ReferenceDocType::ProductionOrder) {
394                manufacturing_accounts::WIP.to_string()
395            } else {
396                expense_accounts::RAW_MATERIALS.to_string()
397            };
398
399        je.add_line(JournalEntryLine {
400            line_number: 1,
401            gl_account: debit_account,
402            debit_amount: movement.value,
403            cost_center: movement.cost_center.clone(),
404            profit_center: None,
405            reference: Some(movement.document_number.clone()),
406            assignment: Some(movement.material_id.clone()),
407            text: Some(movement.description.clone()),
408            quantity: Some(movement.quantity),
409            unit: Some(movement.unit.clone()),
410            ..Default::default()
411        });
412
413        // Credit Inventory
414        je.add_line(JournalEntryLine {
415            line_number: 2,
416            gl_account: control_accounts::INVENTORY.to_string(),
417            credit_amount: movement.value,
418            reference: Some(movement.document_number.clone()),
419            assignment: Some(movement.material_id.clone()),
420            quantity: Some(movement.quantity),
421            unit: Some(movement.unit.clone()),
422            ..Default::default()
423        });
424
425        je
426    }
427
428    fn generate_transfer_je(
429        &self,
430        issue: &InventoryMovement,
431        _receipt: &InventoryMovement,
432    ) -> JournalEntry {
433        // For intra-company transfer with same valuation, this might be a memo entry
434        // or could involve plant-specific inventory accounts
435        let mut je = JournalEntry::new_simple(
436            format!("JE-XFER-{}", issue.document_number),
437            issue.company_code.clone(),
438            issue.posting_date,
439            format!("Stock Transfer {}", issue.material_id),
440        );
441
442        // Debit Inventory at destination (using same account for simplicity)
443        je.add_line(JournalEntryLine {
444            line_number: 1,
445            gl_account: control_accounts::INVENTORY.to_string(),
446            debit_amount: issue.value,
447            reference: Some(issue.document_number.clone()),
448            assignment: Some(issue.material_id.clone()),
449            quantity: Some(issue.quantity),
450            unit: Some(issue.unit.clone()),
451            ..Default::default()
452        });
453
454        // Credit Inventory at source
455        je.add_line(JournalEntryLine {
456            line_number: 2,
457            gl_account: control_accounts::INVENTORY.to_string(),
458            credit_amount: issue.value,
459            reference: Some(issue.document_number.clone()),
460            assignment: Some(issue.material_id.clone()),
461            quantity: Some(issue.quantity),
462            unit: Some(issue.unit.clone()),
463            ..Default::default()
464        });
465
466        je
467    }
468
469    fn generate_adjustment_je(
470        &self,
471        movement: &InventoryMovement,
472        is_increase: bool,
473    ) -> JournalEntry {
474        let mut je = JournalEntry::new_simple(
475            format!("JE-{}", movement.document_number),
476            movement.company_code.clone(),
477            movement.posting_date,
478            format!("Inventory Adjustment {}", movement.material_id),
479        );
480
481        if is_increase {
482            // Debit Inventory
483            je.add_line(JournalEntryLine {
484                line_number: 1,
485                gl_account: control_accounts::INVENTORY.to_string(),
486                debit_amount: movement.value,
487                reference: Some(movement.document_number.clone()),
488                assignment: Some(movement.material_id.clone()),
489                text: Some(movement.reason_code.clone().unwrap_or_default()),
490                quantity: Some(movement.quantity),
491                unit: Some(movement.unit.clone()),
492                ..Default::default()
493            });
494
495            // Credit Inventory Adjustment Account
496            je.add_line(JournalEntryLine {
497                line_number: 2,
498                gl_account: inventory_accounts::WRITEUP_INCOME.to_string(),
499                credit_amount: movement.value,
500                cost_center: movement.cost_center.clone(),
501                reference: Some(movement.document_number.clone()),
502                ..Default::default()
503            });
504        } else {
505            // Debit Inventory Adjustment Account (expense)
506            je.add_line(JournalEntryLine {
507                line_number: 1,
508                gl_account: inventory_accounts::WRITEDOWN_EXPENSE.to_string(),
509                debit_amount: movement.value,
510                cost_center: movement.cost_center.clone(),
511                reference: Some(movement.document_number.clone()),
512                text: Some(movement.reason_code.clone().unwrap_or_default()),
513                ..Default::default()
514            });
515
516            // Credit Inventory
517            je.add_line(JournalEntryLine {
518                line_number: 2,
519                gl_account: control_accounts::INVENTORY.to_string(),
520                credit_amount: movement.value,
521                reference: Some(movement.document_number.clone()),
522                assignment: Some(movement.material_id.clone()),
523                quantity: Some(movement.quantity),
524                unit: Some(movement.unit.clone()),
525                ..Default::default()
526            });
527        }
528
529        je
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use rand::SeedableRng;
537
538    #[test]
539    fn test_generate_position() {
540        let rng = ChaCha8Rng::seed_from_u64(12345);
541        let mut generator = InventoryGenerator::new(InventoryGeneratorConfig::default(), rng);
542
543        let position = generator.generate_position(
544            "1000",
545            "PLANT01",
546            "WH01",
547            "MAT001",
548            "Raw Material A",
549            dec!(100),
550            None,
551            "USD",
552        );
553
554        assert_eq!(position.quantity_on_hand, dec!(100));
555        assert!(position.valuation.unit_cost > Decimal::ZERO);
556    }
557
558    #[test]
559    fn test_generate_goods_receipt() {
560        let rng = ChaCha8Rng::seed_from_u64(12345);
561        let mut generator = InventoryGenerator::new(InventoryGeneratorConfig::default(), rng);
562
563        let mut position = generator.generate_position(
564            "1000",
565            "PLANT01",
566            "WH01",
567            "MAT001",
568            "Raw Material A",
569            dec!(100),
570            Some(dec!(50)),
571            "USD",
572        );
573
574        let (movement, je) = generator.generate_goods_receipt(
575            &mut position,
576            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
577            dec!(50),
578            dec!(50),
579            Some("PO001"),
580        );
581
582        assert_eq!(movement.movement_type, MovementType::GoodsReceipt);
583        assert_eq!(movement.quantity, dec!(50));
584        assert!(je.is_balanced());
585    }
586
587    #[test]
588    fn test_generate_goods_issue() {
589        let rng = ChaCha8Rng::seed_from_u64(12345);
590        let mut generator = InventoryGenerator::new(InventoryGeneratorConfig::default(), rng);
591
592        let position = generator.generate_position(
593            "1000",
594            "PLANT01",
595            "WH01",
596            "MAT001",
597            "Raw Material A",
598            dec!(100),
599            Some(dec!(50)),
600            "USD",
601        );
602
603        let (movement, je) = generator.generate_goods_issue(
604            &position,
605            NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
606            dec!(30),
607            Some("CC100"),
608            None,
609        );
610
611        assert_eq!(movement.movement_type, MovementType::GoodsIssue);
612        assert_eq!(movement.quantity, dec!(30));
613        assert!(je.is_balanced());
614    }
615}