Skip to main content

datasynth_generators/subledger/
inventory_generator.rs

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