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