Skip to main content

datasynth_generators/
management_report_generator.rs

1//! Management report generator (WI-7).
2//!
3//! Generates realistic management packs, board reports, flash reports, and
4//! forecast packs that aggregate KPI performance and budget variance data
5//! into period-level narrative documents used by auditors as analytical
6//! evidence (ISA 520).
7
8use 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
16// ---------------------------------------------------------------------------
17// Template pools
18// ---------------------------------------------------------------------------
19
20/// KPI metric names used in management packs.
21const 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
36/// GL account categories used for budget variance lines.
37const BUDGET_ACCOUNTS: &[(&str, f64, f64)] = &[
38    // (label, budget_base_min, budget_base_max) all amounts in thousands
39    ("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
56/// Commentary templates keyed on overall budget position.
57const 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
80// ---------------------------------------------------------------------------
81// Generator
82// ---------------------------------------------------------------------------
83
84/// Generates [`ManagementReport`] instances representing monthly packs,
85/// quarterly board reports, and flash reports for a given entity.
86pub struct ManagementReportGenerator {
87    rng: ChaCha8Rng,
88    uuid_factory: DeterministicUuidFactory,
89}
90
91impl ManagementReportGenerator {
92    /// Create a new generator with the given seed.
93    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    /// Generate management reports for the given entity and fiscal year.
101    ///
102    /// Produces:
103    /// - 12 monthly flash reports (prepared on day 5 of the following month)
104    /// - 12 monthly packs (prepared on day 15 of the following month)
105    /// - 4 quarterly board reports (prepared ~20 days after quarter end)
106    ///
107    /// # Arguments
108    ///
109    /// * `entity_code` – The entity code these reports belong to.
110    /// * `fiscal_year` – The four-digit fiscal year (e.g., 2025).
111    /// * `period_months` – Number of months to generate (1–12).
112    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            // Flash report — quick preliminary numbers, prepared on day 5
125            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            // Monthly pack — full management pack, prepared on day 15
136            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            // Board report — one per quarter, prepared ~20 days after quarter end
147            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    /// Generate a single [`ManagementReport`].
166    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    /// Generate KPI summary lines with RAG statuses.
203    fn generate_kpi_summary(&mut self, count: usize) -> Vec<KpiSummaryLine> {
204        // Pick a random subset of the available metric pool
205        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                // Target in a realistic range (10–100)
215                let target_raw: f64 = self.rng.random_range(10.0..100.0);
216                let target = safe_decimal(target_raw, 2);
217
218                // Actual = target * (1 + variance)
219                // Distribution: 60% small (<5%), 30% medium (<10%), 10% large (>=10%)
220                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    /// Generate budget variance lines.
238    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    /// Pick a variance percentage with a realistic distribution.
270    ///
271    /// 60% small (|v| < 5%), 30% medium (5% ≤ |v| < 10%), 10% large (|v| ≥ 10%)
272    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    /// Generate a narrative commentary sentence based on overall budget position.
286    fn generate_commentary(&mut self, variances: &[BudgetVarianceLine]) -> String {
287        // Calculate average variance across all lines
288        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
307// ---------------------------------------------------------------------------
308// Helpers
309// ---------------------------------------------------------------------------
310
311/// Determine the RAG status from a variance percentage.
312///
313/// - green  : |variance| < 5%
314/// - amber  : 5% ≤ |variance| < 10%
315/// - red    : |variance| ≥ 10%
316fn 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
327/// Return a NaiveDate for `day` in the month following `(fiscal_year, month)`.
328/// If `month == 12` the returned date is in January of `fiscal_year + 1`.
329fn 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
340/// Convert an f64 to Decimal with NaN/Inf safety, rounded to `dp` decimal places.
341fn 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// ---------------------------------------------------------------------------
352// Tests
353// ---------------------------------------------------------------------------
354
355#[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        // 12 flash + 12 monthly_pack + 4 board = 28 total
366        assert_eq!(reports.len(), 28);
367
368        // All reports must have non-empty required fields
369        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        // Counts
391        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                // Allow small rounding difference (< 0.01)
441                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}