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