Skip to main content

datasynth_generators/project_accounting/
earned_value_generator.rs

1//! Earned Value Management (EVM) metrics generator.
2//!
3//! Computes EVM metrics (SPI, CPI, EAC, ETC, TCPI) for projects based on
4//! WBS budgets, actual costs, and schedule progress.
5use 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
14/// Generates [`EarnedValueMetric`] records for projects.
15pub struct EarnedValueGenerator {
16    rng: ChaCha8Rng,
17    /// Controls measurement frequency (weekly, biweekly, monthly).
18    config: EarnedValueSchemaConfig,
19    counter: u64,
20}
21
22impl EarnedValueGenerator {
23    /// Create a new earned value generator.
24    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    /// Generate EVM metrics for a set of projects.
33    ///
34    /// Produces one metric per project per measurement period (monthly by default).
35    /// Planned Value (PV) is computed as a linear schedule baseline from the project budget.
36    /// Earned Value (EV) reflects the budget value of work actually performed (with a
37    /// small random efficiency factor to create realistic SPI/CPI variations).
38    /// Actual Cost (AC) comes directly from the cost lines.
39    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            // Parse project dates or use provided range
60            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            // Generate metrics at measurement frequency
74            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                // Actual Cost: sum of cost lines up to measurement date
82                let ac: Decimal = project_costs
83                    .iter()
84                    .filter(|cl| cl.posting_date <= measurement_date)
85                    .map(|cl| cl.amount)
86                    .sum();
87
88                // Skip periods with no cost activity
89                if ac.is_zero() {
90                    current = self.advance_date(current);
91                    continue;
92                }
93
94                // Planned Value: linear schedule baseline
95                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                // Earned Value: actual cost adjusted by efficiency factor
101                // Creates realistic SPI/CPI variations
102                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    /// Advance to the next measurement period based on configured frequency.
127    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    /// Get the measurement date for the current period.
136    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
144/// Get the last day of a month.
145fn 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
157/// Get the first day of the next month.
158fn 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            // SV = EV - PV should be computed
253            assert_eq!(
254                metric.schedule_variance,
255                metric.earned_value - metric.planned_value
256            );
257            // CV = EV - AC should be computed
258            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            // Verify SPI = EV / PV
276            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            // Verify CPI = EV / AC
282            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            // Verify SV = EV - PV
288            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            // Verify CV = EV - AC
295            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}