1use 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
15struct KpiDefinition {
17 name: &'static str,
18 category: KpiCategory,
19 unit: &'static str,
20 target_min: f64,
21 target_max: f64,
22}
23
24const 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
98pub struct KpiGenerator {
101 rng: ChaCha8Rng,
102 uuid_factory: DeterministicUuidFactory,
103}
104
105impl KpiGenerator {
106 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 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 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 let actual_end = if current_end > period_end {
144 period_end
145 } else {
146 current_end.pred_opt().unwrap_or(current_end)
148 };
149
150 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 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 let target_raw: f64 = self.rng.gen_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 let multiplier: f64 = self.rng.gen_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 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 let yoy_raw: f64 = self.rng.gen_range(-0.10..0.15);
204 let year_over_year_change = Some((yoy_raw * 10000.0).round() / 10000.0);
205
206 let prior_change: f64 = self.rng.gen_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
232fn 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
242fn 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#[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 assert_eq!(kpis.len(), 60);
289
290 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 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 assert_eq!(kpis.len(), 40);
323
324 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}