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        self.generate_with_production_orders(
59            company_code,
60            material_ids,
61            period_start,
62            period_end,
63            movements_per_material,
64            currency,
65            &[],
66        )
67    }
68
69    /// Generate inventory movements, linking `GoodsIssue` movements to real production order IDs.
70    ///
71    /// When `production_order_ids` is non-empty, `GoodsIssue` movements use actual IDs
72    /// (cycling through the list) instead of fabricated `PRD-{random}` strings.
73    ///
74    /// # Arguments
75    ///
76    /// * `company_code` - Company / entity code.
77    /// * `material_ids` - Available materials as `(material_id, description)` tuples.
78    /// * `period_start` - Start of the generation period.
79    /// * `period_end` - End of the generation period.
80    /// * `movements_per_material` - Average number of movements per material.
81    /// * `currency` - Currency code for value calculations.
82    /// * `production_order_ids` - Real production order IDs to use for GoodsIssue reference docs.
83    #[allow(clippy::too_many_arguments)]
84    pub fn generate_with_production_orders(
85        &mut self,
86        company_code: &str,
87        material_ids: &[(String, String)],
88        period_start: NaiveDate,
89        period_end: NaiveDate,
90        movements_per_material: u32,
91        currency: &str,
92        production_order_ids: &[String],
93    ) -> Vec<InventoryMovement> {
94        debug!(
95            company_code,
96            material_count = material_ids.len(),
97            %period_start,
98            %period_end,
99            movements_per_material,
100            production_order_count = production_order_ids.len(),
101            "Generating inventory movements"
102        );
103
104        let mut movements = Vec::new();
105        let period_days = (period_end - period_start).num_days().max(1) as u64;
106        let period_str = format!(
107            "{}-{:02}",
108            period_start.format("%Y"),
109            period_start.format("%m")
110        );
111        let mut prd_idx: usize = 0;
112
113        for (material_id, material_desc) in material_ids {
114            let count = self.rng.random_range(1..=movements_per_material.max(1) * 2);
115
116            for _ in 0..count {
117                let mv_id = self.uuid_factory.next().to_string();
118                let day_offset = self.rng.random_range(0..period_days);
119                let movement_date = period_start + chrono::Duration::days(day_offset as i64);
120
121                let movement_type = self.pick_movement_type();
122                let quantity = Decimal::from(self.rng.random_range(1..=500));
123                let unit_cost: f64 = self.rng.random_range(5.0..=200.0);
124                let value = (quantity
125                    * Decimal::from_f64_retain(unit_cost).unwrap_or(Decimal::from(10)))
126                .round_dp(2);
127
128                let storage_location = STORAGE_LOCATIONS
129                    [self.rng.random_range(0..STORAGE_LOCATIONS.len())]
130                .to_string();
131
132                let reference_doc = match movement_type {
133                    MovementType::GoodsReceipt => {
134                        format!("PO-{:08}", self.rng.random_range(10000..99999))
135                    }
136                    MovementType::GoodsIssue => {
137                        if !production_order_ids.is_empty() {
138                            // Use a real production order ID (cycle through the list)
139                            let id =
140                                production_order_ids[prd_idx % production_order_ids.len()].clone();
141                            prd_idx += 1;
142                            id
143                        } else {
144                            format!("PRD-{:08}", self.rng.random_range(10000..99999))
145                        }
146                    }
147                    MovementType::Transfer => {
148                        format!("TR-{:08}", self.rng.random_range(10000..99999))
149                    }
150                    MovementType::Return => {
151                        format!("RET-{:08}", self.rng.random_range(10000..99999))
152                    }
153                    MovementType::Scrap => {
154                        format!("QI-{:08}", self.rng.random_range(10000..99999))
155                    }
156                    MovementType::Adjustment => {
157                        format!("CC-{:08}", self.rng.random_range(10000..99999))
158                    }
159                };
160
161                let mv = InventoryMovement::new(
162                    mv_id,
163                    company_code,
164                    material_id,
165                    material_desc,
166                    movement_date,
167                    &period_str,
168                    movement_type,
169                    quantity,
170                    "EA",
171                    value,
172                    currency,
173                    &storage_location,
174                    &reference_doc,
175                );
176                movements.push(mv);
177            }
178        }
179
180        movements
181    }
182
183    /// Pick a movement type based on realistic distribution.
184    ///
185    /// 35% GoodsReceipt, 30% GoodsIssue, 15% Transfer, 8% Return, 7% Scrap, 5% Adjustment
186    fn pick_movement_type(&mut self) -> MovementType {
187        let roll: f64 = self.rng.random();
188        if roll < 0.35 {
189            MovementType::GoodsReceipt
190        } else if roll < 0.65 {
191            MovementType::GoodsIssue
192        } else if roll < 0.80 {
193            MovementType::Transfer
194        } else if roll < 0.88 {
195            MovementType::Return
196        } else if roll < 0.95 {
197            MovementType::Scrap
198        } else {
199            MovementType::Adjustment
200        }
201    }
202}
203
204// ---------------------------------------------------------------------------
205// Tests
206// ---------------------------------------------------------------------------
207
208#[cfg(test)]
209#[allow(clippy::unwrap_used)]
210mod tests {
211    use super::*;
212
213    fn test_materials() -> Vec<(String, String)> {
214        vec![
215            ("MAT-001".to_string(), "Widget A".to_string()),
216            ("MAT-002".to_string(), "Widget B".to_string()),
217            ("MAT-003".to_string(), "Widget C".to_string()),
218        ]
219    }
220
221    #[test]
222    fn test_movement_generation() {
223        let mut gen = InventoryMovementGenerator::new(42);
224        let materials = test_materials();
225        let start = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
226        let end = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
227
228        let movements = gen.generate("C001", &materials, start, end, 5, "USD");
229
230        assert!(!movements.is_empty());
231        for mv in &movements {
232            assert_eq!(mv.entity_code, "C001");
233            assert_eq!(mv.currency, "USD");
234            assert!(mv.quantity > Decimal::ZERO);
235            assert!(mv.value > Decimal::ZERO);
236            assert!(mv.movement_date >= start);
237            assert!(mv.movement_date <= end);
238            assert!(!mv.reference_doc.is_empty());
239        }
240    }
241
242    #[test]
243    fn test_movement_type_distribution() {
244        let mut gen = InventoryMovementGenerator::new(77);
245        let materials: Vec<(String, String)> = (0..20)
246            .map(|i| (format!("MAT-{:03}", i), format!("M-{}", i)))
247            .collect();
248        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
249        let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
250
251        let movements = gen.generate("C001", &materials, start, end, 10, "USD");
252
253        let receipts = movements
254            .iter()
255            .filter(|m| matches!(m.movement_type, MovementType::GoodsReceipt))
256            .count();
257        let issues = movements
258            .iter()
259            .filter(|m| matches!(m.movement_type, MovementType::GoodsIssue))
260            .count();
261
262        assert!(receipts > 0, "Should have goods receipts");
263        assert!(issues > 0, "Should have goods issues");
264    }
265
266    #[test]
267    fn test_movement_deterministic() {
268        let materials = test_materials();
269        let start = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
270        let end = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
271
272        let mut gen1 = InventoryMovementGenerator::new(12345);
273        let mv1 = gen1.generate("C001", &materials, start, end, 3, "USD");
274        let mut gen2 = InventoryMovementGenerator::new(12345);
275        let mv2 = gen2.generate("C001", &materials, start, end, 3, "USD");
276
277        assert_eq!(mv1.len(), mv2.len());
278        for (a, b) in mv1.iter().zip(mv2.iter()) {
279            assert_eq!(a.id, b.id);
280            assert_eq!(a.material_code, b.material_code);
281            assert_eq!(a.quantity, b.quantity);
282            assert_eq!(a.value, b.value);
283        }
284    }
285}