Skip to main content

datasynth_generators/manufacturing/
cycle_count_generator.rs

1//! Cycle count generator for warehouse inventory management.
2//!
3//! Generates realistic cycle counts with variance distributions matching
4//! typical warehouse accuracy patterns: most items match, a few have minor
5//! variances, and rare items show major or critical discrepancies.
6
7use 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
16/// Adjustment reason strings for items that are adjusted.
17const 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
28/// Generates [`CycleCount`] instances with realistic variance distributions
29/// and adjustment patterns.
30pub struct CycleCountGenerator {
31    rng: ChaCha8Rng,
32    uuid_factory: DeterministicUuidFactory,
33    employee_ids_pool: Vec<String>,
34    /// Mapping of material_id → description for denormalization (DS-011).
35    material_descriptions: HashMap<String, String>,
36}
37
38impl CycleCountGenerator {
39    /// Create a new cycle count generator with the given seed.
40    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    /// Set the employee ID pool used for counter and supervisor IDs.
50    ///
51    /// When non-empty, `counter_id` and `supervisor_id` are picked from this
52    /// pool instead of fabricated `WH-{:02}` / `SUP-{:02}` strings.
53    pub fn with_employee_pool(mut self, employee_ids: Vec<String>) -> Self {
54        self.employee_ids_pool = employee_ids;
55        self
56    }
57
58    /// Set the material description mapping for denormalization (DS-011).
59    ///
60    /// Maps material IDs to their descriptions so that generated cycle count
61    /// items include the material description for graph export convenience.
62    pub fn with_material_descriptions(mut self, descriptions: HashMap<String, String>) -> Self {
63        self.material_descriptions = descriptions;
64        self
65    }
66
67    /// Generate a single cycle count event covering the specified materials.
68    ///
69    /// # Arguments
70    ///
71    /// * `company_code` - Company code for the cycle count.
72    /// * `material_ids` - Available materials as `(material_id, storage_location)` tuples.
73    /// * `count_date` - Date the count is performed.
74    /// * `items_per_count` - Number of items to include in this count.
75    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        // Select items: pick `items_per_count` random materials (or all if fewer)
85        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        // Generate items
91        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        // Compute totals
100        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        // Status: 40% Reconciled, 30% Closed, 20% Counted, 10% InProgress
112        let status = self.pick_status();
113
114        // Counter and supervisor – use real employee IDs when available
115        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        // Warehouse ID
127        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    /// Generate a single cycle count item with realistic variance patterns.
145    fn generate_item(&mut self, material_id: &str, storage_location: &str) -> CycleCountItem {
146        // Book quantity: random 100 - 10,000
147        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        // Unit cost: random 5.0 - 500.0
152        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        // Determine variance type and counted quantity
158        // 85% match, 10% minor (±1-3%), 4% major (±5-15%), 1% critical (±20-50%)
159        let roll: f64 = self.rng.random();
160        let (variance_type, counted_quantity) = if roll < 0.85 {
161            // Exact match
162            (CountVarianceType::None, book_quantity)
163        } else if roll < 0.95 {
164            // Minor variance: ±1-3%
165            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            // Major variance: ±5-15%
172            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            // Critical variance: ±20-50%
180            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        // Adjusted: 80% of items with variance get adjusted
192        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    /// Pick a cycle count status based on distribution.
219    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// ---------------------------------------------------------------------------
234// Tests
235// ---------------------------------------------------------------------------
236
237#[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            // Variance value should equal variance_quantity * unit_cost (within rounding)
273            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        // With 100 items and 85% match rate, expect ~80-95 exact matches
322        assert!(
323            none_count >= 70 && none_count <= 98,
324            "Expected ~85% exact matches, got {}/100",
325            none_count,
326        );
327        // Minor should be present but not dominant
328        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(); // 5 materials
335        let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
336
337        // Request more items than available materials
338        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}