use crate::error::EvalResult;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectAccountingThresholds {
pub min_evm_accuracy: f64,
pub min_poc_accuracy: f64,
pub evm_tolerance: f64,
}
impl Default for ProjectAccountingThresholds {
fn default() -> Self {
Self {
min_evm_accuracy: 0.999,
min_poc_accuracy: 0.99,
evm_tolerance: 0.01,
}
}
}
#[derive(Debug, Clone)]
pub struct ProjectRevenueData {
pub project_id: String,
pub costs_to_date: f64,
pub estimated_total_cost: f64,
pub completion_pct: f64,
pub contract_value: f64,
pub cumulative_revenue: f64,
pub billed_to_date: f64,
pub unbilled_revenue: f64,
}
#[derive(Debug, Clone)]
pub struct EarnedValueData {
pub project_id: String,
pub planned_value: f64,
pub earned_value: f64,
pub actual_cost: f64,
pub bac: f64,
pub schedule_variance: f64,
pub cost_variance: f64,
pub spi: f64,
pub cpi: f64,
}
#[derive(Debug, Clone)]
pub struct RetainageData {
pub retainage_id: String,
pub total_held: f64,
pub released_amount: f64,
pub balance_held: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectAccountingEvaluation {
pub poc_accuracy: f64,
pub revenue_accuracy: f64,
pub unbilled_accuracy: f64,
pub evm_accuracy: f64,
pub retainage_accuracy: f64,
pub total_projects: usize,
pub total_evm_records: usize,
pub total_retainage: usize,
pub passes: bool,
pub issues: Vec<String>,
}
pub struct ProjectAccountingEvaluator {
thresholds: ProjectAccountingThresholds,
}
impl ProjectAccountingEvaluator {
pub fn new() -> Self {
Self {
thresholds: ProjectAccountingThresholds::default(),
}
}
pub fn with_thresholds(thresholds: ProjectAccountingThresholds) -> Self {
Self { thresholds }
}
pub fn evaluate(
&self,
projects: &[ProjectRevenueData],
evm_records: &[EarnedValueData],
retainage: &[RetainageData],
) -> EvalResult<ProjectAccountingEvaluation> {
let mut issues = Vec::new();
let tolerance = self.thresholds.evm_tolerance;
let poc_ok = projects
.iter()
.filter(|p| {
if p.estimated_total_cost <= 0.0 {
return true;
}
let expected = p.costs_to_date / p.estimated_total_cost;
(p.completion_pct - expected).abs() <= tolerance
})
.count();
let poc_accuracy = if projects.is_empty() {
1.0
} else {
poc_ok as f64 / projects.len() as f64
};
let rev_ok = projects
.iter()
.filter(|p| {
let expected = p.contract_value * p.completion_pct;
(p.cumulative_revenue - expected).abs()
<= tolerance * p.contract_value.abs().max(1.0)
})
.count();
let revenue_accuracy = if projects.is_empty() {
1.0
} else {
rev_ok as f64 / projects.len() as f64
};
let unbilled_ok = projects
.iter()
.filter(|p| {
let expected = p.cumulative_revenue - p.billed_to_date;
(p.unbilled_revenue - expected).abs()
<= tolerance * p.cumulative_revenue.abs().max(1.0)
})
.count();
let unbilled_accuracy = if projects.is_empty() {
1.0
} else {
unbilled_ok as f64 / projects.len() as f64
};
let evm_ok = evm_records
.iter()
.filter(|e| {
let sv_expected = e.earned_value - e.planned_value;
let cv_expected = e.earned_value - e.actual_cost;
let sv_ok = (e.schedule_variance - sv_expected).abs()
<= tolerance * e.earned_value.abs().max(1.0);
let cv_ok = (e.cost_variance - cv_expected).abs()
<= tolerance * e.earned_value.abs().max(1.0);
let spi_ok = if e.planned_value > 0.0 {
let expected = e.earned_value / e.planned_value;
(e.spi - expected).abs() <= tolerance
} else {
true
};
let cpi_ok = if e.actual_cost > 0.0 {
let expected = e.earned_value / e.actual_cost;
(e.cpi - expected).abs() <= tolerance
} else {
true
};
sv_ok && cv_ok && spi_ok && cpi_ok
})
.count();
let evm_accuracy = if evm_records.is_empty() {
1.0
} else {
evm_ok as f64 / evm_records.len() as f64
};
let ret_ok = retainage
.iter()
.filter(|r| {
let expected = r.total_held - r.released_amount;
(r.balance_held - expected).abs() <= tolerance * r.total_held.abs().max(1.0)
})
.count();
let retainage_accuracy = if retainage.is_empty() {
1.0
} else {
ret_ok as f64 / retainage.len() as f64
};
if poc_accuracy < self.thresholds.min_poc_accuracy {
issues.push(format!(
"PoC completion accuracy {:.4} < {:.4}",
poc_accuracy, self.thresholds.min_poc_accuracy
));
}
if revenue_accuracy < self.thresholds.min_poc_accuracy {
issues.push(format!(
"Revenue recognition accuracy {:.4} < {:.4}",
revenue_accuracy, self.thresholds.min_poc_accuracy
));
}
if unbilled_accuracy < self.thresholds.min_poc_accuracy {
issues.push(format!(
"Unbilled revenue accuracy {:.4} < {:.4}",
unbilled_accuracy, self.thresholds.min_poc_accuracy
));
}
if evm_accuracy < self.thresholds.min_evm_accuracy {
issues.push(format!(
"EVM metric accuracy {:.4} < {:.4}",
evm_accuracy, self.thresholds.min_evm_accuracy
));
}
if retainage_accuracy < self.thresholds.min_evm_accuracy {
issues.push(format!(
"Retainage balance accuracy {:.4} < {:.4}",
retainage_accuracy, self.thresholds.min_evm_accuracy
));
}
let passes = issues.is_empty();
Ok(ProjectAccountingEvaluation {
poc_accuracy,
revenue_accuracy,
unbilled_accuracy,
evm_accuracy,
retainage_accuracy,
total_projects: projects.len(),
total_evm_records: evm_records.len(),
total_retainage: retainage.len(),
passes,
issues,
})
}
}
impl Default for ProjectAccountingEvaluator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_valid_project_accounting() {
let evaluator = ProjectAccountingEvaluator::new();
let projects = vec![ProjectRevenueData {
project_id: "PRJ001".to_string(),
costs_to_date: 500_000.0,
estimated_total_cost: 1_000_000.0,
completion_pct: 0.50,
contract_value: 1_200_000.0,
cumulative_revenue: 600_000.0,
billed_to_date: 550_000.0,
unbilled_revenue: 50_000.0,
}];
let evm = vec![EarnedValueData {
project_id: "PRJ001".to_string(),
planned_value: 600_000.0,
earned_value: 500_000.0,
actual_cost: 520_000.0,
bac: 1_000_000.0,
schedule_variance: -100_000.0,
cost_variance: -20_000.0,
spi: 500_000.0 / 600_000.0,
cpi: 500_000.0 / 520_000.0,
}];
let retainage = vec![RetainageData {
retainage_id: "RET001".to_string(),
total_held: 60_000.0,
released_amount: 10_000.0,
balance_held: 50_000.0,
}];
let result = evaluator.evaluate(&projects, &evm, &retainage).unwrap();
assert!(result.passes);
assert_eq!(result.total_projects, 1);
assert_eq!(result.total_evm_records, 1);
}
#[test]
fn test_wrong_completion_pct() {
let evaluator = ProjectAccountingEvaluator::new();
let projects = vec![ProjectRevenueData {
project_id: "PRJ001".to_string(),
costs_to_date: 500_000.0,
estimated_total_cost: 1_000_000.0,
completion_pct: 0.80, contract_value: 1_200_000.0,
cumulative_revenue: 960_000.0,
billed_to_date: 900_000.0,
unbilled_revenue: 60_000.0,
}];
let result = evaluator.evaluate(&projects, &[], &[]).unwrap();
assert!(!result.passes);
assert!(result.issues.iter().any(|i| i.contains("PoC completion")));
}
#[test]
fn test_wrong_evm_metrics() {
let evaluator = ProjectAccountingEvaluator::new();
let evm = vec![EarnedValueData {
project_id: "PRJ001".to_string(),
planned_value: 600_000.0,
earned_value: 500_000.0,
actual_cost: 520_000.0,
bac: 1_000_000.0,
schedule_variance: 0.0, cost_variance: -20_000.0,
spi: 500_000.0 / 600_000.0,
cpi: 500_000.0 / 520_000.0,
}];
let result = evaluator.evaluate(&[], &evm, &[]).unwrap();
assert!(!result.passes);
assert!(result.issues.iter().any(|i| i.contains("EVM metric")));
}
#[test]
fn test_wrong_retainage_balance() {
let evaluator = ProjectAccountingEvaluator::new();
let retainage = vec![RetainageData {
retainage_id: "RET001".to_string(),
total_held: 60_000.0,
released_amount: 10_000.0,
balance_held: 60_000.0, }];
let result = evaluator.evaluate(&[], &[], &retainage).unwrap();
assert!(!result.passes);
assert!(result.issues.iter().any(|i| i.contains("Retainage")));
}
#[test]
fn test_wrong_cumulative_revenue() {
let evaluator = ProjectAccountingEvaluator::new();
let projects = vec![ProjectRevenueData {
project_id: "PRJ001".to_string(),
costs_to_date: 500_000.0,
estimated_total_cost: 1_000_000.0,
completion_pct: 0.50,
contract_value: 1_200_000.0,
cumulative_revenue: 900_000.0, billed_to_date: 550_000.0,
unbilled_revenue: 350_000.0,
}];
let result = evaluator.evaluate(&projects, &[], &[]).unwrap();
assert!(!result.passes);
assert!(result
.issues
.iter()
.any(|i| i.contains("Revenue recognition")));
}
#[test]
fn test_wrong_unbilled_revenue() {
let evaluator = ProjectAccountingEvaluator::new();
let projects = vec![ProjectRevenueData {
project_id: "PRJ001".to_string(),
costs_to_date: 500_000.0,
estimated_total_cost: 1_000_000.0,
completion_pct: 0.50,
contract_value: 1_200_000.0,
cumulative_revenue: 600_000.0,
billed_to_date: 550_000.0,
unbilled_revenue: 200_000.0, }];
let result = evaluator.evaluate(&projects, &[], &[]).unwrap();
assert!(!result.passes);
assert!(result.issues.iter().any(|i| i.contains("Unbilled revenue")));
}
#[test]
fn test_empty_data() {
let evaluator = ProjectAccountingEvaluator::new();
let result = evaluator.evaluate(&[], &[], &[]).unwrap();
assert!(result.passes);
}
}