1use 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
14struct KpiDefinition {
16 name: &'static str,
17 category: KpiCategory,
18 unit: &'static str,
19 target_min: f64,
20 target_max: f64,
21}
22
23const 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
97pub struct KpiGenerator {
100 rng: ChaCha8Rng,
101 uuid_factory: DeterministicUuidFactory,
102}
103
104impl KpiGenerator {
105 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 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 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 let actual_end = if current_end > period_end {
142 period_end
143 } else {
144 current_end.pred_opt().unwrap_or(current_end)
146 };
147
148 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 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 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 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 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 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 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
230fn 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
240fn 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#[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 assert_eq!(kpis.len(), 60);
287
288 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 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 assert_eq!(kpis.len(), 40);
321
322 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}