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::uuid_factory::{DeterministicUuidFactory, GeneratorType};
10use rand::prelude::*;
11use rand_chacha::ChaCha8Rng;
12use rust_decimal::Decimal;
13
14/// Adjustment reason strings for items that are adjusted.
15const 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
26/// Generates [`CycleCount`] instances with realistic variance distributions
27/// and adjustment patterns.
28pub struct CycleCountGenerator {
29    rng: ChaCha8Rng,
30    uuid_factory: DeterministicUuidFactory,
31}
32
33impl CycleCountGenerator {
34    /// Create a new cycle count generator with the given seed.
35    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    /// Generate a single cycle count event covering the specified materials.
43    ///
44    /// # Arguments
45    ///
46    /// * `company_code` - Company code for the cycle count.
47    /// * `material_ids` - Available materials as `(material_id, storage_location)` tuples.
48    /// * `count_date` - Date the count is performed.
49    /// * `items_per_count` - Number of items to include in this count.
50    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        // Select items: pick `items_per_count` random materials (or all if fewer)
60        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        // Generate items
66        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        // Compute totals
75        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        // Status: 40% Reconciled, 30% Closed, 20% Counted, 10% InProgress
87        let status = self.pick_status();
88
89        // Counter and supervisor
90        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        // Warehouse ID
94        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    /// Generate a single cycle count item with realistic variance patterns.
112    fn generate_item(&mut self, material_id: &str, storage_location: &str) -> CycleCountItem {
113        // Book quantity: random 100 - 10,000
114        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        // Unit cost: random 5.0 - 500.0
119        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        // Determine variance type and counted quantity
125        // 85% match, 10% minor (±1-3%), 4% major (±5-15%), 1% critical (±20-50%)
126        let roll: f64 = self.rng.gen();
127        let (variance_type, counted_quantity) = if roll < 0.85 {
128            // Exact match
129            (CountVarianceType::None, book_quantity)
130        } else if roll < 0.95 {
131            // Minor variance: ±1-3%
132            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            // Major variance: ±5-15%
139            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            // Critical variance: ±20-50%
147            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        // Adjusted: 80% of items with variance get adjusted
159        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    /// Pick a cycle count status based on distribution.
185    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// ---------------------------------------------------------------------------
200// Tests
201// ---------------------------------------------------------------------------
202
203#[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            // Variance value should equal variance_quantity * unit_cost (within rounding)
239            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        // With 100 items and 85% match rate, expect ~80-95 exact matches
288        assert!(
289            none_count >= 70 && none_count <= 98,
290            "Expected ~85% exact matches, got {}/100",
291            none_count,
292        );
293        // Minor should be present but not dominant
294        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(); // 5 materials
301        let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
302
303        // Request more items than available materials
304        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}