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