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)]
168#[allow(clippy::unwrap_used)]
169mod tests {
170 use super::*;
171 use datasynth_core::models::{CostCategory, CostSourceType, ProjectType, WbsElement};
172
173 fn d(s: &str) -> NaiveDate {
174 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
175 }
176
177 fn test_project() -> Project {
178 let mut project = Project::new("PRJ-001", "Test Project", ProjectType::Capital)
179 .with_budget(dec!(1000000))
180 .with_company("TEST");
181 project.start_date = Some("2024-01-01".to_string());
182 project.end_date = Some("2024-12-31".to_string());
183 project.add_wbs_element(
184 WbsElement::new("PRJ-001.01", "PRJ-001", "Phase 1").with_budget(dec!(500000)),
185 );
186 project.add_wbs_element(
187 WbsElement::new("PRJ-001.02", "PRJ-001", "Phase 2").with_budget(dec!(500000)),
188 );
189 project
190 }
191
192 fn test_cost_lines() -> Vec<ProjectCostLine> {
193 vec![
194 ProjectCostLine::new(
195 "PCL-001",
196 "PRJ-001",
197 "PRJ-001.01",
198 "TEST",
199 d("2024-01-15"),
200 CostCategory::Labor,
201 CostSourceType::TimeEntry,
202 "TE-001",
203 dec!(80000),
204 "USD",
205 ),
206 ProjectCostLine::new(
207 "PCL-002",
208 "PRJ-001",
209 "PRJ-001.01",
210 "TEST",
211 d("2024-02-15"),
212 CostCategory::Labor,
213 CostSourceType::TimeEntry,
214 "TE-002",
215 dec!(90000),
216 "USD",
217 ),
218 ProjectCostLine::new(
219 "PCL-003",
220 "PRJ-001",
221 "PRJ-001.02",
222 "TEST",
223 d("2024-03-15"),
224 CostCategory::Material,
225 CostSourceType::PurchaseOrder,
226 "PO-001",
227 dec!(120000),
228 "USD",
229 ),
230 ]
231 }
232
233 #[test]
234 fn test_evm_generation() {
235 let project = test_project();
236 let cost_lines = test_cost_lines();
237 let config = EarnedValueSchemaConfig::default();
238
239 let mut gen = EarnedValueGenerator::new(config, 42);
240 let metrics = gen.generate(&[project], &cost_lines, d("2024-01-01"), d("2024-03-31"));
241
242 assert_eq!(
243 metrics.len(),
244 3,
245 "Should have one metric per month with costs"
246 );
247
248 for metric in &metrics {
249 assert_eq!(metric.project_id, "PRJ-001");
250 assert_eq!(metric.bac, dec!(1000000));
251 assert!(metric.actual_cost > Decimal::ZERO);
252 assert_eq!(
254 metric.schedule_variance,
255 metric.earned_value - metric.planned_value
256 );
257 assert_eq!(
259 metric.cost_variance,
260 metric.earned_value - metric.actual_cost
261 );
262 }
263 }
264
265 #[test]
266 fn test_evm_formulas_correct() {
267 let project = test_project();
268 let cost_lines = test_cost_lines();
269 let config = EarnedValueSchemaConfig::default();
270
271 let mut gen = EarnedValueGenerator::new(config, 42);
272 let metrics = gen.generate(&[project], &cost_lines, d("2024-01-01"), d("2024-03-31"));
273
274 for metric in &metrics {
275 if metric.planned_value > Decimal::ZERO {
277 let expected_spi = (metric.earned_value / metric.planned_value).round_dp(4);
278 assert_eq!(metric.spi, expected_spi, "SPI formula incorrect");
279 }
280
281 if metric.actual_cost > Decimal::ZERO {
283 let expected_cpi = (metric.earned_value / metric.actual_cost).round_dp(4);
284 assert_eq!(metric.cpi, expected_cpi, "CPI formula incorrect");
285 }
286
287 let expected_sv = (metric.earned_value - metric.planned_value).round_dp(2);
289 assert_eq!(
290 metric.schedule_variance, expected_sv,
291 "SV formula incorrect"
292 );
293
294 let expected_cv = (metric.earned_value - metric.actual_cost).round_dp(2);
296 assert_eq!(metric.cost_variance, expected_cv, "CV formula incorrect");
297 }
298 }
299
300 #[test]
301 fn test_evm_no_costs_no_metrics() {
302 let project = test_project();
303 let config = EarnedValueSchemaConfig::default();
304
305 let mut gen = EarnedValueGenerator::new(config, 42);
306 let metrics = gen.generate(&[project], &[], d("2024-01-01"), d("2024-03-31"));
307
308 assert!(metrics.is_empty(), "No costs should produce no EVM metrics");
309 }
310
311 #[test]
312 fn test_evm_deterministic() {
313 let project = test_project();
314 let cost_lines = test_cost_lines();
315 let config = EarnedValueSchemaConfig::default();
316
317 let mut gen1 = EarnedValueGenerator::new(config.clone(), 42);
318 let m1 = gen1.generate(
319 std::slice::from_ref(&project),
320 &cost_lines,
321 d("2024-01-01"),
322 d("2024-03-31"),
323 );
324
325 let mut gen2 = EarnedValueGenerator::new(config, 42);
326 let m2 = gen2.generate(
327 std::slice::from_ref(&project),
328 &cost_lines,
329 d("2024-01-01"),
330 d("2024-03-31"),
331 );
332
333 assert_eq!(m1.len(), m2.len());
334 for (a, b) in m1.iter().zip(m2.iter()) {
335 assert_eq!(a.spi, b.spi);
336 assert_eq!(a.cpi, b.cpi);
337 assert_eq!(a.eac, b.eac);
338 }
339 }
340}