use chrono::NaiveDate;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::graph_properties::{GraphPropertyValue, ToNodeProperties};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CostCategory {
#[default]
Labor,
Material,
Subcontractor,
Overhead,
Equipment,
Travel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CostSourceType {
#[default]
TimeEntry,
ExpenseReport,
PurchaseOrder,
VendorInvoice,
JournalEntry,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectCostLine {
pub id: String,
pub project_id: String,
pub wbs_id: String,
pub entity_id: String,
pub posting_date: NaiveDate,
pub cost_category: CostCategory,
pub source_type: CostSourceType,
pub source_document_id: String,
#[serde(with = "crate::serde_decimal")]
pub amount: Decimal,
pub currency: String,
pub hours: Option<Decimal>,
pub description: String,
}
impl ProjectCostLine {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: impl Into<String>,
project_id: impl Into<String>,
wbs_id: impl Into<String>,
entity_id: impl Into<String>,
posting_date: NaiveDate,
cost_category: CostCategory,
source_type: CostSourceType,
source_document_id: impl Into<String>,
amount: Decimal,
currency: impl Into<String>,
) -> Self {
Self {
id: id.into(),
project_id: project_id.into(),
wbs_id: wbs_id.into(),
entity_id: entity_id.into(),
posting_date,
cost_category,
source_type,
source_document_id: source_document_id.into(),
amount,
currency: currency.into(),
hours: None,
description: String::new(),
}
}
pub fn with_hours(mut self, hours: Decimal) -> Self {
self.hours = Some(hours);
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
pub fn hourly_rate(&self) -> Option<Decimal> {
self.hours
.filter(|h| !h.is_zero())
.map(|h| (self.amount / h).round_dp(2))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RevenueMethod {
#[default]
PercentageOfCompletion,
CompletedContract,
MilestoneBased,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CompletionMeasure {
#[default]
CostToCost,
LaborHours,
PhysicalCompletion,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectRevenue {
pub id: String,
pub project_id: String,
pub entity_id: String,
pub period_start: NaiveDate,
pub period_end: NaiveDate,
#[serde(with = "crate::serde_decimal")]
pub contract_value: Decimal,
#[serde(with = "crate::serde_decimal")]
pub estimated_total_cost: Decimal,
#[serde(with = "crate::serde_decimal")]
pub costs_to_date: Decimal,
#[serde(with = "crate::serde_decimal")]
pub completion_pct: Decimal,
pub method: RevenueMethod,
pub measure: CompletionMeasure,
#[serde(with = "crate::serde_decimal")]
pub cumulative_revenue: Decimal,
#[serde(with = "crate::serde_decimal")]
pub period_revenue: Decimal,
#[serde(with = "crate::serde_decimal")]
pub billed_to_date: Decimal,
#[serde(with = "crate::serde_decimal")]
pub unbilled_revenue: Decimal,
#[serde(with = "crate::serde_decimal")]
pub gross_margin_pct: Decimal,
}
impl ProjectRevenue {
pub fn computed_completion_pct(&self) -> Decimal {
if self.estimated_total_cost.is_zero() {
return Decimal::ZERO;
}
(self.costs_to_date / self.estimated_total_cost).round_dp(4)
}
pub fn computed_cumulative_revenue(&self) -> Decimal {
(self.contract_value * self.completion_pct).round_dp(2)
}
pub fn computed_unbilled_revenue(&self) -> Decimal {
(self.cumulative_revenue - self.billed_to_date).round_dp(2)
}
pub fn computed_gross_margin_pct(&self) -> Decimal {
if self.contract_value.is_zero() {
return Decimal::ZERO;
}
((self.contract_value - self.estimated_total_cost) / self.contract_value).round_dp(4)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MilestoneStatus {
#[default]
Pending,
InProgress,
Completed,
Overdue,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectMilestone {
pub id: String,
pub project_id: String,
pub wbs_id: Option<String>,
pub name: String,
pub planned_date: NaiveDate,
pub actual_date: Option<NaiveDate>,
pub status: MilestoneStatus,
#[serde(with = "crate::serde_decimal")]
pub payment_amount: Decimal,
#[serde(with = "crate::serde_decimal")]
pub weight: Decimal,
pub sequence: u32,
}
impl ProjectMilestone {
pub fn new(
id: impl Into<String>,
project_id: impl Into<String>,
name: impl Into<String>,
planned_date: NaiveDate,
sequence: u32,
) -> Self {
Self {
id: id.into(),
project_id: project_id.into(),
wbs_id: None,
name: name.into(),
planned_date,
actual_date: None,
status: MilestoneStatus::Pending,
payment_amount: Decimal::ZERO,
weight: Decimal::ZERO,
sequence,
}
}
pub fn with_wbs(mut self, wbs_id: impl Into<String>) -> Self {
self.wbs_id = Some(wbs_id.into());
self
}
pub fn with_payment(mut self, amount: Decimal) -> Self {
self.payment_amount = amount;
self
}
pub fn with_weight(mut self, weight: Decimal) -> Self {
self.weight = weight;
self
}
pub fn is_overdue_on(&self, date: NaiveDate) -> bool {
self.actual_date.is_none() && date > self.planned_date
}
pub fn days_variance(&self) -> Option<i64> {
self.actual_date
.map(|actual| (actual - self.planned_date).num_days())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ChangeOrderStatus {
#[default]
Submitted,
UnderReview,
Approved,
Rejected,
Withdrawn,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ChangeReason {
#[default]
ScopeChange,
UnforeseenConditions,
DesignError,
RegulatoryChange,
ValueEngineering,
ScheduleAcceleration,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangeOrder {
pub id: String,
pub project_id: String,
pub number: u32,
pub submitted_date: NaiveDate,
pub approved_date: Option<NaiveDate>,
pub status: ChangeOrderStatus,
pub reason: ChangeReason,
pub description: String,
#[serde(with = "crate::serde_decimal")]
pub cost_impact: Decimal,
#[serde(with = "crate::serde_decimal")]
pub estimated_cost_impact: Decimal,
pub schedule_impact_days: i32,
}
impl ChangeOrder {
pub fn new(
id: impl Into<String>,
project_id: impl Into<String>,
number: u32,
submitted_date: NaiveDate,
reason: ChangeReason,
description: impl Into<String>,
) -> Self {
Self {
id: id.into(),
project_id: project_id.into(),
number,
submitted_date,
approved_date: None,
status: ChangeOrderStatus::Submitted,
reason,
description: description.into(),
cost_impact: Decimal::ZERO,
estimated_cost_impact: Decimal::ZERO,
schedule_impact_days: 0,
}
}
pub fn with_cost_impact(mut self, contract_impact: Decimal, estimated_impact: Decimal) -> Self {
self.cost_impact = contract_impact;
self.estimated_cost_impact = estimated_impact;
self
}
pub fn with_schedule_impact(mut self, days: i32) -> Self {
self.schedule_impact_days = days;
self
}
pub fn approve(mut self, date: NaiveDate) -> Self {
self.status = ChangeOrderStatus::Approved;
self.approved_date = Some(date);
self
}
pub fn is_approved(&self) -> bool {
self.status == ChangeOrderStatus::Approved
}
pub fn net_cost_impact(&self) -> Decimal {
if self.is_approved() {
self.cost_impact
} else {
Decimal::ZERO
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RetainageStatus {
#[default]
Held,
PartiallyReleased,
Released,
Forfeited,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Retainage {
pub id: String,
pub project_id: String,
pub entity_id: String,
pub vendor_id: String,
#[serde(with = "crate::serde_decimal")]
pub retainage_pct: Decimal,
#[serde(with = "crate::serde_decimal")]
pub total_held: Decimal,
#[serde(with = "crate::serde_decimal")]
pub released_amount: Decimal,
pub status: RetainageStatus,
pub inception_date: NaiveDate,
pub last_release_date: Option<NaiveDate>,
}
impl Retainage {
pub fn new(
id: impl Into<String>,
project_id: impl Into<String>,
entity_id: impl Into<String>,
vendor_id: impl Into<String>,
retainage_pct: Decimal,
inception_date: NaiveDate,
) -> Self {
Self {
id: id.into(),
project_id: project_id.into(),
entity_id: entity_id.into(),
vendor_id: vendor_id.into(),
retainage_pct,
total_held: Decimal::ZERO,
released_amount: Decimal::ZERO,
status: RetainageStatus::Held,
inception_date,
last_release_date: None,
}
}
pub fn add_from_payment(&mut self, payment_amount: Decimal) {
let held = (payment_amount * self.retainage_pct).round_dp(2);
self.total_held += held;
}
pub fn balance_held(&self) -> Decimal {
(self.total_held - self.released_amount).round_dp(2)
}
pub fn release(&mut self, amount: Decimal, date: NaiveDate) {
let release = amount.min(self.balance_held());
self.released_amount += release;
self.last_release_date = Some(date);
if self.balance_held().is_zero() {
self.status = RetainageStatus::Released;
} else {
self.status = RetainageStatus::PartiallyReleased;
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EarnedValueMetric {
pub id: String,
pub project_id: String,
pub measurement_date: NaiveDate,
#[serde(with = "crate::serde_decimal")]
pub bac: Decimal,
#[serde(with = "crate::serde_decimal")]
pub planned_value: Decimal,
#[serde(with = "crate::serde_decimal")]
pub earned_value: Decimal,
#[serde(with = "crate::serde_decimal")]
pub actual_cost: Decimal,
#[serde(with = "crate::serde_decimal")]
pub schedule_variance: Decimal,
#[serde(with = "crate::serde_decimal")]
pub cost_variance: Decimal,
#[serde(with = "crate::serde_decimal")]
pub spi: Decimal,
#[serde(with = "crate::serde_decimal")]
pub cpi: Decimal,
#[serde(with = "crate::serde_decimal")]
pub eac: Decimal,
#[serde(with = "crate::serde_decimal")]
pub etc: Decimal,
#[serde(with = "crate::serde_decimal")]
pub tcpi: Decimal,
}
impl EarnedValueMetric {
pub fn compute(
id: impl Into<String>,
project_id: impl Into<String>,
measurement_date: NaiveDate,
bac: Decimal,
planned_value: Decimal,
earned_value: Decimal,
actual_cost: Decimal,
) -> Self {
let sv = (earned_value - planned_value).round_dp(2);
let cv = (earned_value - actual_cost).round_dp(2);
let spi = if planned_value.is_zero() {
dec!(1.00)
} else {
(earned_value / planned_value).round_dp(4)
};
let cpi = if actual_cost.is_zero() {
dec!(1.00)
} else {
(earned_value / actual_cost).round_dp(4)
};
let eac = if cpi.is_zero() {
bac
} else {
(bac / cpi).round_dp(2)
};
let etc = (eac - actual_cost).round_dp(2);
let remaining_budget = bac - actual_cost;
let remaining_work = bac - earned_value;
let tcpi = if remaining_budget.is_zero() {
dec!(1.00)
} else {
(remaining_work / remaining_budget).round_dp(4)
};
Self {
id: id.into(),
project_id: project_id.into(),
measurement_date,
bac,
planned_value,
earned_value,
actual_cost,
schedule_variance: sv,
cost_variance: cv,
spi,
cpi,
eac,
etc,
tcpi,
}
}
pub fn is_ahead_of_schedule(&self) -> bool {
self.spi > dec!(1.00)
}
pub fn is_under_budget(&self) -> bool {
self.cpi > dec!(1.00)
}
pub fn is_healthy(&self) -> bool {
self.spi >= dec!(0.90) && self.cpi >= dec!(0.90)
}
pub fn variance_at_completion(&self) -> Decimal {
(self.bac - self.eac).round_dp(2)
}
}
impl ToNodeProperties for ProjectCostLine {
fn node_type_name(&self) -> &'static str {
"project_cost_line"
}
fn node_type_code(&self) -> u16 {
451
}
fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
let mut p = HashMap::new();
p.insert(
"projectId".into(),
GraphPropertyValue::String(self.project_id.clone()),
);
p.insert(
"wbsElement".into(),
GraphPropertyValue::String(self.wbs_id.clone()),
);
p.insert(
"entityCode".into(),
GraphPropertyValue::String(self.entity_id.clone()),
);
p.insert(
"postingDate".into(),
GraphPropertyValue::Date(self.posting_date),
);
p.insert(
"costCategory".into(),
GraphPropertyValue::String(format!("{:?}", self.cost_category)),
);
p.insert(
"sourceType".into(),
GraphPropertyValue::String(format!("{:?}", self.source_type)),
);
if !self.source_document_id.is_empty() {
p.insert(
"sourceDocumentId".into(),
GraphPropertyValue::String(self.source_document_id.clone()),
);
}
p.insert(
"actualAmount".into(),
GraphPropertyValue::Decimal(self.amount),
);
p.insert(
"currency".into(),
GraphPropertyValue::String(self.currency.clone()),
);
if let Some(hours) = self.hours {
p.insert("hours".into(), GraphPropertyValue::Decimal(hours));
}
p
}
}
impl ToNodeProperties for ProjectRevenue {
fn node_type_name(&self) -> &'static str {
"project_revenue"
}
fn node_type_code(&self) -> u16 {
452
}
fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
let mut p = HashMap::new();
p.insert(
"projectId".into(),
GraphPropertyValue::String(self.project_id.clone()),
);
p.insert(
"entityCode".into(),
GraphPropertyValue::String(self.entity_id.clone()),
);
p.insert(
"periodStart".into(),
GraphPropertyValue::Date(self.period_start),
);
p.insert(
"periodEnd".into(),
GraphPropertyValue::Date(self.period_end),
);
p.insert(
"contractValue".into(),
GraphPropertyValue::Decimal(self.contract_value),
);
p.insert(
"completionPct".into(),
GraphPropertyValue::Decimal(self.completion_pct),
);
p.insert(
"method".into(),
GraphPropertyValue::String(format!("{:?}", self.method)),
);
p.insert(
"cumulativeRevenue".into(),
GraphPropertyValue::Decimal(self.cumulative_revenue),
);
p.insert(
"periodRevenue".into(),
GraphPropertyValue::Decimal(self.period_revenue),
);
p.insert(
"grossMarginPct".into(),
GraphPropertyValue::Decimal(self.gross_margin_pct),
);
p
}
}
impl ToNodeProperties for ProjectMilestone {
fn node_type_name(&self) -> &'static str {
"project_milestone"
}
fn node_type_code(&self) -> u16 {
455
}
fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
let mut p = HashMap::new();
p.insert(
"projectId".into(),
GraphPropertyValue::String(self.project_id.clone()),
);
if let Some(ref wbs) = self.wbs_id {
p.insert("wbsElement".into(), GraphPropertyValue::String(wbs.clone()));
}
p.insert(
"milestoneName".into(),
GraphPropertyValue::String(self.name.clone()),
);
p.insert(
"plannedDate".into(),
GraphPropertyValue::Date(self.planned_date),
);
if let Some(actual) = self.actual_date {
p.insert("actualDate".into(), GraphPropertyValue::Date(actual));
}
p.insert(
"status".into(),
GraphPropertyValue::String(format!("{:?}", self.status)),
);
p.insert(
"paymentAmount".into(),
GraphPropertyValue::Decimal(self.payment_amount),
);
p.insert("weightPct".into(), GraphPropertyValue::Decimal(self.weight));
p.insert(
"isComplete".into(),
GraphPropertyValue::Bool(matches!(self.status, MilestoneStatus::Completed)),
);
p
}
}
impl ToNodeProperties for ChangeOrder {
fn node_type_name(&self) -> &'static str {
"change_order"
}
fn node_type_code(&self) -> u16 {
454
}
fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
let mut p = HashMap::new();
p.insert(
"projectId".into(),
GraphPropertyValue::String(self.project_id.clone()),
);
p.insert(
"orderNumber".into(),
GraphPropertyValue::Int(self.number as i64),
);
p.insert(
"submittedDate".into(),
GraphPropertyValue::Date(self.submitted_date),
);
if let Some(approved) = self.approved_date {
p.insert("approvedDate".into(), GraphPropertyValue::Date(approved));
}
p.insert(
"status".into(),
GraphPropertyValue::String(format!("{:?}", self.status)),
);
p.insert(
"reason".into(),
GraphPropertyValue::String(format!("{:?}", self.reason)),
);
p.insert(
"costImpact".into(),
GraphPropertyValue::Decimal(self.cost_impact),
);
p.insert(
"scheduleImpactDays".into(),
GraphPropertyValue::Int(self.schedule_impact_days as i64),
);
p.insert(
"isApproved".into(),
GraphPropertyValue::Bool(matches!(self.status, ChangeOrderStatus::Approved)),
);
p
}
}
impl ToNodeProperties for EarnedValueMetric {
fn node_type_name(&self) -> &'static str {
"earned_value_metric"
}
fn node_type_code(&self) -> u16 {
453
}
fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
let mut p = HashMap::new();
p.insert(
"projectId".into(),
GraphPropertyValue::String(self.project_id.clone()),
);
p.insert(
"measurementDate".into(),
GraphPropertyValue::Date(self.measurement_date),
);
p.insert("bac".into(), GraphPropertyValue::Decimal(self.bac));
p.insert(
"plannedValue".into(),
GraphPropertyValue::Decimal(self.planned_value),
);
p.insert(
"earnedValue".into(),
GraphPropertyValue::Decimal(self.earned_value),
);
p.insert(
"actualCost".into(),
GraphPropertyValue::Decimal(self.actual_cost),
);
p.insert("spi".into(), GraphPropertyValue::Decimal(self.spi));
p.insert("cpi".into(), GraphPropertyValue::Decimal(self.cpi));
p.insert("eac".into(), GraphPropertyValue::Decimal(self.eac));
p
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn d(s: &str) -> NaiveDate {
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
}
#[test]
fn test_cost_line_creation() {
let line = ProjectCostLine::new(
"PCL-001",
"PRJ-001",
"PRJ-001.01",
"C001",
d("2025-03-15"),
CostCategory::Labor,
CostSourceType::TimeEntry,
"TE-001",
dec!(1500),
"USD",
)
.with_hours(dec!(20))
.with_description("Developer time - sprint 5");
assert_eq!(line.cost_category, CostCategory::Labor);
assert_eq!(line.source_type, CostSourceType::TimeEntry);
assert_eq!(line.amount, dec!(1500));
assert_eq!(line.hours, Some(dec!(20)));
assert_eq!(line.hourly_rate(), Some(dec!(75.00)));
}
#[test]
fn test_cost_line_hourly_rate_no_hours() {
let line = ProjectCostLine::new(
"PCL-002",
"PRJ-001",
"PRJ-001.02",
"C001",
d("2025-03-15"),
CostCategory::Material,
CostSourceType::PurchaseOrder,
"PO-001",
dec!(5000),
"USD",
);
assert_eq!(line.hourly_rate(), None);
}
#[test]
fn test_revenue_poc_completion() {
let rev = ProjectRevenue {
id: "REV-001".to_string(),
project_id: "PRJ-001".to_string(),
entity_id: "C001".to_string(),
period_start: d("2025-01-01"),
period_end: d("2025-03-31"),
contract_value: dec!(1000000),
estimated_total_cost: dec!(800000),
costs_to_date: dec!(400000),
completion_pct: dec!(0.50),
method: RevenueMethod::PercentageOfCompletion,
measure: CompletionMeasure::CostToCost,
cumulative_revenue: dec!(500000),
period_revenue: dec!(200000),
billed_to_date: dec!(400000),
unbilled_revenue: dec!(100000),
gross_margin_pct: dec!(0.20),
};
assert_eq!(rev.computed_completion_pct(), dec!(0.5000));
assert_eq!(rev.computed_cumulative_revenue(), dec!(500000.00));
assert_eq!(rev.computed_unbilled_revenue(), dec!(100000.00));
assert_eq!(rev.computed_gross_margin_pct(), dec!(0.2000));
}
#[test]
fn test_revenue_zero_estimated_cost() {
let rev = ProjectRevenue {
id: "REV-002".to_string(),
project_id: "PRJ-002".to_string(),
entity_id: "C001".to_string(),
period_start: d("2025-01-01"),
period_end: d("2025-03-31"),
contract_value: dec!(100000),
estimated_total_cost: Decimal::ZERO,
costs_to_date: Decimal::ZERO,
completion_pct: Decimal::ZERO,
method: RevenueMethod::PercentageOfCompletion,
measure: CompletionMeasure::CostToCost,
cumulative_revenue: Decimal::ZERO,
period_revenue: Decimal::ZERO,
billed_to_date: Decimal::ZERO,
unbilled_revenue: Decimal::ZERO,
gross_margin_pct: Decimal::ZERO,
};
assert_eq!(rev.computed_completion_pct(), Decimal::ZERO);
}
#[test]
fn test_milestone_creation() {
let ms = ProjectMilestone::new(
"MS-001",
"PRJ-001",
"Foundation Complete",
d("2025-06-30"),
1,
)
.with_wbs("PRJ-001.02")
.with_payment(dec!(50000))
.with_weight(dec!(0.25));
assert_eq!(ms.status, MilestoneStatus::Pending);
assert_eq!(ms.payment_amount, dec!(50000));
assert_eq!(ms.weight, dec!(0.25));
assert!(ms.is_overdue_on(d("2025-07-01")));
assert!(!ms.is_overdue_on(d("2025-06-15")));
}
#[test]
fn test_milestone_variance() {
let mut ms = ProjectMilestone::new("MS-002", "PRJ-001", "Testing", d("2025-09-30"), 3);
assert_eq!(ms.days_variance(), None);
ms.actual_date = Some(d("2025-10-05"));
ms.status = MilestoneStatus::Completed;
assert_eq!(ms.days_variance(), Some(5));
let mut ms2 = ProjectMilestone::new("MS-003", "PRJ-001", "Delivery", d("2025-12-31"), 4);
ms2.actual_date = Some(d("2025-12-28"));
ms2.status = MilestoneStatus::Completed;
assert_eq!(ms2.days_variance(), Some(-3));
}
#[test]
fn test_change_order_approval() {
let co = ChangeOrder::new(
"CO-001",
"PRJ-001",
1,
d("2025-04-15"),
ChangeReason::ScopeChange,
"Add additional floor to building",
)
.with_cost_impact(dec!(200000), dec!(180000))
.with_schedule_impact(30);
assert!(!co.is_approved());
assert_eq!(co.net_cost_impact(), Decimal::ZERO);
let co_approved = co.approve(d("2025-04-25"));
assert!(co_approved.is_approved());
assert_eq!(co_approved.net_cost_impact(), dec!(200000));
assert_eq!(co_approved.schedule_impact_days, 30);
}
#[test]
fn test_retainage_hold_and_release() {
let mut ret = Retainage::new(
"RET-001",
"PRJ-001",
"C001",
"V-001",
dec!(0.10), d("2025-01-15"),
);
ret.add_from_payment(dec!(100000));
ret.add_from_payment(dec!(150000));
ret.add_from_payment(dec!(75000));
assert_eq!(ret.total_held, dec!(32500.00));
assert_eq!(ret.balance_held(), dec!(32500.00));
assert_eq!(ret.status, RetainageStatus::Held);
ret.release(dec!(15000), d("2025-06-30"));
assert_eq!(ret.balance_held(), dec!(17500.00));
assert_eq!(ret.status, RetainageStatus::PartiallyReleased);
ret.release(dec!(17500), d("2025-12-31"));
assert_eq!(ret.balance_held(), dec!(0.00));
assert_eq!(ret.status, RetainageStatus::Released);
}
#[test]
fn test_retainage_release_capped() {
let mut ret = Retainage::new(
"RET-002",
"PRJ-001",
"C001",
"V-001",
dec!(0.10),
d("2025-01-15"),
);
ret.add_from_payment(dec!(100000));
ret.release(dec!(50000), d("2025-12-31"));
assert_eq!(ret.released_amount, dec!(10000.00)); assert_eq!(ret.balance_held(), dec!(0.00));
assert_eq!(ret.status, RetainageStatus::Released);
}
#[test]
fn test_evm_formulas() {
let evm = EarnedValueMetric::compute(
"EVM-001",
"PRJ-001",
d("2025-06-30"),
dec!(1000000), dec!(500000), dec!(400000), dec!(450000), );
assert_eq!(evm.schedule_variance, dec!(-100000.00));
assert_eq!(evm.cost_variance, dec!(-50000.00));
assert_eq!(evm.spi, dec!(0.8000));
assert_eq!(evm.cpi, dec!(0.8889));
let expected_eac = (dec!(1000000) / dec!(0.8889)).round_dp(2);
assert_eq!(evm.eac, expected_eac);
assert_eq!(evm.etc, (evm.eac - dec!(450000)).round_dp(2));
assert_eq!(evm.tcpi, dec!(1.0909));
assert!(!evm.is_ahead_of_schedule());
assert!(!evm.is_under_budget());
assert!(!evm.is_healthy());
}
#[test]
fn test_evm_healthy_project() {
let evm = EarnedValueMetric::compute(
"EVM-002",
"PRJ-002",
d("2025-06-30"),
dec!(500000), dec!(250000), dec!(275000), dec!(240000), );
assert_eq!(evm.spi, dec!(1.1000));
assert_eq!(evm.cpi, dec!(1.1458));
assert!(evm.is_ahead_of_schedule());
assert!(evm.is_under_budget());
assert!(evm.is_healthy());
assert!(evm.variance_at_completion() > Decimal::ZERO);
}
#[test]
fn test_evm_zero_inputs() {
let evm = EarnedValueMetric::compute(
"EVM-003",
"PRJ-003",
d("2025-01-01"),
dec!(1000000), Decimal::ZERO, Decimal::ZERO, Decimal::ZERO, );
assert_eq!(evm.spi, dec!(1.00));
assert_eq!(evm.cpi, dec!(1.00));
assert_eq!(evm.eac, dec!(1000000));
}
#[test]
fn test_cost_line_serde_roundtrip() {
let line = ProjectCostLine::new(
"PCL-100",
"PRJ-001",
"PRJ-001.01",
"C001",
d("2025-03-15"),
CostCategory::Subcontractor,
CostSourceType::VendorInvoice,
"VI-099",
dec!(25000),
"EUR",
);
let json = serde_json::to_string(&line).unwrap();
let deserialized: ProjectCostLine = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.id, "PCL-100");
assert_eq!(deserialized.cost_category, CostCategory::Subcontractor);
assert_eq!(deserialized.amount, dec!(25000));
}
#[test]
fn test_evm_serde_roundtrip() {
let evm = EarnedValueMetric::compute(
"EVM-100",
"PRJ-001",
d("2025-06-30"),
dec!(1000000),
dec!(500000),
dec!(400000),
dec!(450000),
);
let json = serde_json::to_string(&evm).unwrap();
let deserialized: EarnedValueMetric = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.spi, evm.spi);
assert_eq!(deserialized.cpi, evm.cpi);
assert_eq!(deserialized.eac, evm.eac);
}
}