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(std::string::ToString::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)]
238mod tests {
239 use super::*;
240
241 fn sample_materials() -> Vec<(String, String)> {
242 vec![
243 ("MAT-001".to_string(), "SL-A01".to_string()),
244 ("MAT-002".to_string(), "SL-A02".to_string()),
245 ("MAT-003".to_string(), "SL-B01".to_string()),
246 ("MAT-004".to_string(), "SL-B02".to_string()),
247 ("MAT-005".to_string(), "SL-C01".to_string()),
248 ]
249 }
250
251 #[test]
252 fn test_basic_generation() {
253 let mut gen = CycleCountGenerator::new(42);
254 let materials = sample_materials();
255 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
256
257 let count = gen.generate("C001", &materials, date, 5);
258
259 assert_eq!(count.company_code, "C001");
260 assert_eq!(count.count_date, date);
261 assert!(!count.count_id.is_empty());
262 assert_eq!(count.items.len(), 5);
263 assert_eq!(count.total_items_counted, 5);
264 assert!(count.counter_id.is_some());
265 assert!(count.supervisor_id.is_some());
266
267 for item in &count.items {
268 assert!(item.book_quantity > Decimal::ZERO);
269 assert!(item.counted_quantity >= Decimal::ZERO);
270 assert!(item.unit_cost > Decimal::ZERO);
271 let expected_variance = (item.variance_quantity * item.unit_cost).round_dp(2);
273 assert_eq!(item.variance_value, expected_variance);
274 }
275 }
276
277 #[test]
278 fn test_deterministic() {
279 let materials = sample_materials();
280 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
281
282 let mut gen1 = CycleCountGenerator::new(12345);
283 let count1 = gen1.generate("C001", &materials, date, 5);
284
285 let mut gen2 = CycleCountGenerator::new(12345);
286 let count2 = gen2.generate("C001", &materials, date, 5);
287
288 assert_eq!(count1.count_id, count2.count_id);
289 assert_eq!(count1.items.len(), count2.items.len());
290 assert_eq!(count1.total_variances, count2.total_variances);
291 for (i1, i2) in count1.items.iter().zip(count2.items.iter()) {
292 assert_eq!(i1.material_id, i2.material_id);
293 assert_eq!(i1.book_quantity, i2.book_quantity);
294 assert_eq!(i1.counted_quantity, i2.counted_quantity);
295 assert_eq!(i1.variance_value, i2.variance_value);
296 }
297 }
298
299 #[test]
300 fn test_variance_distribution() {
301 let mut gen = CycleCountGenerator::new(77);
302 let materials: Vec<(String, String)> = (0..100)
303 .map(|i| (format!("MAT-{:03}", i), format!("SL-{:03}", i)))
304 .collect();
305 let date = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
306
307 let count = gen.generate("C001", &materials, date, 100);
308
309 let none_count = count
310 .items
311 .iter()
312 .filter(|i| matches!(i.variance_type, CountVarianceType::None))
313 .count();
314 let minor_count = count
315 .items
316 .iter()
317 .filter(|i| matches!(i.variance_type, CountVarianceType::Minor))
318 .count();
319
320 assert!(
322 (70..=98).contains(&none_count),
323 "Expected ~85% exact matches, got {}/100",
324 none_count,
325 );
326 assert!(minor_count > 0, "Expected at least some minor variances");
328 }
329
330 #[test]
331 fn test_items_per_count_cap() {
332 let mut gen = CycleCountGenerator::new(55);
333 let materials = sample_materials(); let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
335
336 let count = gen.generate("C001", &materials, date, 20);
338
339 assert_eq!(
340 count.items.len(),
341 5,
342 "Items should be capped at available material count"
343 );
344 }
345}