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 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 #[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 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 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#[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}