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)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn test_valid_project_accounting() {
295 let evaluator = ProjectAccountingEvaluator::new();
296 let projects = vec![ProjectRevenueData {
297 project_id: "PRJ001".to_string(),
298 costs_to_date: 500_000.0,
299 estimated_total_cost: 1_000_000.0,
300 completion_pct: 0.50,
301 contract_value: 1_200_000.0,
302 cumulative_revenue: 600_000.0,
303 billed_to_date: 550_000.0,
304 unbilled_revenue: 50_000.0,
305 }];
306 let evm = vec![EarnedValueData {
307 project_id: "PRJ001".to_string(),
308 planned_value: 600_000.0,
309 earned_value: 500_000.0,
310 actual_cost: 520_000.0,
311 bac: 1_000_000.0,
312 schedule_variance: -100_000.0,
313 cost_variance: -20_000.0,
314 spi: 500_000.0 / 600_000.0,
315 cpi: 500_000.0 / 520_000.0,
316 }];
317 let retainage = vec![RetainageData {
318 retainage_id: "RET001".to_string(),
319 total_held: 60_000.0,
320 released_amount: 10_000.0,
321 balance_held: 50_000.0,
322 }];
323
324 let result = evaluator.evaluate(&projects, &evm, &retainage).unwrap();
325 assert!(result.passes);
326 assert_eq!(result.total_projects, 1);
327 assert_eq!(result.total_evm_records, 1);
328 }
329
330 #[test]
331 fn test_wrong_completion_pct() {
332 let evaluator = ProjectAccountingEvaluator::new();
333 let projects = vec![ProjectRevenueData {
334 project_id: "PRJ001".to_string(),
335 costs_to_date: 500_000.0,
336 estimated_total_cost: 1_000_000.0,
337 completion_pct: 0.80, contract_value: 1_200_000.0,
339 cumulative_revenue: 960_000.0,
340 billed_to_date: 900_000.0,
341 unbilled_revenue: 60_000.0,
342 }];
343
344 let result = evaluator.evaluate(&projects, &[], &[]).unwrap();
345 assert!(!result.passes);
346 assert!(result.issues.iter().any(|i| i.contains("PoC completion")));
347 }
348
349 #[test]
350 fn test_wrong_evm_metrics() {
351 let evaluator = ProjectAccountingEvaluator::new();
352 let evm = vec![EarnedValueData {
353 project_id: "PRJ001".to_string(),
354 planned_value: 600_000.0,
355 earned_value: 500_000.0,
356 actual_cost: 520_000.0,
357 bac: 1_000_000.0,
358 schedule_variance: 0.0, cost_variance: -20_000.0,
360 spi: 500_000.0 / 600_000.0,
361 cpi: 500_000.0 / 520_000.0,
362 }];
363
364 let result = evaluator.evaluate(&[], &evm, &[]).unwrap();
365 assert!(!result.passes);
366 assert!(result.issues.iter().any(|i| i.contains("EVM metric")));
367 }
368
369 #[test]
370 fn test_wrong_retainage_balance() {
371 let evaluator = ProjectAccountingEvaluator::new();
372 let retainage = vec![RetainageData {
373 retainage_id: "RET001".to_string(),
374 total_held: 60_000.0,
375 released_amount: 10_000.0,
376 balance_held: 60_000.0, }];
378
379 let result = evaluator.evaluate(&[], &[], &retainage).unwrap();
380 assert!(!result.passes);
381 assert!(result.issues.iter().any(|i| i.contains("Retainage")));
382 }
383
384 #[test]
385 fn test_wrong_cumulative_revenue() {
386 let evaluator = ProjectAccountingEvaluator::new();
387 let projects = vec![ProjectRevenueData {
388 project_id: "PRJ001".to_string(),
389 costs_to_date: 500_000.0,
390 estimated_total_cost: 1_000_000.0,
391 completion_pct: 0.50,
392 contract_value: 1_200_000.0,
393 cumulative_revenue: 900_000.0, billed_to_date: 550_000.0,
395 unbilled_revenue: 350_000.0,
396 }];
397
398 let result = evaluator.evaluate(&projects, &[], &[]).unwrap();
399 assert!(!result.passes);
400 assert!(result
401 .issues
402 .iter()
403 .any(|i| i.contains("Revenue recognition")));
404 }
405
406 #[test]
407 fn test_wrong_unbilled_revenue() {
408 let evaluator = ProjectAccountingEvaluator::new();
409 let projects = vec![ProjectRevenueData {
410 project_id: "PRJ001".to_string(),
411 costs_to_date: 500_000.0,
412 estimated_total_cost: 1_000_000.0,
413 completion_pct: 0.50,
414 contract_value: 1_200_000.0,
415 cumulative_revenue: 600_000.0,
416 billed_to_date: 550_000.0,
417 unbilled_revenue: 200_000.0, }];
419
420 let result = evaluator.evaluate(&projects, &[], &[]).unwrap();
421 assert!(!result.passes);
422 assert!(result.issues.iter().any(|i| i.contains("Unbilled revenue")));
423 }
424
425 #[test]
426 fn test_empty_data() {
427 let evaluator = ProjectAccountingEvaluator::new();
428 let result = evaluator.evaluate(&[], &[], &[]).unwrap();
429 assert!(result.passes);
430 }
431}