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;
14
15/// Adjustment reason strings for items that are adjusted.
16const 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
27/// Generates [`CycleCount`] instances with realistic variance distributions
28/// and adjustment patterns.
29pub struct CycleCountGenerator {
30    rng: ChaCha8Rng,
31    uuid_factory: DeterministicUuidFactory,
32    employee_ids_pool: Vec<String>,
33}
34
35impl CycleCountGenerator {
36    /// Create a new cycle count generator with the given seed.
37    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    /// Set the employee ID pool used for counter and supervisor IDs.
46    ///
47    /// When non-empty, `counter_id` and `supervisor_id` are picked from this
48    /// pool instead of fabricated `WH-{:02}` / `SUP-{:02}` strings.
49    pub fn with_employee_pool(mut self, employee_ids: Vec<String>) -> Self {
50        self.employee_ids_pool = employee_ids;
51        self
52    }
53
54    /// Generate a single cycle count event covering the specified materials.
55    ///
56    /// # Arguments
57    ///
58    /// * `company_code` - Company code for the cycle count.
59    /// * `material_ids` - Available materials as `(material_id, storage_location)` tuples.
60    /// * `count_date` - Date the count is performed.
61    /// * `items_per_count` - Number of items to include in this count.
62    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        // Select items: pick `items_per_count` random materials (or all if fewer)
72        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        // Generate items
78        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        // Compute totals
87        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        // Status: 40% Reconciled, 30% Closed, 20% Counted, 10% InProgress
99        let status = self.pick_status();
100
101        // Counter and supervisor – use real employee IDs when available
102        let counter_id = if self.employee_ids_pool.is_empty() {
103            Some(format!("WH-{:02}", self.rng.gen_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.gen_range(1..=5)))
109        } else {
110            self.employee_ids_pool.choose(&mut self.rng).cloned()
111        };
112
113        // Warehouse ID
114        let warehouse_id = format!("WH-{:03}", self.rng.gen_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    /// Generate a single cycle count item with realistic variance patterns.
132    fn generate_item(&mut self, material_id: &str, storage_location: &str) -> CycleCountItem {
133        // Book quantity: random 100 - 10,000
134        let book_qty_f64: f64 = self.rng.gen_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        // Unit cost: random 5.0 - 500.0
139        let unit_cost_f64: f64 = self.rng.gen_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        // Determine variance type and counted quantity
145        // 85% match, 10% minor (±1-3%), 4% major (±5-15%), 1% critical (±20-50%)
146        let roll: f64 = self.rng.gen();
147        let (variance_type, counted_quantity) = if roll < 0.85 {
148            // Exact match
149            (CountVarianceType::None, book_quantity)
150        } else if roll < 0.95 {
151            // Minor variance: ±1-3%
152            let pct: f64 = self.rng.gen_range(0.01..=0.03);
153            let sign = if self.rng.gen_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            // Major variance: ±5-15%
159            let pct: f64 = self.rng.gen_range(0.05..=0.15);
160            let sign = if self.rng.gen_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            // Critical variance: ±20-50%
167            let pct: f64 = self.rng.gen_range(0.20..=0.50);
168            let sign = if self.rng.gen_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        // Adjusted: 80% of items with variance get adjusted
179        let has_variance = !matches!(variance_type, CountVarianceType::None);
180        let adjusted = has_variance && self.rng.gen_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    /// Pick a cycle count status based on distribution.
205    fn pick_status(&mut self) -> CycleCountStatus {
206        let roll: f64 = self.rng.gen();
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// ---------------------------------------------------------------------------
220// Tests
221// ---------------------------------------------------------------------------
222
223#[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            // Variance value should equal variance_quantity * unit_cost (within rounding)
259            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        // With 100 items and 85% match rate, expect ~80-95 exact matches
308        assert!(
309            none_count >= 70 && none_count <= 98,
310            "Expected ~85% exact matches, got {}/100",
311            none_count,
312        );
313        // Minor should be present but not dominant
314        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(); // 5 materials
321        let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
322
323        // Request more items than available materials
324        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}