1use chrono::NaiveDate;
9use datasynth_core::models::{BudgetVarianceLine, KpiSummaryLine, ManagementReport};
10use datasynth_core::utils::seeded_rng;
11use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
12use rand::prelude::*;
13use rand_chacha::ChaCha8Rng;
14use rust_decimal::Decimal;
15
16const KPI_METRICS: &[&str] = &[
22 "Revenue Growth Rate",
23 "Gross Margin",
24 "Operating Margin",
25 "EBITDA Margin",
26 "Current Ratio",
27 "Days Sales Outstanding",
28 "Inventory Turnover",
29 "Order Fulfillment Rate",
30 "Customer Satisfaction Score",
31 "Employee Turnover Rate",
32 "Net Promoter Score",
33 "Return on Assets",
34];
35
36const BUDGET_ACCOUNTS: &[(&str, f64, f64)] = &[
38 ("Revenue", 500.0, 5_000.0),
40 ("Cost of Goods Sold", 200.0, 3_000.0),
41 ("Gross Profit", 150.0, 2_000.0),
42 ("Salaries & Benefits", 100.0, 1_500.0),
43 ("Rent & Facilities", 20.0, 200.0),
44 ("Marketing & Advertising", 15.0, 300.0),
45 ("Research & Development", 10.0, 500.0),
46 ("Depreciation & Amortisation", 5.0, 100.0),
47 ("Interest Expense", 2.0, 50.0),
48 ("General & Administrative", 10.0, 150.0),
49 ("Travel & Entertainment", 5.0, 80.0),
50 ("IT & Software", 8.0, 120.0),
51 ("Professional Fees", 5.0, 60.0),
52 ("Taxes", 10.0, 200.0),
53 ("Capital Expenditure", 20.0, 400.0),
54];
55
56const POSITIVE_COMMENTARY: &[&str] = &[
58 "Revenue exceeded target for the period, driven by strong demand in the core product segment.",
59 "Gross margin improvement reflects continued procurement savings and favourable product mix.",
60 "Operating expenses were well-controlled; all major cost lines came in on or below budget.",
61 "Strong cash collections in the period resulted in DSO improvement versus prior year.",
62 "Operating profit was ahead of plan, supported by one-off cost savings in facilities.",
63];
64
65const NEGATIVE_COMMENTARY: &[&str] = &[
66 "Revenue fell short of target due to delayed customer onboarding and a weaker macro environment.",
67 "Cost overruns in the Engineering department require remediation action in the next period.",
68 "Supply chain disruptions led to higher-than-budgeted COGS; management is reviewing sourcing strategy.",
69 "Margin compression was observed as a result of increased input costs not yet passed on to customers.",
70 "Operating expenses exceeded budget primarily in Marketing; a revised spend plan is being developed.",
71];
72
73const NEUTRAL_COMMENTARY: &[&str] = &[
74 "Performance was broadly in line with the annual operating plan.",
75 "No material variances were identified; the business is on track to deliver the full-year budget.",
76 "Minor timing differences between actual and budget are expected to reverse in subsequent periods.",
77 "The period results reflect normal seasonal patterns consistent with the prior year.",
78];
79
80pub struct ManagementReportGenerator {
87 rng: ChaCha8Rng,
88 uuid_factory: DeterministicUuidFactory,
89}
90
91impl ManagementReportGenerator {
92 pub fn new(seed: u64) -> Self {
94 Self {
95 rng: seeded_rng(seed, 0),
96 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::ManagementReport),
97 }
98 }
99
100 pub fn generate_reports(
113 &mut self,
114 entity_code: &str,
115 fiscal_year: u32,
116 period_months: u32,
117 ) -> Vec<ManagementReport> {
118 let months = period_months.clamp(1, 12);
119 let mut reports = Vec::with_capacity(months as usize * 2 + 4);
120
121 for month in 1..=months {
122 let period_label = format!("{fiscal_year}-{month:02}");
123
124 let flash_date = next_month_day(fiscal_year, month, 5);
126 reports.push(self.generate_single(
127 entity_code,
128 "flash_report",
129 &period_label,
130 flash_date,
131 6..=8,
132 8..=10,
133 ));
134
135 let pack_date = next_month_day(fiscal_year, month, 15);
137 reports.push(self.generate_single(
138 entity_code,
139 "monthly_pack",
140 &period_label,
141 pack_date,
142 8..=10,
143 10..=13,
144 ));
145
146 if month % 3 == 0 {
148 let quarter = month / 3;
149 let period_q = format!("{fiscal_year}-Q{quarter}");
150 let board_date = next_month_day(fiscal_year, month, 20);
151 reports.push(self.generate_single(
152 entity_code,
153 "board_report",
154 &period_q,
155 board_date,
156 8..=10,
157 12..=15,
158 ));
159 }
160 }
161
162 reports
163 }
164
165 fn generate_single(
167 &mut self,
168 entity_code: &str,
169 report_type: &str,
170 period: &str,
171 prepared_date: NaiveDate,
172 kpi_range: std::ops::RangeInclusive<usize>,
173 variance_range: std::ops::RangeInclusive<usize>,
174 ) -> ManagementReport {
175 let report_id = self.uuid_factory.next();
176
177 let kpi_count = self.rng.random_range(*kpi_range.start()..=*kpi_range.end());
178 let variance_count = self
179 .rng
180 .random_range(*variance_range.start()..=*variance_range.end());
181
182 let kpi_summary = self.generate_kpi_summary(kpi_count);
183 let budget_variances = self.generate_budget_variances(variance_count);
184 let commentary = self.generate_commentary(&budget_variances);
185
186 let preparer_num: u32 = self.rng.random_range(1..=5);
187 let prepared_by = format!("FIN-ANALYST-{preparer_num:03}");
188
189 ManagementReport {
190 report_id,
191 report_type: report_type.to_string(),
192 period: period.to_string(),
193 entity_code: entity_code.to_string(),
194 prepared_by,
195 prepared_date,
196 kpi_summary,
197 budget_variances,
198 commentary,
199 }
200 }
201
202 fn generate_kpi_summary(&mut self, count: usize) -> Vec<KpiSummaryLine> {
204 let mut indices: Vec<usize> = (0..KPI_METRICS.len()).collect();
206 indices.shuffle(&mut self.rng);
207 indices.truncate(count);
208
209 indices
210 .into_iter()
211 .map(|i| {
212 let metric = KPI_METRICS[i];
213
214 let target_raw: f64 = self.rng.random_range(10.0..100.0);
216 let target = safe_decimal(target_raw, 2);
217
218 let variance_pct = self.sample_variance_pct();
221 let actual_raw = target_raw * (1.0 + variance_pct / 100.0);
222 let actual = safe_decimal(actual_raw, 2);
223
224 let rag_status = rag_from_variance(variance_pct);
225
226 KpiSummaryLine {
227 metric: metric.to_string(),
228 actual,
229 target,
230 variance_pct: (variance_pct * 100.0).round() / 100.0,
231 rag_status,
232 }
233 })
234 .collect()
235 }
236
237 fn generate_budget_variances(&mut self, count: usize) -> Vec<BudgetVarianceLine> {
239 let count = count.min(BUDGET_ACCOUNTS.len());
240 let mut indices: Vec<usize> = (0..BUDGET_ACCOUNTS.len()).collect();
241 indices.shuffle(&mut self.rng);
242 indices.truncate(count);
243
244 indices
245 .into_iter()
246 .map(|i| {
247 let (label, min_k, max_k) = BUDGET_ACCOUNTS[i];
248
249 let budget_raw: f64 = self.rng.random_range(min_k..max_k) * 1_000.0;
250 let budget_amount = safe_decimal(budget_raw, 2);
251
252 let variance_pct = self.sample_variance_pct();
253 let actual_raw = budget_raw * (1.0 + variance_pct / 100.0);
254 let actual_amount = safe_decimal(actual_raw, 2);
255
256 let variance = actual_amount - budget_amount;
257
258 BudgetVarianceLine {
259 account: label.to_string(),
260 budget_amount,
261 actual_amount,
262 variance,
263 variance_pct: (variance_pct * 100.0).round() / 100.0,
264 }
265 })
266 .collect()
267 }
268
269 fn sample_variance_pct(&mut self) -> f64 {
273 let bucket: f64 = self.rng.random();
274 let sign: f64 = if self.rng.random_bool(0.5) { 1.0 } else { -1.0 };
275
276 if bucket < 0.60 {
277 sign * self.rng.random_range(0.0_f64..5.0)
278 } else if bucket < 0.90 {
279 sign * self.rng.random_range(5.0_f64..10.0)
280 } else {
281 sign * self.rng.random_range(10.0_f64..25.0)
282 }
283 }
284
285 fn generate_commentary(&mut self, variances: &[BudgetVarianceLine]) -> String {
287 let avg_pct = if variances.is_empty() {
289 0.0
290 } else {
291 variances.iter().map(|v| v.variance_pct).sum::<f64>() / variances.len() as f64
292 };
293
294 let pool = if avg_pct > 2.0 {
295 POSITIVE_COMMENTARY
296 } else if avg_pct < -2.0 {
297 NEGATIVE_COMMENTARY
298 } else {
299 NEUTRAL_COMMENTARY
300 };
301
302 let idx = self.rng.random_range(0..pool.len());
303 pool[idx].to_string()
304 }
305}
306
307fn rag_from_variance(variance_pct: f64) -> String {
317 let abs_v = variance_pct.abs();
318 if abs_v < 5.0 {
319 "green".to_string()
320 } else if abs_v < 10.0 {
321 "amber".to_string()
322 } else {
323 "red".to_string()
324 }
325}
326
327fn next_month_day(fiscal_year: u32, month: u32, day: u32) -> NaiveDate {
330 let (y, m) = if month == 12 {
331 (fiscal_year as i32 + 1, 1u32)
332 } else {
333 (fiscal_year as i32, month + 1)
334 };
335 NaiveDate::from_ymd_opt(y, m, day)
336 .or_else(|| NaiveDate::from_ymd_opt(y, m, 28))
337 .unwrap_or_else(|| NaiveDate::from_ymd_opt(y, m, 1).unwrap_or_default())
338}
339
340fn safe_decimal(raw: f64, dp: u32) -> Decimal {
342 if raw.is_finite() {
343 Decimal::from_f64_retain(raw)
344 .unwrap_or(Decimal::ZERO)
345 .round_dp(dp)
346 } else {
347 Decimal::ZERO
348 }
349}
350
351#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn test_reports_generated_for_12_month_period() {
361 let mut gen = ManagementReportGenerator::new(42);
362 let reports = gen.generate_reports("C001", 2025, 12);
363
364 assert_eq!(reports.len(), 28);
366
367 for r in &reports {
369 assert!(!r.entity_code.is_empty());
370 assert!(!r.period.is_empty());
371 assert!(!r.report_type.is_empty());
372 assert!(!r.prepared_by.is_empty());
373 assert!(!r.commentary.is_empty());
374 }
375 }
376
377 #[test]
378 fn test_monthly_and_quarterly_report_types_present() {
379 let mut gen = ManagementReportGenerator::new(99);
380 let reports = gen.generate_reports("ENTITY_A", 2025, 12);
381
382 let types: std::collections::HashSet<&str> =
383 reports.iter().map(|r| r.report_type.as_str()).collect();
384
385 assert!(types.contains("flash_report"), "Missing flash_report");
386 assert!(types.contains("monthly_pack"), "Missing monthly_pack");
387 assert!(types.contains("board_report"), "Missing board_report");
388
389 let flash_count = reports
391 .iter()
392 .filter(|r| r.report_type == "flash_report")
393 .count();
394 let pack_count = reports
395 .iter()
396 .filter(|r| r.report_type == "monthly_pack")
397 .count();
398 let board_count = reports
399 .iter()
400 .filter(|r| r.report_type == "board_report")
401 .count();
402 assert_eq!(flash_count, 12);
403 assert_eq!(pack_count, 12);
404 assert_eq!(board_count, 4);
405 }
406
407 #[test]
408 fn test_kpi_rag_status_consistent_with_variance() {
409 let mut gen = ManagementReportGenerator::new(7);
410 let reports = gen.generate_reports("C002", 2025, 3);
411
412 for report in &reports {
413 for kpi in &report.kpi_summary {
414 let abs_v = kpi.variance_pct.abs();
415 let expected_rag = if abs_v < 5.0 {
416 "green"
417 } else if abs_v < 10.0 {
418 "amber"
419 } else {
420 "red"
421 };
422 assert_eq!(
423 kpi.rag_status, expected_rag,
424 "RAG mismatch for metric '{}': variance_pct={:.2}, got '{}', expected '{}'",
425 kpi.metric, kpi.variance_pct, kpi.rag_status, expected_rag
426 );
427 }
428 }
429 }
430
431 #[test]
432 fn test_budget_variances_sum_correctly() {
433 let mut gen = ManagementReportGenerator::new(1234);
434 let reports = gen.generate_reports("C003", 2025, 1);
435
436 for report in &reports {
437 for line in &report.budget_variances {
438 let expected = line.actual_amount - line.budget_amount;
439 let diff = (line.variance - expected).abs();
441 assert!(
442 diff <= Decimal::from_f64_retain(0.01).unwrap_or(Decimal::ZERO),
443 "Variance arithmetic mismatch for account '{}': variance={}, expected={}",
444 line.account,
445 line.variance,
446 expected
447 );
448 }
449 }
450 }
451
452 #[test]
453 fn test_serialization_roundtrip() {
454 let mut gen = ManagementReportGenerator::new(555);
455 let reports = gen.generate_reports("C004", 2025, 1);
456
457 assert!(!reports.is_empty());
458 let report = &reports[0];
459
460 let json = serde_json::to_string(report).expect("serialization failed");
461 let roundtripped: ManagementReport =
462 serde_json::from_str(&json).expect("deserialization failed");
463
464 assert_eq!(report.report_id, roundtripped.report_id);
465 assert_eq!(report.report_type, roundtripped.report_type);
466 assert_eq!(report.period, roundtripped.period);
467 assert_eq!(report.entity_code, roundtripped.entity_code);
468 assert_eq!(report.kpi_summary.len(), roundtripped.kpi_summary.len());
469 assert_eq!(
470 report.budget_variances.len(),
471 roundtripped.budget_variances.len()
472 );
473 assert_eq!(report.commentary, roundtripped.commentary);
474 }
475}