datasynth_eval/coherence/
project_accounting.rs1use crate::error::EvalResult;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ProjectAccountingThresholds {
12 pub min_evm_accuracy: f64,
14 pub min_poc_accuracy: f64,
16 pub evm_tolerance: f64,
18}
19
20impl Default for ProjectAccountingThresholds {
21 fn default() -> Self {
22 Self {
23 min_evm_accuracy: 0.999,
24 min_poc_accuracy: 0.99,
25 evm_tolerance: 0.01,
26 }
27 }
28}
29
30#[derive(Debug, Clone)]
32pub struct ProjectRevenueData {
33 pub project_id: String,
35 pub costs_to_date: f64,
37 pub estimated_total_cost: f64,
39 pub completion_pct: f64,
41 pub contract_value: f64,
43 pub cumulative_revenue: f64,
45 pub billed_to_date: f64,
47 pub unbilled_revenue: f64,
49}
50
51#[derive(Debug, Clone)]
53pub struct EarnedValueData {
54 pub project_id: String,
56 pub planned_value: f64,
58 pub earned_value: f64,
60 pub actual_cost: f64,
62 pub bac: f64,
64 pub schedule_variance: f64,
66 pub cost_variance: f64,
68 pub spi: f64,
70 pub cpi: f64,
72}
73
74#[derive(Debug, Clone)]
76pub struct RetainageData {
77 pub retainage_id: String,
79 pub total_held: f64,
81 pub released_amount: f64,
83 pub balance_held: f64,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ProjectAccountingEvaluation {
90 pub poc_accuracy: f64,
92 pub revenue_accuracy: f64,
94 pub unbilled_accuracy: f64,
96 pub evm_accuracy: f64,
98 pub retainage_accuracy: f64,
100 pub total_projects: usize,
102 pub total_evm_records: usize,
104 pub total_retainage: usize,
106 pub passes: bool,
108 pub issues: Vec<String>,
110}
111
112pub struct ProjectAccountingEvaluator {
114 thresholds: ProjectAccountingThresholds,
115}
116
117impl ProjectAccountingEvaluator {
118 pub fn new() -> Self {
120 Self {
121 thresholds: ProjectAccountingThresholds::default(),
122 }
123 }
124
125 pub fn with_thresholds(thresholds: ProjectAccountingThresholds) -> Self {
127 Self { thresholds }
128 }
129
130 pub fn evaluate(
132 &self,
133 projects: &[ProjectRevenueData],
134 evm_records: &[EarnedValueData],
135 retainage: &[RetainageData],
136 ) -> EvalResult<ProjectAccountingEvaluation> {
137 let mut issues = Vec::new();
138 let tolerance = self.thresholds.evm_tolerance;
139
140 let poc_ok = projects
142 .iter()
143 .filter(|p| {
144 if p.estimated_total_cost <= 0.0 {
145 return true;
146 }
147 let expected = p.costs_to_date / p.estimated_total_cost;
148 (p.completion_pct - expected).abs() <= tolerance
149 })
150 .count();
151 let poc_accuracy = if projects.is_empty() {
152 1.0
153 } else {
154 poc_ok as f64 / projects.len() as f64
155 };
156
157 let rev_ok = projects
159 .iter()
160 .filter(|p| {
161 let expected = p.contract_value * p.completion_pct;
162 (p.cumulative_revenue - expected).abs()
163 <= tolerance * p.contract_value.abs().max(1.0)
164 })
165 .count();
166 let revenue_accuracy = if projects.is_empty() {
167 1.0
168 } else {
169 rev_ok as f64 / projects.len() as f64
170 };
171
172 let unbilled_ok = projects
174 .iter()
175 .filter(|p| {
176 let expected = p.cumulative_revenue - p.billed_to_date;
177 (p.unbilled_revenue - expected).abs()
178 <= tolerance * p.cumulative_revenue.abs().max(1.0)
179 })
180 .count();
181 let unbilled_accuracy = if projects.is_empty() {
182 1.0
183 } else {
184 unbilled_ok as f64 / projects.len() as f64
185 };
186
187 let evm_ok = evm_records
189 .iter()
190 .filter(|e| {
191 let sv_expected = e.earned_value - e.planned_value;
192 let cv_expected = e.earned_value - e.actual_cost;
193 let sv_ok = (e.schedule_variance - sv_expected).abs()
194 <= tolerance * e.earned_value.abs().max(1.0);
195 let cv_ok = (e.cost_variance - cv_expected).abs()
196 <= tolerance * e.earned_value.abs().max(1.0);
197
198 let spi_ok = if e.planned_value > 0.0 {
199 let expected = e.earned_value / e.planned_value;
200 (e.spi - expected).abs() <= tolerance
201 } else {
202 true
203 };
204 let cpi_ok = if e.actual_cost > 0.0 {
205 let expected = e.earned_value / e.actual_cost;
206 (e.cpi - expected).abs() <= tolerance
207 } else {
208 true
209 };
210
211 sv_ok && cv_ok && spi_ok && cpi_ok
212 })
213 .count();
214 let evm_accuracy = if evm_records.is_empty() {
215 1.0
216 } else {
217 evm_ok as f64 / evm_records.len() as f64
218 };
219
220 let ret_ok = retainage
222 .iter()
223 .filter(|r| {
224 let expected = r.total_held - r.released_amount;
225 (r.balance_held - expected).abs() <= tolerance * r.total_held.abs().max(1.0)
226 })
227 .count();
228 let retainage_accuracy = if retainage.is_empty() {
229 1.0
230 } else {
231 ret_ok as f64 / retainage.len() as f64
232 };
233
234 if poc_accuracy < self.thresholds.min_poc_accuracy {
236 issues.push(format!(
237 "PoC completion accuracy {:.4} < {:.4}",
238 poc_accuracy, self.thresholds.min_poc_accuracy
239 ));
240 }
241 if revenue_accuracy < self.thresholds.min_poc_accuracy {
242 issues.push(format!(
243 "Revenue recognition accuracy {:.4} < {:.4}",
244 revenue_accuracy, self.thresholds.min_poc_accuracy
245 ));
246 }
247 if unbilled_accuracy < self.thresholds.min_poc_accuracy {
248 issues.push(format!(
249 "Unbilled revenue accuracy {:.4} < {:.4}",
250 unbilled_accuracy, self.thresholds.min_poc_accuracy
251 ));
252 }
253 if evm_accuracy < self.thresholds.min_evm_accuracy {
254 issues.push(format!(
255 "EVM metric accuracy {:.4} < {:.4}",
256 evm_accuracy, self.thresholds.min_evm_accuracy
257 ));
258 }
259 if retainage_accuracy < self.thresholds.min_evm_accuracy {
260 issues.push(format!(
261 "Retainage balance accuracy {:.4} < {:.4}",
262 retainage_accuracy, self.thresholds.min_evm_accuracy
263 ));
264 }
265
266 let passes = issues.is_empty();
267
268 Ok(ProjectAccountingEvaluation {
269 poc_accuracy,
270 revenue_accuracy,
271 unbilled_accuracy,
272 evm_accuracy,
273 retainage_accuracy,
274 total_projects: projects.len(),
275 total_evm_records: evm_records.len(),
276 total_retainage: retainage.len(),
277 passes,
278 issues,
279 })
280 }
281}
282
283impl Default for ProjectAccountingEvaluator {
284 fn default() -> Self {
285 Self::new()
286 }
287}
288
289#[cfg(test)]
290#[allow(clippy::unwrap_used)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn test_valid_project_accounting() {
296 let evaluator = ProjectAccountingEvaluator::new();
297 let projects = vec![ProjectRevenueData {
298 project_id: "PRJ001".to_string(),
299 costs_to_date: 500_000.0,
300 estimated_total_cost: 1_000_000.0,
301 completion_pct: 0.50,
302 contract_value: 1_200_000.0,
303 cumulative_revenue: 600_000.0,
304 billed_to_date: 550_000.0,
305 unbilled_revenue: 50_000.0,
306 }];
307 let evm = vec![EarnedValueData {
308 project_id: "PRJ001".to_string(),
309 planned_value: 600_000.0,
310 earned_value: 500_000.0,
311 actual_cost: 520_000.0,
312 bac: 1_000_000.0,
313 schedule_variance: -100_000.0,
314 cost_variance: -20_000.0,
315 spi: 500_000.0 / 600_000.0,
316 cpi: 500_000.0 / 520_000.0,
317 }];
318 let retainage = vec![RetainageData {
319 retainage_id: "RET001".to_string(),
320 total_held: 60_000.0,
321 released_amount: 10_000.0,
322 balance_held: 50_000.0,
323 }];
324
325 let result = evaluator.evaluate(&projects, &evm, &retainage).unwrap();
326 assert!(result.passes);
327 assert_eq!(result.total_projects, 1);
328 assert_eq!(result.total_evm_records, 1);
329 }
330
331 #[test]
332 fn test_wrong_completion_pct() {
333 let evaluator = ProjectAccountingEvaluator::new();
334 let projects = vec![ProjectRevenueData {
335 project_id: "PRJ001".to_string(),
336 costs_to_date: 500_000.0,
337 estimated_total_cost: 1_000_000.0,
338 completion_pct: 0.80, contract_value: 1_200_000.0,
340 cumulative_revenue: 960_000.0,
341 billed_to_date: 900_000.0,
342 unbilled_revenue: 60_000.0,
343 }];
344
345 let result = evaluator.evaluate(&projects, &[], &[]).unwrap();
346 assert!(!result.passes);
347 assert!(result.issues.iter().any(|i| i.contains("PoC completion")));
348 }
349
350 #[test]
351 fn test_wrong_evm_metrics() {
352 let evaluator = ProjectAccountingEvaluator::new();
353 let evm = vec![EarnedValueData {
354 project_id: "PRJ001".to_string(),
355 planned_value: 600_000.0,
356 earned_value: 500_000.0,
357 actual_cost: 520_000.0,
358 bac: 1_000_000.0,
359 schedule_variance: 0.0, cost_variance: -20_000.0,
361 spi: 500_000.0 / 600_000.0,
362 cpi: 500_000.0 / 520_000.0,
363 }];
364
365 let result = evaluator.evaluate(&[], &evm, &[]).unwrap();
366 assert!(!result.passes);
367 assert!(result.issues.iter().any(|i| i.contains("EVM metric")));
368 }
369
370 #[test]
371 fn test_wrong_retainage_balance() {
372 let evaluator = ProjectAccountingEvaluator::new();
373 let retainage = vec![RetainageData {
374 retainage_id: "RET001".to_string(),
375 total_held: 60_000.0,
376 released_amount: 10_000.0,
377 balance_held: 60_000.0, }];
379
380 let result = evaluator.evaluate(&[], &[], &retainage).unwrap();
381 assert!(!result.passes);
382 assert!(result.issues.iter().any(|i| i.contains("Retainage")));
383 }
384
385 #[test]
386 fn test_wrong_cumulative_revenue() {
387 let evaluator = ProjectAccountingEvaluator::new();
388 let projects = vec![ProjectRevenueData {
389 project_id: "PRJ001".to_string(),
390 costs_to_date: 500_000.0,
391 estimated_total_cost: 1_000_000.0,
392 completion_pct: 0.50,
393 contract_value: 1_200_000.0,
394 cumulative_revenue: 900_000.0, billed_to_date: 550_000.0,
396 unbilled_revenue: 350_000.0,
397 }];
398
399 let result = evaluator.evaluate(&projects, &[], &[]).unwrap();
400 assert!(!result.passes);
401 assert!(result
402 .issues
403 .iter()
404 .any(|i| i.contains("Revenue recognition")));
405 }
406
407 #[test]
408 fn test_wrong_unbilled_revenue() {
409 let evaluator = ProjectAccountingEvaluator::new();
410 let projects = vec![ProjectRevenueData {
411 project_id: "PRJ001".to_string(),
412 costs_to_date: 500_000.0,
413 estimated_total_cost: 1_000_000.0,
414 completion_pct: 0.50,
415 contract_value: 1_200_000.0,
416 cumulative_revenue: 600_000.0,
417 billed_to_date: 550_000.0,
418 unbilled_revenue: 200_000.0, }];
420
421 let result = evaluator.evaluate(&projects, &[], &[]).unwrap();
422 assert!(!result.passes);
423 assert!(result.issues.iter().any(|i| i.contains("Unbilled revenue")));
424 }
425
426 #[test]
427 fn test_empty_data() {
428 let evaluator = ProjectAccountingEvaluator::new();
429 let result = evaluator.evaluate(&[], &[], &[]).unwrap();
430 assert!(result.passes);
431 }
432}