datasynth_generators/manufacturing/
cycle_count_generator.rs1use chrono::NaiveDate;
8use datasynth_core::models::{CountVarianceType, CycleCount, CycleCountItem, CycleCountStatus};
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 std::collections::HashMap;
15
16const ADJUSTMENT_REASONS: &[&str] = &[
18 "Physical recount confirmed",
19 "Damaged goods written off",
20 "Misplaced inventory located",
21 "Unit of measure correction",
22 "System sync error resolved",
23 "Picking discrepancy",
24 "Receiving error",
25 "Scrap not recorded",
26];
27
28pub struct CycleCountGenerator {
31 rng: ChaCha8Rng,
32 uuid_factory: DeterministicUuidFactory,
33 employee_ids_pool: Vec<String>,
34 material_descriptions: HashMap<String, String>,
36}
37
38impl CycleCountGenerator {
39 pub fn new(seed: u64) -> Self {
41 Self {
42 rng: seeded_rng(seed, 0),
43 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::CycleCount),
44 employee_ids_pool: Vec::new(),
45 material_descriptions: HashMap::new(),
46 }
47 }
48
49 pub fn with_employee_pool(mut self, employee_ids: Vec<String>) -> Self {
54 self.employee_ids_pool = employee_ids;
55 self
56 }
57
58 pub fn with_material_descriptions(mut self, descriptions: HashMap<String, String>) -> Self {
63 self.material_descriptions = descriptions;
64 self
65 }
66
67 pub fn generate(
76 &mut self,
77 company_code: &str,
78 material_ids: &[(String, String)],
79 count_date: NaiveDate,
80 items_per_count: usize,
81 ) -> CycleCount {
82 let count_id = self.uuid_factory.next().to_string();
83
84 let selected_count = items_per_count.min(material_ids.len());
86 let mut indices: Vec<usize> = (0..material_ids.len()).collect();
87 indices.shuffle(&mut self.rng);
88 let selected_indices = &indices[..selected_count];
89
90 let items: Vec<CycleCountItem> = selected_indices
92 .iter()
93 .map(|&idx| {
94 let (material_id, storage_location) = &material_ids[idx];
95 self.generate_item(material_id, storage_location)
96 })
97 .collect();
98
99 let total_items_counted = items.len() as u32;
101 let total_variances = items
102 .iter()
103 .filter(|item| !matches!(item.variance_type, CountVarianceType::None))
104 .count() as u32;
105 let variance_rate = if total_items_counted > 0 {
106 total_variances as f64 / total_items_counted as f64
107 } else {
108 0.0
109 };
110
111 let status = self.pick_status();
113
114 let counter_id = if self.employee_ids_pool.is_empty() {
116 Some(format!("WH-{:02}", self.rng.random_range(1..=10)))
117 } else {
118 self.employee_ids_pool.choose(&mut self.rng).cloned()
119 };
120 let supervisor_id = if self.employee_ids_pool.is_empty() {
121 Some(format!("SUP-{:02}", self.rng.random_range(1..=5)))
122 } else {
123 self.employee_ids_pool.choose(&mut self.rng).cloned()
124 };
125
126 let warehouse_id = format!("WH-{:03}", self.rng.random_range(1..=10));
128
129 CycleCount {
130 count_id,
131 company_code: company_code.to_string(),
132 warehouse_id,
133 count_date,
134 status,
135 counter_id,
136 supervisor_id,
137 items,
138 total_items_counted,
139 total_variances,
140 variance_rate,
141 }
142 }
143
144 fn generate_item(&mut self, material_id: &str, storage_location: &str) -> CycleCountItem {
146 let book_qty_f64: f64 = self.rng.random_range(100.0..=10_000.0);
148 let book_quantity =
149 Decimal::from_f64_retain(book_qty_f64.round()).unwrap_or(Decimal::from(100));
150
151 let unit_cost_f64: f64 = self.rng.random_range(5.0..=500.0);
153 let unit_cost = Decimal::from_f64_retain(unit_cost_f64)
154 .unwrap_or(Decimal::from(10))
155 .round_dp(2);
156
157 let roll: f64 = self.rng.random();
160 let (variance_type, counted_quantity) = if roll < 0.85 {
161 (CountVarianceType::None, book_quantity)
163 } else if roll < 0.95 {
164 let pct: f64 = self.rng.random_range(0.01..=0.03);
166 let sign = if self.rng.random_bool(0.5) { 1.0 } else { -1.0 };
167 let counted_f64 = book_qty_f64 * (1.0 + sign * pct);
168 let counted = Decimal::from_f64_retain(counted_f64.round()).unwrap_or(book_quantity);
169 (CountVarianceType::Minor, counted)
170 } else if roll < 0.99 {
171 let pct: f64 = self.rng.random_range(0.05..=0.15);
173 let sign = if self.rng.random_bool(0.5) { 1.0 } else { -1.0 };
174 let counted_f64 = book_qty_f64 * (1.0 + sign * pct);
175 let counted =
176 Decimal::from_f64_retain(counted_f64.round().max(0.0)).unwrap_or(book_quantity);
177 (CountVarianceType::Major, counted)
178 } else {
179 let pct: f64 = self.rng.random_range(0.20..=0.50);
181 let sign = if self.rng.random_bool(0.5) { 1.0 } else { -1.0 };
182 let counted_f64 = book_qty_f64 * (1.0 + sign * pct);
183 let counted =
184 Decimal::from_f64_retain(counted_f64.round().max(0.0)).unwrap_or(book_quantity);
185 (CountVarianceType::Critical, counted)
186 };
187
188 let variance_quantity = counted_quantity - book_quantity;
189 let variance_value = (variance_quantity * unit_cost).round_dp(2);
190
191 let has_variance = !matches!(variance_type, CountVarianceType::None);
193 let adjusted = has_variance && self.rng.random_bool(0.80);
194
195 let adjustment_reason = if adjusted {
196 ADJUSTMENT_REASONS
197 .choose(&mut self.rng)
198 .map(|s| s.to_string())
199 } else {
200 None
201 };
202
203 CycleCountItem {
204 material_id: material_id.to_string(),
205 material_description: self.material_descriptions.get(material_id).cloned(),
206 storage_location: storage_location.to_string(),
207 book_quantity,
208 counted_quantity,
209 variance_quantity,
210 unit_cost,
211 variance_value,
212 variance_type,
213 adjusted,
214 adjustment_reason,
215 }
216 }
217
218 fn pick_status(&mut self) -> CycleCountStatus {
220 let roll: f64 = self.rng.random();
221 if roll < 0.40 {
222 CycleCountStatus::Reconciled
223 } else if roll < 0.70 {
224 CycleCountStatus::Closed
225 } else if roll < 0.90 {
226 CycleCountStatus::Counted
227 } else {
228 CycleCountStatus::InProgress
229 }
230 }
231}
232
233#[cfg(test)]
238#[allow(clippy::unwrap_used)]
239mod tests {
240 use super::*;
241
242 fn sample_materials() -> Vec<(String, String)> {
243 vec![
244 ("MAT-001".to_string(), "SL-A01".to_string()),
245 ("MAT-002".to_string(), "SL-A02".to_string()),
246 ("MAT-003".to_string(), "SL-B01".to_string()),
247 ("MAT-004".to_string(), "SL-B02".to_string()),
248 ("MAT-005".to_string(), "SL-C01".to_string()),
249 ]
250 }
251
252 #[test]
253 fn test_basic_generation() {
254 let mut gen = CycleCountGenerator::new(42);
255 let materials = sample_materials();
256 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
257
258 let count = gen.generate("C001", &materials, date, 5);
259
260 assert_eq!(count.company_code, "C001");
261 assert_eq!(count.count_date, date);
262 assert!(!count.count_id.is_empty());
263 assert_eq!(count.items.len(), 5);
264 assert_eq!(count.total_items_counted, 5);
265 assert!(count.counter_id.is_some());
266 assert!(count.supervisor_id.is_some());
267
268 for item in &count.items {
269 assert!(item.book_quantity > Decimal::ZERO);
270 assert!(item.counted_quantity >= Decimal::ZERO);
271 assert!(item.unit_cost > Decimal::ZERO);
272 let expected_variance = (item.variance_quantity * item.unit_cost).round_dp(2);
274 assert_eq!(item.variance_value, expected_variance);
275 }
276 }
277
278 #[test]
279 fn test_deterministic() {
280 let materials = sample_materials();
281 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
282
283 let mut gen1 = CycleCountGenerator::new(12345);
284 let count1 = gen1.generate("C001", &materials, date, 5);
285
286 let mut gen2 = CycleCountGenerator::new(12345);
287 let count2 = gen2.generate("C001", &materials, date, 5);
288
289 assert_eq!(count1.count_id, count2.count_id);
290 assert_eq!(count1.items.len(), count2.items.len());
291 assert_eq!(count1.total_variances, count2.total_variances);
292 for (i1, i2) in count1.items.iter().zip(count2.items.iter()) {
293 assert_eq!(i1.material_id, i2.material_id);
294 assert_eq!(i1.book_quantity, i2.book_quantity);
295 assert_eq!(i1.counted_quantity, i2.counted_quantity);
296 assert_eq!(i1.variance_value, i2.variance_value);
297 }
298 }
299
300 #[test]
301 fn test_variance_distribution() {
302 let mut gen = CycleCountGenerator::new(77);
303 let materials: Vec<(String, String)> = (0..100)
304 .map(|i| (format!("MAT-{:03}", i), format!("SL-{:03}", i)))
305 .collect();
306 let date = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
307
308 let count = gen.generate("C001", &materials, date, 100);
309
310 let none_count = count
311 .items
312 .iter()
313 .filter(|i| matches!(i.variance_type, CountVarianceType::None))
314 .count();
315 let minor_count = count
316 .items
317 .iter()
318 .filter(|i| matches!(i.variance_type, CountVarianceType::Minor))
319 .count();
320
321 assert!(
323 none_count >= 70 && none_count <= 98,
324 "Expected ~85% exact matches, got {}/100",
325 none_count,
326 );
327 assert!(minor_count > 0, "Expected at least some minor variances");
329 }
330
331 #[test]
332 fn test_items_per_count_cap() {
333 let mut gen = CycleCountGenerator::new(55);
334 let materials = sample_materials(); let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
336
337 let count = gen.generate("C001", &materials, date, 20);
339
340 assert_eq!(
341 count.items.len(),
342 5,
343 "Items should be capped at available material count"
344 );
345 }
346}