1use chrono::NaiveDate;
7use datasynth_config::schema::BudgetConfig;
8use datasynth_core::models::{Budget, BudgetLineItem, BudgetStatus};
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
15const DEPARTMENTS: &[&str] = &["Finance", "Sales", "Engineering", "Operations", "HR"];
17
18const COST_CENTERS: &[&str] = &["CC-100", "CC-200", "CC-300", "CC-400", "CC-500"];
20
21pub struct BudgetGenerator {
24 rng: ChaCha8Rng,
25 uuid_factory: DeterministicUuidFactory,
26 line_uuid_factory: DeterministicUuidFactory,
27}
28
29impl BudgetGenerator {
30 pub fn new(seed: u64) -> Self {
32 Self {
33 rng: seeded_rng(seed, 0),
34 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::BudgetLine),
35 line_uuid_factory: DeterministicUuidFactory::with_sub_discriminator(
36 seed,
37 GeneratorType::BudgetLine,
38 1,
39 ),
40 }
41 }
42
43 pub fn generate(
52 &mut self,
53 company_code: &str,
54 fiscal_year: u32,
55 account_codes: &[(String, String)],
56 config: &BudgetConfig,
57 ) -> Budget {
58 tracing::debug!(
59 company_code,
60 fiscal_year,
61 accounts = account_codes.len(),
62 "Generating budget"
63 );
64 let budget_id = self.uuid_factory.next().to_string();
65
66 let mut line_items = Vec::new();
67 let mut total_budget = Decimal::ZERO;
68 let mut total_actual = Decimal::ZERO;
69
70 for (idx, (account_code, account_name)) in account_codes.iter().enumerate() {
71 let dept_idx = idx % DEPARTMENTS.len();
73 let department = DEPARTMENTS[dept_idx];
74 let cost_center = COST_CENTERS[dept_idx];
75
76 for month in 1..=12u32 {
78 let line = self.generate_line_item(
79 &budget_id,
80 account_code,
81 account_name,
82 department,
83 cost_center,
84 fiscal_year,
85 month,
86 config,
87 );
88 total_budget += line.budget_amount;
89 total_actual += line.actual_amount;
90 line_items.push(line);
91 }
92 }
93
94 let total_variance = total_actual - total_budget;
95
96 let status_roll: f64 = self.rng.random();
98 let status = if status_roll < 0.60 {
99 BudgetStatus::Approved
100 } else if status_roll < 0.80 {
101 BudgetStatus::Closed
102 } else if status_roll < 0.95 {
103 BudgetStatus::Revised
104 } else {
105 BudgetStatus::Submitted
106 };
107
108 let (approved_by, approved_date) =
110 if matches!(status, BudgetStatus::Approved | BudgetStatus::Closed) {
111 let approver = if self.rng.random_bool(0.5) {
112 "CFO-001".to_string()
113 } else {
114 "VP-FIN-001".to_string()
115 };
116 let approve_date =
118 NaiveDate::from_ymd_opt(fiscal_year.saturating_sub(1) as i32, 12, 15)
119 .or_else(|| NaiveDate::from_ymd_opt(fiscal_year as i32, 1, 1));
120 (Some(approver), approve_date)
121 } else {
122 (None, None)
123 };
124
125 Budget {
126 budget_id,
127 company_code: company_code.to_string(),
128 fiscal_year,
129 name: format!("FY{} Operating Budget", fiscal_year),
130 status,
131 total_budget: total_budget.round_dp(2),
132 total_actual: total_actual.round_dp(2),
133 total_variance: total_variance.round_dp(2),
134 line_items,
135 approved_by,
136 approved_date,
137 }
138 }
139
140 #[allow(clippy::too_many_arguments)]
142 fn generate_line_item(
143 &mut self,
144 budget_id: &str,
145 account_code: &str,
146 account_name: &str,
147 department: &str,
148 cost_center: &str,
149 fiscal_year: u32,
150 month: u32,
151 config: &BudgetConfig,
152 ) -> BudgetLineItem {
153 let line_id = self.line_uuid_factory.next().to_string();
154
155 let base_amount: f64 = self.rng.random_range(10_000.0..500_000.0);
157 let growth_adjusted = base_amount * (1.0 + config.revenue_growth_rate);
158 let budget_amount = Decimal::from_f64_retain(growth_adjusted)
159 .unwrap_or(Decimal::ZERO)
160 .round_dp(2);
161
162 let variance_factor: f64 = self
164 .rng
165 .random_range(-config.variance_noise..config.variance_noise);
166 let actual_raw = growth_adjusted * (1.0 + variance_factor);
167 let actual_amount = Decimal::from_f64_retain(actual_raw)
168 .unwrap_or(Decimal::ZERO)
169 .round_dp(2);
170
171 let variance = actual_amount - budget_amount;
173
174 let variance_percent = if budget_amount != Decimal::ZERO {
176 let pct = variance.to_string().parse::<f64>().unwrap_or(0.0) / growth_adjusted * 100.0;
177 (pct * 100.0).round() / 100.0
178 } else {
179 0.0
180 };
181
182 let period_start =
184 NaiveDate::from_ymd_opt(fiscal_year as i32, month, 1).unwrap_or_else(|| {
185 NaiveDate::from_ymd_opt(fiscal_year as i32, 1, 1)
186 .unwrap_or(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap_or_default())
187 });
188
189 let period_end = {
190 let (next_year, next_month) = if month == 12 {
191 (fiscal_year as i32 + 1, 1)
192 } else {
193 (fiscal_year as i32, month + 1)
194 };
195 NaiveDate::from_ymd_opt(next_year, next_month, 1)
196 .and_then(|d| d.pred_opt())
197 .unwrap_or(period_start)
198 };
199
200 let notes = if variance_percent.abs() > 5.0 {
202 Some(format!(
203 "Variance of {:.1}% requires management review",
204 variance_percent
205 ))
206 } else {
207 None
208 };
209
210 BudgetLineItem {
211 line_id,
212 budget_id: budget_id.to_string(),
213 account_code: account_code.to_string(),
214 account_name: account_name.to_string(),
215 department: Some(department.to_string()),
216 cost_center: Some(cost_center.to_string()),
217 budget_amount,
218 actual_amount,
219 variance,
220 variance_percent,
221 period_start,
222 period_end,
223 notes,
224 }
225 }
226}
227
228#[cfg(test)]
233#[allow(clippy::unwrap_used)]
234mod tests {
235 use super::*;
236
237 fn sample_accounts() -> Vec<(String, String)> {
238 vec![
239 ("4000".to_string(), "Revenue".to_string()),
240 ("5000".to_string(), "Cost of Goods Sold".to_string()),
241 ("6100".to_string(), "Salaries Expense".to_string()),
242 ("6200".to_string(), "Rent Expense".to_string()),
243 ("6300".to_string(), "Utilities Expense".to_string()),
244 ]
245 }
246
247 fn default_config() -> BudgetConfig {
248 BudgetConfig {
249 enabled: true,
250 revenue_growth_rate: 0.05,
251 expense_inflation_rate: 0.03,
252 variance_noise: 0.10,
253 }
254 }
255
256 #[test]
257 fn test_basic_generation_produces_expected_structure() {
258 let mut gen = BudgetGenerator::new(42);
259 let accounts = sample_accounts();
260 let config = default_config();
261
262 let budget = gen.generate("C001", 2024, &accounts, &config);
263
264 assert!(!budget.budget_id.is_empty());
266 assert_eq!(budget.company_code, "C001");
267 assert_eq!(budget.fiscal_year, 2024);
268 assert_eq!(budget.name, "FY2024 Operating Budget");
269
270 assert_eq!(budget.line_items.len(), 60);
272
273 let sum_budget: Decimal = budget.line_items.iter().map(|l| l.budget_amount).sum();
275 let sum_actual: Decimal = budget.line_items.iter().map(|l| l.actual_amount).sum();
276 assert_eq!(budget.total_budget, sum_budget.round_dp(2));
277 assert_eq!(budget.total_actual, sum_actual.round_dp(2));
278
279 let expected_variance = budget.total_actual - budget.total_budget;
281 assert_eq!(budget.total_variance, expected_variance);
282
283 for line in &budget.line_items {
285 assert!(line.department.is_some());
286 assert!(line.cost_center.is_some());
287 assert!(line.budget_amount > Decimal::ZERO);
288 assert!(line.actual_amount > Decimal::ZERO);
289 }
290
291 if matches!(budget.status, BudgetStatus::Approved | BudgetStatus::Closed) {
293 assert!(budget.approved_by.is_some());
294 assert!(budget.approved_date.is_some());
295 }
296 }
297
298 #[test]
299 fn test_deterministic_output_with_same_seed() {
300 let accounts = sample_accounts();
301 let config = default_config();
302
303 let mut gen1 = BudgetGenerator::new(12345);
304 let budget1 = gen1.generate("C001", 2025, &accounts, &config);
305
306 let mut gen2 = BudgetGenerator::new(12345);
307 let budget2 = gen2.generate("C001", 2025, &accounts, &config);
308
309 assert_eq!(budget1.budget_id, budget2.budget_id);
310 assert_eq!(budget1.total_budget, budget2.total_budget);
311 assert_eq!(budget1.total_actual, budget2.total_actual);
312 assert_eq!(budget1.line_items.len(), budget2.line_items.len());
313
314 for (l1, l2) in budget1.line_items.iter().zip(budget2.line_items.iter()) {
315 assert_eq!(l1.line_id, l2.line_id);
316 assert_eq!(l1.budget_amount, l2.budget_amount);
317 assert_eq!(l1.actual_amount, l2.actual_amount);
318 assert_eq!(l1.variance, l2.variance);
319 }
320 }
321
322 #[test]
323 fn test_variance_within_noise_bounds() {
324 let mut gen = BudgetGenerator::new(777);
325 let accounts = sample_accounts();
326 let config = BudgetConfig {
327 enabled: true,
328 revenue_growth_rate: 0.0,
329 expense_inflation_rate: 0.0,
330 variance_noise: 0.10,
331 };
332
333 let budget = gen.generate("C002", 2024, &accounts, &config);
334
335 for line in &budget.line_items {
337 let ratio = if line.budget_amount != Decimal::ZERO {
338 (line.actual_amount - line.budget_amount).abs() / line.budget_amount
339 } else {
340 Decimal::ZERO
341 };
342 assert!(
344 ratio <= Decimal::from_f64_retain(0.11).unwrap_or(Decimal::ONE),
345 "Variance ratio {} exceeds noise bound for account {}",
346 ratio,
347 line.account_code
348 );
349 }
350 }
351}