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.
5
6use 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
15/// Generates [`EarnedValueMetric`] records for projects.
16pub struct EarnedValueGenerator {
17    rng: ChaCha8Rng,
18    uuid_factory: DeterministicUuidFactory,
19    config: EarnedValueSchemaConfig,
20    counter: u64,
21}
22
23impl EarnedValueGenerator {
24    /// Create a new earned value generator.
25    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    /// Generate EVM metrics for a set of projects.
35    ///
36    /// Produces one metric per project per measurement period (monthly by default).
37    /// Planned Value (PV) is computed as a linear schedule baseline from the project budget.
38    /// Earned Value (EV) reflects the budget value of work actually performed (with a
39    /// small random efficiency factor to create realistic SPI/CPI variations).
40    /// Actual Cost (AC) comes directly from the cost lines.
41    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            // Parse project dates or use provided range
62            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            // Generate metrics at measurement frequency
76            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                // Actual Cost: sum of cost lines up to measurement date
84                let ac: Decimal = project_costs
85                    .iter()
86                    .filter(|cl| cl.posting_date <= measurement_date)
87                    .map(|cl| cl.amount)
88                    .sum();
89
90                // Skip periods with no cost activity
91                if ac.is_zero() {
92                    current = next_month_start(current);
93                    continue;
94                }
95
96                // Planned Value: linear schedule baseline
97                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                // Earned Value: actual cost adjusted by efficiency factor
103                // Creates realistic SPI/CPI variations
104                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
129/// Get the last day of a month.
130fn 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
142/// Get the first day of the next month.
143fn 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            // SV = EV - PV should be computed
238            assert_eq!(
239                metric.schedule_variance,
240                metric.earned_value - metric.planned_value
241            );
242            // CV = EV - AC should be computed
243            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            // Verify SPI = EV / PV
261            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            // Verify CPI = EV / AC
267            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            // Verify SV = EV - PV
273            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            // Verify CV = EV - AC
280            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}