datasynth_generators/manufacturing/
inventory_movement_generator.rs1use 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
16const STORAGE_LOCATIONS: &[&str] = &[
18 "WH01-A01", "WH01-A02", "WH01-B01", "WH02-A01", "WH02-B01", "WH03-A01",
19];
20
21pub struct InventoryMovementGenerator {
23 rng: ChaCha8Rng,
24 uuid_factory: DeterministicUuidFactory,
25}
26
27impl InventoryMovementGenerator {
28 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 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 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#[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}