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(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    /// 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)]
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            // Variance value should equal variance_quantity * unit_cost (within rounding)
272            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        // With 100 items and 85% match rate, expect ~80-95 exact matches
321        assert!(
322            (70..=98).contains(&none_count),
323            "Expected ~85% exact matches, got {}/100",
324            none_count,
325        );
326        // Minor should be present but not dominant
327        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(); // 5 materials
334        let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
335
336        // Request more items than available materials
337        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}