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