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    // Stored for future configurable thresholds (e.g., schedule variance tolerance).
18    #[allow(dead_code)]
19    config: EarnedValueSchemaConfig,
20    counter: u64,
21}
22
23impl EarnedValueGenerator {
24    /// Create a new earned value generator.
25    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    /// Generate EVM metrics for a set of projects.
34    ///
35    /// Produces one metric per project per measurement period (monthly by default).
36    /// Planned Value (PV) is computed as a linear schedule baseline from the project budget.
37    /// Earned Value (EV) reflects the budget value of work actually performed (with a
38    /// small random efficiency factor to create realistic SPI/CPI variations).
39    /// Actual Cost (AC) comes directly from the cost lines.
40    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            // Parse project dates or use provided range
61            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            // Generate metrics at measurement frequency
75            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                // Actual Cost: sum of cost lines up to measurement date
83                let ac: Decimal = project_costs
84                    .iter()
85                    .filter(|cl| cl.posting_date <= measurement_date)
86                    .map(|cl| cl.amount)
87                    .sum();
88
89                // Skip periods with no cost activity
90                if ac.is_zero() {
91                    current = next_month_start(current);
92                    continue;
93                }
94
95                // Planned Value: linear schedule baseline
96                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                // Earned Value: actual cost adjusted by efficiency factor
102                // Creates realistic SPI/CPI variations
103                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
128/// Get the last day of a month.
129fn 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
141/// Get the first day of the next month.
142fn 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            // SV = EV - PV should be computed
237            assert_eq!(
238                metric.schedule_variance,
239                metric.earned_value - metric.planned_value
240            );
241            // CV = EV - AC should be computed
242            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            // Verify SPI = EV / PV
260            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            // Verify CPI = EV / AC
266            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            // Verify SV = EV - PV
272            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            // Verify CV = EV - AC
279            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}