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)]
356#[allow(clippy::unwrap_used)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn test_reports_generated_for_12_month_period() {
362 let mut gen = ManagementReportGenerator::new(42);
363 let reports = gen.generate_reports("C001", 2025, 12);
364
365 assert_eq!(reports.len(), 28);
367
368 for r in &reports {
370 assert!(!r.entity_code.is_empty());
371 assert!(!r.period.is_empty());
372 assert!(!r.report_type.is_empty());
373 assert!(!r.prepared_by.is_empty());
374 assert!(!r.commentary.is_empty());
375 }
376 }
377
378 #[test]
379 fn test_monthly_and_quarterly_report_types_present() {
380 let mut gen = ManagementReportGenerator::new(99);
381 let reports = gen.generate_reports("ENTITY_A", 2025, 12);
382
383 let types: std::collections::HashSet<&str> =
384 reports.iter().map(|r| r.report_type.as_str()).collect();
385
386 assert!(types.contains("flash_report"), "Missing flash_report");
387 assert!(types.contains("monthly_pack"), "Missing monthly_pack");
388 assert!(types.contains("board_report"), "Missing board_report");
389
390 let flash_count = reports
392 .iter()
393 .filter(|r| r.report_type == "flash_report")
394 .count();
395 let pack_count = reports
396 .iter()
397 .filter(|r| r.report_type == "monthly_pack")
398 .count();
399 let board_count = reports
400 .iter()
401 .filter(|r| r.report_type == "board_report")
402 .count();
403 assert_eq!(flash_count, 12);
404 assert_eq!(pack_count, 12);
405 assert_eq!(board_count, 4);
406 }
407
408 #[test]
409 fn test_kpi_rag_status_consistent_with_variance() {
410 let mut gen = ManagementReportGenerator::new(7);
411 let reports = gen.generate_reports("C002", 2025, 3);
412
413 for report in &reports {
414 for kpi in &report.kpi_summary {
415 let abs_v = kpi.variance_pct.abs();
416 let expected_rag = if abs_v < 5.0 {
417 "green"
418 } else if abs_v < 10.0 {
419 "amber"
420 } else {
421 "red"
422 };
423 assert_eq!(
424 kpi.rag_status, expected_rag,
425 "RAG mismatch for metric '{}': variance_pct={:.2}, got '{}', expected '{}'",
426 kpi.metric, kpi.variance_pct, kpi.rag_status, expected_rag
427 );
428 }
429 }
430 }
431
432 #[test]
433 fn test_budget_variances_sum_correctly() {
434 let mut gen = ManagementReportGenerator::new(1234);
435 let reports = gen.generate_reports("C003", 2025, 1);
436
437 for report in &reports {
438 for line in &report.budget_variances {
439 let expected = line.actual_amount - line.budget_amount;
440 let diff = (line.variance - expected).abs();
442 assert!(
443 diff <= Decimal::from_f64_retain(0.01).unwrap_or(Decimal::ZERO),
444 "Variance arithmetic mismatch for account '{}': variance={}, expected={}",
445 line.account,
446 line.variance,
447 expected
448 );
449 }
450 }
451 }
452
453 #[test]
454 fn test_serialization_roundtrip() {
455 let mut gen = ManagementReportGenerator::new(555);
456 let reports = gen.generate_reports("C004", 2025, 1);
457
458 assert!(!reports.is_empty());
459 let report = &reports[0];
460
461 let json = serde_json::to_string(report).expect("serialization failed");
462 let roundtripped: ManagementReport =
463 serde_json::from_str(&json).expect("deserialization failed");
464
465 assert_eq!(report.report_id, roundtripped.report_id);
466 assert_eq!(report.report_type, roundtripped.report_type);
467 assert_eq!(report.period, roundtripped.period);
468 assert_eq!(report.entity_code, roundtripped.entity_code);
469 assert_eq!(report.kpi_summary.len(), roundtripped.kpi_summary.len());
470 assert_eq!(
471 report.budget_variances.len(),
472 roundtripped.budget_variances.len()
473 );
474 assert_eq!(report.commentary, roundtripped.commentary);
475 }
476}