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