Skip to main content

datasynth_generators/manufacturing/
inventory_movement_generator.rs

1//! Inventory movement generator for manufacturing processes.
2//!
3//! Generates stock movements (goods receipts, goods issues, transfers,
4//! returns, scrap, and adjustments) tied to production orders and purchase
5//! orders for realistic warehouse flow simulation.
6
7use chrono::NaiveDate;
8use datasynth_core::models::{InventoryMovement, MovementType};
9use datasynth_core::utils::seeded_rng;
10use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
11use rand::prelude::*;
12use rand_chacha::ChaCha8Rng;
13use rust_decimal::Decimal;
14use tracing::debug;
15
16/// Storage locations for movement generation.
17const STORAGE_LOCATIONS: &[&str] = &[
18    "WH01-A01", "WH01-A02", "WH01-B01", "WH02-A01", "WH02-B01", "WH03-A01",
19];
20
21/// Generates [`InventoryMovement`] records for warehouse stock flow.
22pub struct InventoryMovementGenerator {
23    rng: ChaCha8Rng,
24    uuid_factory: DeterministicUuidFactory,
25}
26
27impl InventoryMovementGenerator {
28    /// Create a new inventory movement generator with the given seed.
29    pub fn new(seed: u64) -> Self {
30        Self {
31            rng: seeded_rng(seed, 0),
32            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::InventoryMovement),
33        }
34    }
35
36    /// Generate inventory movements for the given period.
37    ///
38    /// Creates a mix of movement types distributed across the period for each
39    /// material in the pool.
40    ///
41    /// # Arguments
42    ///
43    /// * `company_code` - Company / entity code.
44    /// * `material_ids` - Available materials as `(material_id, description)` tuples.
45    /// * `period_start` - Start of the generation period.
46    /// * `period_end` - End of the generation period.
47    /// * `movements_per_material` - Average number of movements per material.
48    /// * `currency` - Currency code for value calculations.
49    pub fn generate(
50        &mut self,
51        company_code: &str,
52        material_ids: &[(String, String)],
53        period_start: NaiveDate,
54        period_end: NaiveDate,
55        movements_per_material: u32,
56        currency: &str,
57    ) -> Vec<InventoryMovement> {
58        debug!(
59            company_code,
60            material_count = material_ids.len(),
61            %period_start,
62            %period_end,
63            movements_per_material,
64            "Generating inventory movements"
65        );
66
67        let mut movements = Vec::new();
68        let period_days = (period_end - period_start).num_days().max(1) as u64;
69        let period_str = format!(
70            "{}-{:02}",
71            period_start.format("%Y"),
72            period_start.format("%m")
73        );
74
75        for (material_id, material_desc) in material_ids {
76            let count = self.rng.random_range(1..=movements_per_material.max(1) * 2);
77
78            for _ in 0..count {
79                let mv_id = self.uuid_factory.next().to_string();
80                let day_offset = self.rng.random_range(0..period_days);
81                let movement_date = period_start + chrono::Duration::days(day_offset as i64);
82
83                let movement_type = self.pick_movement_type();
84                let quantity = Decimal::from(self.rng.random_range(1..=500));
85                let unit_cost: f64 = self.rng.random_range(5.0..=200.0);
86                let value = (quantity
87                    * Decimal::from_f64_retain(unit_cost).unwrap_or(Decimal::from(10)))
88                .round_dp(2);
89
90                let storage_location = STORAGE_LOCATIONS
91                    [self.rng.random_range(0..STORAGE_LOCATIONS.len())]
92                .to_string();
93
94                let reference_doc = match movement_type {
95                    MovementType::GoodsReceipt => {
96                        format!("PO-{:08}", self.rng.random_range(10000..99999))
97                    }
98                    MovementType::GoodsIssue => {
99                        format!("PRD-{:08}", self.rng.random_range(10000..99999))
100                    }
101                    MovementType::Transfer => {
102                        format!("TR-{:08}", self.rng.random_range(10000..99999))
103                    }
104                    MovementType::Return => {
105                        format!("RET-{:08}", self.rng.random_range(10000..99999))
106                    }
107                    MovementType::Scrap => {
108                        format!("QI-{:08}", self.rng.random_range(10000..99999))
109                    }
110                    MovementType::Adjustment => {
111                        format!("CC-{:08}", self.rng.random_range(10000..99999))
112                    }
113                };
114
115                let mv = InventoryMovement::new(
116                    mv_id,
117                    company_code,
118                    material_id,
119                    material_desc,
120                    movement_date,
121                    &period_str,
122                    movement_type,
123                    quantity,
124                    "EA",
125                    value,
126                    currency,
127                    &storage_location,
128                    &reference_doc,
129                );
130                movements.push(mv);
131            }
132        }
133
134        movements
135    }
136
137    /// Pick a movement type based on realistic distribution.
138    ///
139    /// 35% GoodsReceipt, 30% GoodsIssue, 15% Transfer, 8% Return, 7% Scrap, 5% Adjustment
140    fn pick_movement_type(&mut self) -> MovementType {
141        let roll: f64 = self.rng.random();
142        if roll < 0.35 {
143            MovementType::GoodsReceipt
144        } else if roll < 0.65 {
145            MovementType::GoodsIssue
146        } else if roll < 0.80 {
147            MovementType::Transfer
148        } else if roll < 0.88 {
149            MovementType::Return
150        } else if roll < 0.95 {
151            MovementType::Scrap
152        } else {
153            MovementType::Adjustment
154        }
155    }
156}
157
158// ---------------------------------------------------------------------------
159// Tests
160// ---------------------------------------------------------------------------
161
162#[cfg(test)]
163#[allow(clippy::unwrap_used)]
164mod tests {
165    use super::*;
166
167    fn test_materials() -> Vec<(String, String)> {
168        vec![
169            ("MAT-001".to_string(), "Widget A".to_string()),
170            ("MAT-002".to_string(), "Widget B".to_string()),
171            ("MAT-003".to_string(), "Widget C".to_string()),
172        ]
173    }
174
175    #[test]
176    fn test_movement_generation() {
177        let mut gen = InventoryMovementGenerator::new(42);
178        let materials = test_materials();
179        let start = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
180        let end = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
181
182        let movements = gen.generate("C001", &materials, start, end, 5, "USD");
183
184        assert!(!movements.is_empty());
185        for mv in &movements {
186            assert_eq!(mv.entity_code, "C001");
187            assert_eq!(mv.currency, "USD");
188            assert!(mv.quantity > Decimal::ZERO);
189            assert!(mv.value > Decimal::ZERO);
190            assert!(mv.movement_date >= start);
191            assert!(mv.movement_date <= end);
192            assert!(!mv.reference_doc.is_empty());
193        }
194    }
195
196    #[test]
197    fn test_movement_type_distribution() {
198        let mut gen = InventoryMovementGenerator::new(77);
199        let materials: Vec<(String, String)> = (0..20)
200            .map(|i| (format!("MAT-{:03}", i), format!("M-{}", i)))
201            .collect();
202        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
203        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
204
205        let movements = gen.generate("C001", &materials, start, end, 10, "USD");
206
207        let receipts = movements
208            .iter()
209            .filter(|m| matches!(m.movement_type, MovementType::GoodsReceipt))
210            .count();
211        let issues = movements
212            .iter()
213            .filter(|m| matches!(m.movement_type, MovementType::GoodsIssue))
214            .count();
215
216        assert!(receipts > 0, "Should have goods receipts");
217        assert!(issues > 0, "Should have goods issues");
218    }
219
220    #[test]
221    fn test_movement_deterministic() {
222        let materials = test_materials();
223        let start = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
224        let end = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
225
226        let mut gen1 = InventoryMovementGenerator::new(12345);
227        let mv1 = gen1.generate("C001", &materials, start, end, 3, "USD");
228        let mut gen2 = InventoryMovementGenerator::new(12345);
229        let mv2 = gen2.generate("C001", &materials, start, end, 3, "USD");
230
231        assert_eq!(mv1.len(), mv2.len());
232        for (a, b) in mv1.iter().zip(mv2.iter()) {
233            assert_eq!(a.id, b.id);
234            assert_eq!(a.material_code, b.material_code);
235            assert_eq!(a.quantity, b.quantity);
236            assert_eq!(a.value, b.value);
237        }
238    }
239}