datasynth_generators/project_accounting/
earned_value_generator.rs1use chrono::{Datelike, NaiveDate};
6use datasynth_config::schema::EarnedValueSchemaConfig;
7use datasynth_core::models::{EarnedValueMetric, Project, ProjectCostLine};
8use datasynth_core::utils::seeded_rng;
9use rand::prelude::*;
10use rand_chacha::ChaCha8Rng;
11use rust_decimal::Decimal;
12use rust_decimal_macros::dec;
13
14pub struct EarnedValueGenerator {
16 rng: ChaCha8Rng,
17 config: EarnedValueSchemaConfig,
19 counter: u64,
20}
21
22impl EarnedValueGenerator {
23 pub fn new(config: EarnedValueSchemaConfig, seed: u64) -> Self {
25 Self {
26 rng: seeded_rng(seed, 0),
27 config,
28 counter: 0,
29 }
30 }
31
32 pub fn generate(
40 &mut self,
41 projects: &[Project],
42 cost_lines: &[ProjectCostLine],
43 start_date: NaiveDate,
44 end_date: NaiveDate,
45 ) -> Vec<EarnedValueMetric> {
46 let mut metrics = Vec::new();
47
48 for project in projects {
49 if !project.allows_postings() && project.budget.is_zero() {
50 continue;
51 }
52
53 let bac = project.budget;
54 let project_costs: Vec<&ProjectCostLine> = cost_lines
55 .iter()
56 .filter(|cl| cl.project_id == project.project_id)
57 .collect();
58
59 let proj_start = project
61 .start_date
62 .as_ref()
63 .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
64 .unwrap_or(start_date);
65 let proj_end = project
66 .end_date
67 .as_ref()
68 .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
69 .unwrap_or(end_date);
70
71 let total_days = (proj_end - proj_start).num_days().max(1) as f64;
72
73 let mut current = start_date;
75 while current <= end_date {
76 let measurement_date = self.measurement_date(current);
77 if measurement_date > end_date {
78 break;
79 }
80
81 let ac: Decimal = project_costs
83 .iter()
84 .filter(|cl| cl.posting_date <= measurement_date)
85 .map(|cl| cl.amount)
86 .sum();
87
88 if ac.is_zero() {
90 current = self.advance_date(current);
91 continue;
92 }
93
94 let elapsed_days = (measurement_date - proj_start).num_days().max(0) as f64;
96 let schedule_pct = (elapsed_days / total_days).min(1.0);
97 let pv =
98 (bac * Decimal::from_f64_retain(schedule_pct).unwrap_or(dec!(0))).round_dp(2);
99
100 let efficiency: f64 = self.rng.random_range(0.75..1.25);
103 let ev = (ac * Decimal::from_f64_retain(efficiency).unwrap_or(dec!(1)))
104 .min(bac)
105 .round_dp(2);
106
107 self.counter += 1;
108 let metric = EarnedValueMetric::compute(
109 format!("EVM-{:06}", self.counter),
110 &project.project_id,
111 measurement_date,
112 bac,
113 pv,
114 ev,
115 ac,
116 );
117 metrics.push(metric);
118
119 current = self.advance_date(current);
120 }
121 }
122
123 metrics
124 }
125
126 fn advance_date(&self, current: NaiveDate) -> NaiveDate {
128 match self.config.frequency.as_str() {
129 "weekly" => current + chrono::Duration::weeks(1),
130 "biweekly" => current + chrono::Duration::weeks(2),
131 _ => next_month_start(current),
132 }
133 }
134
135 fn measurement_date(&self, current: NaiveDate) -> NaiveDate {
137 match self.config.frequency.as_str() {
138 "weekly" | "biweekly" => current,
139 _ => end_of_month(current),
140 }
141 }
142}
143
144fn end_of_month(date: NaiveDate) -> NaiveDate {
146 let (year, month) = if date.month() == 12 {
147 (date.year() + 1, 1)
148 } else {
149 (date.year(), date.month() + 1)
150 };
151 NaiveDate::from_ymd_opt(year, month, 1)
152 .expect("valid date")
153 .pred_opt()
154 .expect("valid date")
155}
156
157fn next_month_start(date: NaiveDate) -> NaiveDate {
159 let (year, month) = if date.month() == 12 {
160 (date.year() + 1, 1)
161 } else {
162 (date.year(), date.month() + 1)
163 };
164 NaiveDate::from_ymd_opt(year, month, 1).expect("valid date")
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use datasynth_core::models::{CostCategory, CostSourceType, ProjectType, WbsElement};
171
172 fn d(s: &str) -> NaiveDate {
173 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
174 }
175
176 fn test_project() -> Project {
177 let mut project = Project::new("PRJ-001", "Test Project", ProjectType::Capital)
178 .with_budget(dec!(1000000))
179 .with_company("TEST");
180 project.start_date = Some("2024-01-01".to_string());
181 project.end_date = Some("2024-12-31".to_string());
182 project.add_wbs_element(
183 WbsElement::new("PRJ-001.01", "PRJ-001", "Phase 1").with_budget(dec!(500000)),
184 );
185 project.add_wbs_element(
186 WbsElement::new("PRJ-001.02", "PRJ-001", "Phase 2").with_budget(dec!(500000)),
187 );
188 project
189 }
190
191 fn test_cost_lines() -> Vec<ProjectCostLine> {
192 vec![
193 ProjectCostLine::new(
194 "PCL-001",
195 "PRJ-001",
196 "PRJ-001.01",
197 "TEST",
198 d("2024-01-15"),
199 CostCategory::Labor,
200 CostSourceType::TimeEntry,
201 "TE-001",
202 dec!(80000),
203 "USD",
204 ),
205 ProjectCostLine::new(
206 "PCL-002",
207 "PRJ-001",
208 "PRJ-001.01",
209 "TEST",
210 d("2024-02-15"),
211 CostCategory::Labor,
212 CostSourceType::TimeEntry,
213 "TE-002",
214 dec!(90000),
215 "USD",
216 ),
217 ProjectCostLine::new(
218 "PCL-003",
219 "PRJ-001",
220 "PRJ-001.02",
221 "TEST",
222 d("2024-03-15"),
223 CostCategory::Material,
224 CostSourceType::PurchaseOrder,
225 "PO-001",
226 dec!(120000),
227 "USD",
228 ),
229 ]
230 }
231
232 #[test]
233 fn test_evm_generation() {
234 let project = test_project();
235 let cost_lines = test_cost_lines();
236 let config = EarnedValueSchemaConfig::default();
237
238 let mut gen = EarnedValueGenerator::new(config, 42);
239 let metrics = gen.generate(&[project], &cost_lines, d("2024-01-01"), d("2024-03-31"));
240
241 assert_eq!(
242 metrics.len(),
243 3,
244 "Should have one metric per month with costs"
245 );
246
247 for metric in &metrics {
248 assert_eq!(metric.project_id, "PRJ-001");
249 assert_eq!(metric.bac, dec!(1000000));
250 assert!(metric.actual_cost > Decimal::ZERO);
251 assert_eq!(
253 metric.schedule_variance,
254 metric.earned_value - metric.planned_value
255 );
256 assert_eq!(
258 metric.cost_variance,
259 metric.earned_value - metric.actual_cost
260 );
261 }
262 }
263
264 #[test]
265 fn test_evm_formulas_correct() {
266 let project = test_project();
267 let cost_lines = test_cost_lines();
268 let config = EarnedValueSchemaConfig::default();
269
270 let mut gen = EarnedValueGenerator::new(config, 42);
271 let metrics = gen.generate(&[project], &cost_lines, d("2024-01-01"), d("2024-03-31"));
272
273 for metric in &metrics {
274 if metric.planned_value > Decimal::ZERO {
276 let expected_spi = (metric.earned_value / metric.planned_value).round_dp(4);
277 assert_eq!(metric.spi, expected_spi, "SPI formula incorrect");
278 }
279
280 if metric.actual_cost > Decimal::ZERO {
282 let expected_cpi = (metric.earned_value / metric.actual_cost).round_dp(4);
283 assert_eq!(metric.cpi, expected_cpi, "CPI formula incorrect");
284 }
285
286 let expected_sv = (metric.earned_value - metric.planned_value).round_dp(2);
288 assert_eq!(
289 metric.schedule_variance, expected_sv,
290 "SV formula incorrect"
291 );
292
293 let expected_cv = (metric.earned_value - metric.actual_cost).round_dp(2);
295 assert_eq!(metric.cost_variance, expected_cv, "CV formula incorrect");
296 }
297 }
298
299 #[test]
300 fn test_evm_no_costs_no_metrics() {
301 let project = test_project();
302 let config = EarnedValueSchemaConfig::default();
303
304 let mut gen = EarnedValueGenerator::new(config, 42);
305 let metrics = gen.generate(&[project], &[], d("2024-01-01"), d("2024-03-31"));
306
307 assert!(metrics.is_empty(), "No costs should produce no EVM metrics");
308 }
309
310 #[test]
311 fn test_evm_deterministic() {
312 let project = test_project();
313 let cost_lines = test_cost_lines();
314 let config = EarnedValueSchemaConfig::default();
315
316 let mut gen1 = EarnedValueGenerator::new(config.clone(), 42);
317 let m1 = gen1.generate(
318 std::slice::from_ref(&project),
319 &cost_lines,
320 d("2024-01-01"),
321 d("2024-03-31"),
322 );
323
324 let mut gen2 = EarnedValueGenerator::new(config, 42);
325 let m2 = gen2.generate(
326 std::slice::from_ref(&project),
327 &cost_lines,
328 d("2024-01-01"),
329 d("2024-03-31"),
330 );
331
332 assert_eq!(m1.len(), m2.len());
333 for (a, b) in m1.iter().zip(m2.iter()) {
334 assert_eq!(a.spi, b.spi);
335 assert_eq!(a.cpi, b.cpi);
336 assert_eq!(a.eac, b.eac);
337 }
338 }
339}