Skip to main content

datasynth_generators/
kpi_generator.rs

1//! Management KPI generator.
2//!
3//! Generates realistic key performance indicators across financial, operational,
4//! customer, employee, and quality categories with period-over-period trends.
5
6use chrono::{Datelike, NaiveDate};
7use datasynth_config::schema::ManagementKpisConfig;
8use datasynth_core::models::{KpiCategory, KpiTrend, ManagementKpi};
9use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
10use rand::prelude::*;
11use rand_chacha::ChaCha8Rng;
12use rust_decimal::Decimal;
13
14/// Definition of a standard KPI template.
15struct KpiDefinition {
16    name: &'static str,
17    category: KpiCategory,
18    unit: &'static str,
19    target_min: f64,
20    target_max: f64,
21}
22
23/// Standard KPIs generated per period.
24const STANDARD_KPIS: &[KpiDefinition] = &[
25    KpiDefinition {
26        name: "Revenue Growth Rate",
27        category: KpiCategory::Financial,
28        unit: "%",
29        target_min: 5.0,
30        target_max: 15.0,
31    },
32    KpiDefinition {
33        name: "Gross Margin",
34        category: KpiCategory::Financial,
35        unit: "%",
36        target_min: 30.0,
37        target_max: 60.0,
38    },
39    KpiDefinition {
40        name: "Operating Margin",
41        category: KpiCategory::Financial,
42        unit: "%",
43        target_min: 10.0,
44        target_max: 25.0,
45    },
46    KpiDefinition {
47        name: "Current Ratio",
48        category: KpiCategory::Financial,
49        unit: "ratio",
50        target_min: 1.5,
51        target_max: 2.5,
52    },
53    KpiDefinition {
54        name: "Days Sales Outstanding",
55        category: KpiCategory::Operational,
56        unit: "days",
57        target_min: 30.0,
58        target_max: 60.0,
59    },
60    KpiDefinition {
61        name: "Inventory Turnover",
62        category: KpiCategory::Operational,
63        unit: "turns",
64        target_min: 4.0,
65        target_max: 12.0,
66    },
67    KpiDefinition {
68        name: "Order Fulfillment Rate",
69        category: KpiCategory::Operational,
70        unit: "%",
71        target_min: 95.0,
72        target_max: 99.0,
73    },
74    KpiDefinition {
75        name: "Customer Satisfaction Score",
76        category: KpiCategory::Customer,
77        unit: "score",
78        target_min: 80.0,
79        target_max: 95.0,
80    },
81    KpiDefinition {
82        name: "Employee Turnover Rate",
83        category: KpiCategory::Employee,
84        unit: "%",
85        target_min: 5.0,
86        target_max: 15.0,
87    },
88    KpiDefinition {
89        name: "Defect Rate",
90        category: KpiCategory::Quality,
91        unit: "%",
92        target_min: 0.5,
93        target_max: 3.0,
94    },
95];
96
97/// Generates [`ManagementKpi`] instances with realistic values,
98/// targets, trends, and period-over-period comparisons.
99pub struct KpiGenerator {
100    rng: ChaCha8Rng,
101    uuid_factory: DeterministicUuidFactory,
102}
103
104impl KpiGenerator {
105    /// Create a new generator with the given seed.
106    pub fn new(seed: u64) -> Self {
107        Self {
108            rng: ChaCha8Rng::seed_from_u64(seed),
109            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::Kpi),
110        }
111    }
112
113    /// Generate management KPIs for the given period and configuration.
114    ///
115    /// # Arguments
116    ///
117    /// * `company_code` - The company code these KPIs belong to.
118    /// * `period_start` - Start of the generation period (inclusive).
119    /// * `period_end` - End of the generation period (inclusive).
120    /// * `config` - Management KPI configuration.
121    pub fn generate(
122        &mut self,
123        company_code: &str,
124        period_start: NaiveDate,
125        period_end: NaiveDate,
126        config: &ManagementKpisConfig,
127    ) -> Vec<ManagementKpi> {
128        let mut kpis = Vec::new();
129        let is_quarterly = config.frequency.to_lowercase() == "quarterly";
130
131        // Iterate over periods
132        let mut current_start = period_start;
133        while current_start <= period_end {
134            let current_end = if is_quarterly {
135                advance_quarter(current_start)
136            } else {
137                advance_month(current_start)
138            };
139
140            // Clamp to period_end
141            let actual_end = if current_end > period_end {
142                period_end
143            } else {
144                // End date is the last day of the period (day before next period starts)
145                current_end.pred_opt().unwrap_or(current_end)
146            };
147
148            // Generate all standard KPIs for this period
149            for def in STANDARD_KPIS {
150                let kpi = self.generate_single_kpi(company_code, def, current_start, actual_end);
151                kpis.push(kpi);
152            }
153
154            current_start = current_end;
155            if current_start > period_end {
156                break;
157            }
158        }
159
160        kpis
161    }
162
163    /// Generate a single KPI for a given period.
164    fn generate_single_kpi(
165        &mut self,
166        company_code: &str,
167        def: &KpiDefinition,
168        period_start: NaiveDate,
169        period_end: NaiveDate,
170    ) -> ManagementKpi {
171        let kpi_id = self.uuid_factory.next().to_string();
172
173        // Generate a target value within the defined range
174        let target_raw: f64 = self.rng.gen_range(def.target_min..=def.target_max);
175        let target = Decimal::from_f64_retain(target_raw)
176            .unwrap_or(Decimal::ZERO)
177            .round_dp(2);
178
179        // Actual value = target * random(0.8 - 1.2) with noise
180        let multiplier: f64 = self.rng.gen_range(0.8..1.2);
181        let value_raw = target_raw * multiplier;
182        let value = Decimal::from_f64_retain(value_raw)
183            .unwrap_or(Decimal::ZERO)
184            .round_dp(2);
185
186        // Determine trend: if value > target -> Improving, within 5% -> Stable, else Declining
187        let ratio = if target_raw > 0.0 {
188            value_raw / target_raw
189        } else {
190            1.0
191        };
192        let trend = if ratio > 1.05 {
193            KpiTrend::Improving
194        } else if ratio >= 0.95 {
195            KpiTrend::Stable
196        } else {
197            KpiTrend::Declining
198        };
199
200        // Year-over-year change: random -10% to +15%
201        let yoy_raw: f64 = self.rng.gen_range(-0.10..0.15);
202        let year_over_year_change = Some((yoy_raw * 10000.0).round() / 10000.0);
203
204        // Prior period value: value * (1 - small random change)
205        let prior_change: f64 = self.rng.gen_range(-0.08..0.08);
206        let prior_raw = value_raw * (1.0 - prior_change);
207        let prior_period_value = Some(
208            Decimal::from_f64_retain(prior_raw)
209                .unwrap_or(Decimal::ZERO)
210                .round_dp(2),
211        );
212
213        ManagementKpi {
214            kpi_id,
215            company_code: company_code.to_string(),
216            name: def.name.to_string(),
217            category: def.category,
218            period_start,
219            period_end,
220            value,
221            target,
222            unit: def.unit.to_string(),
223            trend,
224            year_over_year_change,
225            prior_period_value,
226        }
227    }
228}
229
230/// Advance a date to the first day of the next month.
231fn advance_month(date: NaiveDate) -> NaiveDate {
232    let (year, month) = if date.month() == 12 {
233        (date.year() + 1, 1)
234    } else {
235        (date.year(), date.month() + 1)
236    };
237    NaiveDate::from_ymd_opt(year, month, 1).unwrap_or(date)
238}
239
240/// Advance a date to the first day of the next quarter.
241fn advance_quarter(date: NaiveDate) -> NaiveDate {
242    let current_quarter_start_month = ((date.month() - 1) / 3) * 3 + 1;
243    let next_quarter_month = current_quarter_start_month + 3;
244    let (year, month) = if next_quarter_month > 12 {
245        (date.year() + 1, next_quarter_month - 12)
246    } else {
247        (date.year(), next_quarter_month)
248    };
249    NaiveDate::from_ymd_opt(year, month, 1).unwrap_or(date)
250}
251
252// ---------------------------------------------------------------------------
253// Tests
254// ---------------------------------------------------------------------------
255
256#[cfg(test)]
257#[allow(clippy::unwrap_used)]
258mod tests {
259    use super::*;
260
261    fn default_monthly_config() -> ManagementKpisConfig {
262        ManagementKpisConfig {
263            enabled: true,
264            frequency: "monthly".to_string(),
265        }
266    }
267
268    fn default_quarterly_config() -> ManagementKpisConfig {
269        ManagementKpisConfig {
270            enabled: true,
271            frequency: "quarterly".to_string(),
272        }
273    }
274
275    #[test]
276    fn test_monthly_generation_produces_correct_count() {
277        let mut gen = KpiGenerator::new(42);
278        let config = default_monthly_config();
279
280        let period_start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
281        let period_end = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
282
283        let kpis = gen.generate("C001", period_start, period_end, &config);
284
285        // 6 months * 10 standard KPIs = 60
286        assert_eq!(kpis.len(), 60);
287
288        // All KPIs should have valid fields
289        for kpi in &kpis {
290            assert!(!kpi.kpi_id.is_empty());
291            assert_eq!(kpi.company_code, "C001");
292            assert!(!kpi.name.is_empty());
293            assert!(!kpi.unit.is_empty());
294            assert!(kpi.value > Decimal::ZERO);
295            assert!(kpi.target > Decimal::ZERO);
296            assert!(kpi.year_over_year_change.is_some());
297            assert!(kpi.prior_period_value.is_some());
298        }
299
300        // Check that all categories are represented
301        let categories: std::collections::HashSet<_> = kpis.iter().map(|k| k.category).collect();
302        assert!(categories.contains(&KpiCategory::Financial));
303        assert!(categories.contains(&KpiCategory::Operational));
304        assert!(categories.contains(&KpiCategory::Customer));
305        assert!(categories.contains(&KpiCategory::Employee));
306        assert!(categories.contains(&KpiCategory::Quality));
307    }
308
309    #[test]
310    fn test_quarterly_generation() {
311        let mut gen = KpiGenerator::new(99);
312        let config = default_quarterly_config();
313
314        let period_start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
315        let period_end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
316
317        let kpis = gen.generate("C002", period_start, period_end, &config);
318
319        // 4 quarters * 10 standard KPIs = 40
320        assert_eq!(kpis.len(), 40);
321
322        // Verify all trends are valid
323        for kpi in &kpis {
324            assert!(
325                kpi.trend == KpiTrend::Improving
326                    || kpi.trend == KpiTrend::Stable
327                    || kpi.trend == KpiTrend::Declining
328            );
329        }
330    }
331
332    #[test]
333    fn test_deterministic_output_with_same_seed() {
334        let config = default_monthly_config();
335        let period_start = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
336        let period_end = NaiveDate::from_ymd_opt(2024, 5, 31).unwrap();
337
338        let mut gen1 = KpiGenerator::new(12345);
339        let kpis1 = gen1.generate("C001", period_start, period_end, &config);
340
341        let mut gen2 = KpiGenerator::new(12345);
342        let kpis2 = gen2.generate("C001", period_start, period_end, &config);
343
344        assert_eq!(kpis1.len(), kpis2.len());
345        for (k1, k2) in kpis1.iter().zip(kpis2.iter()) {
346            assert_eq!(k1.kpi_id, k2.kpi_id);
347            assert_eq!(k1.name, k2.name);
348            assert_eq!(k1.value, k2.value);
349            assert_eq!(k1.target, k2.target);
350            assert_eq!(k1.trend, k2.trend);
351        }
352    }
353}