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