use chrono::{Datelike, NaiveDate};
use datasynth_config::schema::ManagementKpisConfig;
use datasynth_core::models::{KpiCategory, KpiTrend, ManagementKpi};
use datasynth_core::utils::seeded_rng;
use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
use rust_decimal::Decimal;
struct KpiDefinition {
name: &'static str,
category: KpiCategory,
unit: &'static str,
target_min: f64,
target_max: f64,
}
const STANDARD_KPIS: &[KpiDefinition] = &[
KpiDefinition {
name: "Revenue Growth Rate",
category: KpiCategory::Financial,
unit: "%",
target_min: 5.0,
target_max: 15.0,
},
KpiDefinition {
name: "Gross Margin",
category: KpiCategory::Financial,
unit: "%",
target_min: 30.0,
target_max: 60.0,
},
KpiDefinition {
name: "Operating Margin",
category: KpiCategory::Financial,
unit: "%",
target_min: 10.0,
target_max: 25.0,
},
KpiDefinition {
name: "Current Ratio",
category: KpiCategory::Financial,
unit: "ratio",
target_min: 1.5,
target_max: 2.5,
},
KpiDefinition {
name: "Days Sales Outstanding",
category: KpiCategory::Operational,
unit: "days",
target_min: 30.0,
target_max: 60.0,
},
KpiDefinition {
name: "Inventory Turnover",
category: KpiCategory::Operational,
unit: "turns",
target_min: 4.0,
target_max: 12.0,
},
KpiDefinition {
name: "Order Fulfillment Rate",
category: KpiCategory::Operational,
unit: "%",
target_min: 95.0,
target_max: 99.0,
},
KpiDefinition {
name: "Customer Satisfaction Score",
category: KpiCategory::Customer,
unit: "score",
target_min: 80.0,
target_max: 95.0,
},
KpiDefinition {
name: "Employee Turnover Rate",
category: KpiCategory::Employee,
unit: "%",
target_min: 5.0,
target_max: 15.0,
},
KpiDefinition {
name: "Defect Rate",
category: KpiCategory::Quality,
unit: "%",
target_min: 0.5,
target_max: 3.0,
},
];
pub struct KpiGenerator {
rng: ChaCha8Rng,
uuid_factory: DeterministicUuidFactory,
}
impl KpiGenerator {
pub fn new(seed: u64) -> Self {
Self {
rng: seeded_rng(seed, 0),
uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::Kpi),
}
}
pub fn generate(
&mut self,
company_code: &str,
period_start: NaiveDate,
period_end: NaiveDate,
config: &ManagementKpisConfig,
) -> Vec<ManagementKpi> {
tracing::debug!(company_code, %period_start, %period_end, "Generating management KPIs");
let mut kpis = Vec::new();
let is_quarterly = config.frequency.to_lowercase() == "quarterly";
let mut current_start = period_start;
while current_start <= period_end {
let current_end = if is_quarterly {
advance_quarter(current_start)
} else {
advance_month(current_start)
};
let actual_end = if current_end > period_end {
period_end
} else {
current_end.pred_opt().unwrap_or(current_end)
};
for def in STANDARD_KPIS {
let kpi = self.generate_single_kpi(company_code, def, current_start, actual_end);
kpis.push(kpi);
}
current_start = current_end;
if current_start > period_end {
break;
}
}
kpis
}
fn generate_single_kpi(
&mut self,
company_code: &str,
def: &KpiDefinition,
period_start: NaiveDate,
period_end: NaiveDate,
) -> ManagementKpi {
let kpi_id = self.uuid_factory.next().to_string();
let target_raw: f64 = self.rng.random_range(def.target_min..=def.target_max);
let target = Decimal::from_f64_retain(target_raw)
.unwrap_or(Decimal::ZERO)
.round_dp(2);
let multiplier: f64 = self.rng.random_range(0.8..1.2);
let value_raw = target_raw * multiplier;
let value = Decimal::from_f64_retain(value_raw)
.unwrap_or(Decimal::ZERO)
.round_dp(2);
let ratio = if target_raw > 0.0 {
value_raw / target_raw
} else {
1.0
};
let trend = if ratio > 1.05 {
KpiTrend::Improving
} else if ratio >= 0.95 {
KpiTrend::Stable
} else {
KpiTrend::Declining
};
let yoy_raw: f64 = self.rng.random_range(-0.10..0.15);
let year_over_year_change = Some((yoy_raw * 10000.0).round() / 10000.0);
let prior_change: f64 = self.rng.random_range(-0.08..0.08);
let prior_raw = value_raw * (1.0 - prior_change);
let prior_period_value = Some(
Decimal::from_f64_retain(prior_raw)
.unwrap_or(Decimal::ZERO)
.round_dp(2),
);
ManagementKpi {
kpi_id,
company_code: company_code.to_string(),
name: def.name.to_string(),
category: def.category,
period_start,
period_end,
value,
target,
unit: def.unit.to_string(),
trend,
year_over_year_change,
prior_period_value,
}
}
}
fn advance_month(date: NaiveDate) -> NaiveDate {
let (year, month) = if date.month() == 12 {
(date.year() + 1, 1)
} else {
(date.year(), date.month() + 1)
};
NaiveDate::from_ymd_opt(year, month, 1).unwrap_or(date)
}
fn advance_quarter(date: NaiveDate) -> NaiveDate {
let current_quarter_start_month = ((date.month() - 1) / 3) * 3 + 1;
let next_quarter_month = current_quarter_start_month + 3;
let (year, month) = if next_quarter_month > 12 {
(date.year() + 1, next_quarter_month - 12)
} else {
(date.year(), next_quarter_month)
};
NaiveDate::from_ymd_opt(year, month, 1).unwrap_or(date)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn default_monthly_config() -> ManagementKpisConfig {
ManagementKpisConfig {
enabled: true,
frequency: "monthly".to_string(),
}
}
fn default_quarterly_config() -> ManagementKpisConfig {
ManagementKpisConfig {
enabled: true,
frequency: "quarterly".to_string(),
}
}
#[test]
fn test_monthly_generation_produces_correct_count() {
let mut gen = KpiGenerator::new(42);
let config = default_monthly_config();
let period_start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let period_end = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
let kpis = gen.generate("C001", period_start, period_end, &config);
assert_eq!(kpis.len(), 60);
for kpi in &kpis {
assert!(!kpi.kpi_id.is_empty());
assert_eq!(kpi.company_code, "C001");
assert!(!kpi.name.is_empty());
assert!(!kpi.unit.is_empty());
assert!(kpi.value > Decimal::ZERO);
assert!(kpi.target > Decimal::ZERO);
assert!(kpi.year_over_year_change.is_some());
assert!(kpi.prior_period_value.is_some());
}
let categories: std::collections::HashSet<_> = kpis.iter().map(|k| k.category).collect();
assert!(categories.contains(&KpiCategory::Financial));
assert!(categories.contains(&KpiCategory::Operational));
assert!(categories.contains(&KpiCategory::Customer));
assert!(categories.contains(&KpiCategory::Employee));
assert!(categories.contains(&KpiCategory::Quality));
}
#[test]
fn test_quarterly_generation() {
let mut gen = KpiGenerator::new(99);
let config = default_quarterly_config();
let period_start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let period_end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let kpis = gen.generate("C002", period_start, period_end, &config);
assert_eq!(kpis.len(), 40);
for kpi in &kpis {
assert!(
kpi.trend == KpiTrend::Improving
|| kpi.trend == KpiTrend::Stable
|| kpi.trend == KpiTrend::Declining
);
}
}
#[test]
fn test_deterministic_output_with_same_seed() {
let config = default_monthly_config();
let period_start = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
let period_end = NaiveDate::from_ymd_opt(2024, 5, 31).unwrap();
let mut gen1 = KpiGenerator::new(12345);
let kpis1 = gen1.generate("C001", period_start, period_end, &config);
let mut gen2 = KpiGenerator::new(12345);
let kpis2 = gen2.generate("C001", period_start, period_end, &config);
assert_eq!(kpis1.len(), kpis2.len());
for (k1, k2) in kpis1.iter().zip(kpis2.iter()) {
assert_eq!(k1.kpi_id, k2.kpi_id);
assert_eq!(k1.name, k2.name);
assert_eq!(k1.value, k2.value);
assert_eq!(k1.target, k2.target);
assert_eq!(k1.trend, k2.trend);
}
}
}