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