Skip to main content

datasynth_core/models/
project_accounting.rs

1//! Project Accounting Models.
2//!
3//! Extends the base [`Project`]/[`WbsElement`] models with accounting-specific types:
4//! - Cost tracking by category and source document
5//! - Revenue recognition (Percentage-of-Completion / ASC 606)
6//! - Project milestones
7//! - Change orders and retainage
8//! - Earned Value Management (EVM) metrics (BCWS/BCWP/ACWP/SPI/CPI/EAC/ETC/TCPI)
9
10use chrono::NaiveDate;
11use rust_decimal::Decimal;
12use rust_decimal_macros::dec;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16use super::graph_properties::{GraphPropertyValue, ToNodeProperties};
17
18// ---------------------------------------------------------------------------
19// Cost Category and Source
20// ---------------------------------------------------------------------------
21
22/// Category of project cost.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
24#[serde(rename_all = "snake_case")]
25pub enum CostCategory {
26    /// Labor costs (employee time)
27    #[default]
28    Labor,
29    /// Material / parts costs
30    Material,
31    /// External subcontractor costs
32    Subcontractor,
33    /// Overhead allocation
34    Overhead,
35    /// Equipment / machinery usage
36    Equipment,
37    /// Travel & expense costs
38    Travel,
39}
40
41/// Type of source document that originated the cost.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
43#[serde(rename_all = "snake_case")]
44pub enum CostSourceType {
45    /// From a time entry (employee hours)
46    #[default]
47    TimeEntry,
48    /// From an expense report
49    ExpenseReport,
50    /// From a purchase order
51    PurchaseOrder,
52    /// From a vendor invoice
53    VendorInvoice,
54    /// From a manual journal entry
55    JournalEntry,
56}
57
58// ---------------------------------------------------------------------------
59// Project Cost Line
60// ---------------------------------------------------------------------------
61
62/// A single cost posting against a project WBS element.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ProjectCostLine {
65    /// Unique cost line ID
66    pub id: String,
67    /// Project ID this cost belongs to
68    pub project_id: String,
69    /// WBS element ID for cost assignment
70    pub wbs_id: String,
71    /// Entity that incurred the cost
72    pub entity_id: String,
73    /// Date the cost was incurred
74    pub posting_date: NaiveDate,
75    /// Category of the cost
76    pub cost_category: CostCategory,
77    /// Type of source document
78    pub source_type: CostSourceType,
79    /// Reference to the source document
80    pub source_document_id: String,
81    /// Cost amount (always positive)
82    #[serde(with = "rust_decimal::serde::str")]
83    pub amount: Decimal,
84    /// Currency
85    pub currency: String,
86    /// Hours (for labor costs)
87    pub hours: Option<Decimal>,
88    /// Description
89    pub description: String,
90}
91
92impl ProjectCostLine {
93    /// Creates a new project cost line.
94    #[allow(clippy::too_many_arguments)]
95    pub fn new(
96        id: impl Into<String>,
97        project_id: impl Into<String>,
98        wbs_id: impl Into<String>,
99        entity_id: impl Into<String>,
100        posting_date: NaiveDate,
101        cost_category: CostCategory,
102        source_type: CostSourceType,
103        source_document_id: impl Into<String>,
104        amount: Decimal,
105        currency: impl Into<String>,
106    ) -> Self {
107        Self {
108            id: id.into(),
109            project_id: project_id.into(),
110            wbs_id: wbs_id.into(),
111            entity_id: entity_id.into(),
112            posting_date,
113            cost_category,
114            source_type,
115            source_document_id: source_document_id.into(),
116            amount,
117            currency: currency.into(),
118            hours: None,
119            description: String::new(),
120        }
121    }
122
123    /// Sets the hours (for labor costs).
124    pub fn with_hours(mut self, hours: Decimal) -> Self {
125        self.hours = Some(hours);
126        self
127    }
128
129    /// Sets the description.
130    pub fn with_description(mut self, description: impl Into<String>) -> Self {
131        self.description = description.into();
132        self
133    }
134
135    /// Computes an effective hourly rate (if hours are available).
136    pub fn hourly_rate(&self) -> Option<Decimal> {
137        self.hours
138            .filter(|h| !h.is_zero())
139            .map(|h| (self.amount / h).round_dp(2))
140    }
141}
142
143// ---------------------------------------------------------------------------
144// Revenue Recognition
145// ---------------------------------------------------------------------------
146
147/// Method used for revenue recognition on long-term projects.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
149#[serde(rename_all = "snake_case")]
150pub enum RevenueMethod {
151    /// Percentage of Completion (ASC 606 input method)
152    #[default]
153    PercentageOfCompletion,
154    /// Completed Contract
155    CompletedContract,
156    /// Milestone-based recognition (ASC 606 output method)
157    MilestoneBased,
158}
159
160/// How completion percentage is measured.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
162#[serde(rename_all = "snake_case")]
163pub enum CompletionMeasure {
164    /// Cost-to-cost (incurred / estimated total)
165    #[default]
166    CostToCost,
167    /// Labor hours (hours worked / estimated total hours)
168    LaborHours,
169    /// Physical completion (engineering estimate)
170    PhysicalCompletion,
171}
172
173/// Revenue recognition for a project period.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ProjectRevenue {
176    /// Unique revenue record ID
177    pub id: String,
178    /// Project ID
179    pub project_id: String,
180    /// Entity ID (billing entity)
181    pub entity_id: String,
182    /// Period start
183    pub period_start: NaiveDate,
184    /// Period end
185    pub period_end: NaiveDate,
186    /// Total contract value
187    #[serde(with = "rust_decimal::serde::str")]
188    pub contract_value: Decimal,
189    /// Total estimated cost at completion
190    #[serde(with = "rust_decimal::serde::str")]
191    pub estimated_total_cost: Decimal,
192    /// Costs incurred to date
193    #[serde(with = "rust_decimal::serde::str")]
194    pub costs_to_date: Decimal,
195    /// Completion percentage (0.00 to 1.00)
196    #[serde(with = "rust_decimal::serde::str")]
197    pub completion_pct: Decimal,
198    /// Revenue method
199    pub method: RevenueMethod,
200    /// Completion measure
201    pub measure: CompletionMeasure,
202    /// Cumulative revenue recognized to date
203    #[serde(with = "rust_decimal::serde::str")]
204    pub cumulative_revenue: Decimal,
205    /// Revenue recognized in this period
206    #[serde(with = "rust_decimal::serde::str")]
207    pub period_revenue: Decimal,
208    /// Cumulative amount billed to customer
209    #[serde(with = "rust_decimal::serde::str")]
210    pub billed_to_date: Decimal,
211    /// Unbilled revenue (cumulative_revenue - billed_to_date)
212    #[serde(with = "rust_decimal::serde::str")]
213    pub unbilled_revenue: Decimal,
214    /// Estimated gross margin percentage
215    #[serde(with = "rust_decimal::serde::str")]
216    pub gross_margin_pct: Decimal,
217}
218
219impl ProjectRevenue {
220    /// Computes the PoC completion percentage (cost-to-cost method).
221    pub fn computed_completion_pct(&self) -> Decimal {
222        if self.estimated_total_cost.is_zero() {
223            return Decimal::ZERO;
224        }
225        (self.costs_to_date / self.estimated_total_cost).round_dp(4)
226    }
227
228    /// Computes the cumulative revenue based on PoC.
229    pub fn computed_cumulative_revenue(&self) -> Decimal {
230        (self.contract_value * self.completion_pct).round_dp(2)
231    }
232
233    /// Computes the unbilled revenue.
234    pub fn computed_unbilled_revenue(&self) -> Decimal {
235        (self.cumulative_revenue - self.billed_to_date).round_dp(2)
236    }
237
238    /// Computes estimated gross margin percentage.
239    pub fn computed_gross_margin_pct(&self) -> Decimal {
240        if self.contract_value.is_zero() {
241            return Decimal::ZERO;
242        }
243        ((self.contract_value - self.estimated_total_cost) / self.contract_value).round_dp(4)
244    }
245}
246
247// ---------------------------------------------------------------------------
248// Milestones
249// ---------------------------------------------------------------------------
250
251/// Status of a project milestone.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
253#[serde(rename_all = "snake_case")]
254pub enum MilestoneStatus {
255    /// Not yet started
256    #[default]
257    Pending,
258    /// In progress
259    InProgress,
260    /// Completed
261    Completed,
262    /// Overdue (past planned date, not completed)
263    Overdue,
264    /// Cancelled
265    Cancelled,
266}
267
268/// A project milestone.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct ProjectMilestone {
271    /// Unique milestone ID
272    pub id: String,
273    /// Project ID
274    pub project_id: String,
275    /// WBS element ID (optional)
276    pub wbs_id: Option<String>,
277    /// Milestone name
278    pub name: String,
279    /// Planned completion date
280    pub planned_date: NaiveDate,
281    /// Actual completion date (if completed)
282    pub actual_date: Option<NaiveDate>,
283    /// Current status
284    pub status: MilestoneStatus,
285    /// Payment amount tied to milestone (if any)
286    #[serde(with = "rust_decimal::serde::str")]
287    pub payment_amount: Decimal,
288    /// Completion weight for EVM (0.0 to 1.0)
289    #[serde(with = "rust_decimal::serde::str")]
290    pub weight: Decimal,
291    /// Sequence order
292    pub sequence: u32,
293}
294
295impl ProjectMilestone {
296    /// Creates a new milestone.
297    pub fn new(
298        id: impl Into<String>,
299        project_id: impl Into<String>,
300        name: impl Into<String>,
301        planned_date: NaiveDate,
302        sequence: u32,
303    ) -> Self {
304        Self {
305            id: id.into(),
306            project_id: project_id.into(),
307            wbs_id: None,
308            name: name.into(),
309            planned_date,
310            actual_date: None,
311            status: MilestoneStatus::Pending,
312            payment_amount: Decimal::ZERO,
313            weight: Decimal::ZERO,
314            sequence,
315        }
316    }
317
318    /// Sets the WBS element.
319    pub fn with_wbs(mut self, wbs_id: impl Into<String>) -> Self {
320        self.wbs_id = Some(wbs_id.into());
321        self
322    }
323
324    /// Sets the payment amount tied to this milestone.
325    pub fn with_payment(mut self, amount: Decimal) -> Self {
326        self.payment_amount = amount;
327        self
328    }
329
330    /// Sets the EVM weight.
331    pub fn with_weight(mut self, weight: Decimal) -> Self {
332        self.weight = weight;
333        self
334    }
335
336    /// Returns true if the milestone is overdue on the given date.
337    pub fn is_overdue_on(&self, date: NaiveDate) -> bool {
338        self.actual_date.is_none() && date > self.planned_date
339    }
340
341    /// Returns the number of days late (negative if early or not yet complete).
342    pub fn days_variance(&self) -> Option<i64> {
343        self.actual_date
344            .map(|actual| (actual - self.planned_date).num_days())
345    }
346}
347
348// ---------------------------------------------------------------------------
349// Change Orders
350// ---------------------------------------------------------------------------
351
352/// Status of a change order.
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
354#[serde(rename_all = "snake_case")]
355pub enum ChangeOrderStatus {
356    /// Change order submitted
357    #[default]
358    Submitted,
359    /// Under review
360    UnderReview,
361    /// Approved
362    Approved,
363    /// Rejected
364    Rejected,
365    /// Withdrawn
366    Withdrawn,
367}
368
369/// Reason for the change order.
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
371#[serde(rename_all = "snake_case")]
372pub enum ChangeReason {
373    /// Client-requested scope change
374    #[default]
375    ScopeChange,
376    /// Unforeseen site conditions
377    UnforeseenConditions,
378    /// Design error or omission
379    DesignError,
380    /// Regulatory requirement change
381    RegulatoryChange,
382    /// Value engineering (cost reduction)
383    ValueEngineering,
384    /// Schedule acceleration
385    ScheduleAcceleration,
386}
387
388/// A change order that modifies a project's scope, cost, or schedule.
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct ChangeOrder {
391    /// Unique change order ID
392    pub id: String,
393    /// Project ID
394    pub project_id: String,
395    /// Change order number (sequential within project)
396    pub number: u32,
397    /// Date submitted
398    pub submitted_date: NaiveDate,
399    /// Date approved (if approved)
400    pub approved_date: Option<NaiveDate>,
401    /// Status
402    pub status: ChangeOrderStatus,
403    /// Reason for the change
404    pub reason: ChangeReason,
405    /// Description of the change
406    pub description: String,
407    /// Impact on contract value (positive = increase, negative = decrease)
408    #[serde(with = "rust_decimal::serde::str")]
409    pub cost_impact: Decimal,
410    /// Impact on estimated total cost
411    #[serde(with = "rust_decimal::serde::str")]
412    pub estimated_cost_impact: Decimal,
413    /// Schedule impact in calendar days (positive = delay)
414    pub schedule_impact_days: i32,
415}
416
417impl ChangeOrder {
418    /// Creates a new change order.
419    pub fn new(
420        id: impl Into<String>,
421        project_id: impl Into<String>,
422        number: u32,
423        submitted_date: NaiveDate,
424        reason: ChangeReason,
425        description: impl Into<String>,
426    ) -> Self {
427        Self {
428            id: id.into(),
429            project_id: project_id.into(),
430            number,
431            submitted_date,
432            approved_date: None,
433            status: ChangeOrderStatus::Submitted,
434            reason,
435            description: description.into(),
436            cost_impact: Decimal::ZERO,
437            estimated_cost_impact: Decimal::ZERO,
438            schedule_impact_days: 0,
439        }
440    }
441
442    /// Sets the cost impact.
443    pub fn with_cost_impact(mut self, contract_impact: Decimal, estimated_impact: Decimal) -> Self {
444        self.cost_impact = contract_impact;
445        self.estimated_cost_impact = estimated_impact;
446        self
447    }
448
449    /// Sets the schedule impact.
450    pub fn with_schedule_impact(mut self, days: i32) -> Self {
451        self.schedule_impact_days = days;
452        self
453    }
454
455    /// Approves the change order.
456    pub fn approve(mut self, date: NaiveDate) -> Self {
457        self.status = ChangeOrderStatus::Approved;
458        self.approved_date = Some(date);
459        self
460    }
461
462    /// Returns true if the change order is approved.
463    pub fn is_approved(&self) -> bool {
464        self.status == ChangeOrderStatus::Approved
465    }
466
467    /// Returns the net cost impact (only if approved).
468    pub fn net_cost_impact(&self) -> Decimal {
469        if self.is_approved() {
470            self.cost_impact
471        } else {
472            Decimal::ZERO
473        }
474    }
475}
476
477// ---------------------------------------------------------------------------
478// Retainage
479// ---------------------------------------------------------------------------
480
481/// Status of a retainage hold.
482#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
483#[serde(rename_all = "snake_case")]
484pub enum RetainageStatus {
485    /// Retainage is being held
486    #[default]
487    Held,
488    /// Partial release
489    PartiallyReleased,
490    /// Fully released
491    Released,
492    /// Forfeited (e.g., for defective work)
493    Forfeited,
494}
495
496/// Retainage record — a portion of each payment withheld until project completion.
497#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct Retainage {
499    /// Unique retainage record ID
500    pub id: String,
501    /// Project ID
502    pub project_id: String,
503    /// Entity ID
504    pub entity_id: String,
505    /// Vendor/subcontractor ID this retainage relates to
506    pub vendor_id: String,
507    /// Retainage percentage (e.g., 0.10 for 10%)
508    #[serde(with = "rust_decimal::serde::str")]
509    pub retainage_pct: Decimal,
510    /// Total retainage held
511    #[serde(with = "rust_decimal::serde::str")]
512    pub total_held: Decimal,
513    /// Amount released to date
514    #[serde(with = "rust_decimal::serde::str")]
515    pub released_amount: Decimal,
516    /// Current status
517    pub status: RetainageStatus,
518    /// Date retainage was first held
519    pub inception_date: NaiveDate,
520    /// Date of last release (if any)
521    pub last_release_date: Option<NaiveDate>,
522}
523
524impl Retainage {
525    /// Creates a new retainage record.
526    pub fn new(
527        id: impl Into<String>,
528        project_id: impl Into<String>,
529        entity_id: impl Into<String>,
530        vendor_id: impl Into<String>,
531        retainage_pct: Decimal,
532        inception_date: NaiveDate,
533    ) -> Self {
534        Self {
535            id: id.into(),
536            project_id: project_id.into(),
537            entity_id: entity_id.into(),
538            vendor_id: vendor_id.into(),
539            retainage_pct,
540            total_held: Decimal::ZERO,
541            released_amount: Decimal::ZERO,
542            status: RetainageStatus::Held,
543            inception_date,
544            last_release_date: None,
545        }
546    }
547
548    /// Adds retainage from a payment.
549    pub fn add_from_payment(&mut self, payment_amount: Decimal) {
550        let held = (payment_amount * self.retainage_pct).round_dp(2);
551        self.total_held += held;
552    }
553
554    /// Returns the balance still held.
555    pub fn balance_held(&self) -> Decimal {
556        (self.total_held - self.released_amount).round_dp(2)
557    }
558
559    /// Releases a specified amount.
560    pub fn release(&mut self, amount: Decimal, date: NaiveDate) {
561        let release = amount.min(self.balance_held());
562        self.released_amount += release;
563        self.last_release_date = Some(date);
564        if self.balance_held().is_zero() {
565            self.status = RetainageStatus::Released;
566        } else {
567            self.status = RetainageStatus::PartiallyReleased;
568        }
569    }
570}
571
572// ---------------------------------------------------------------------------
573// Earned Value Management (EVM)
574// ---------------------------------------------------------------------------
575
576/// Earned Value Management metrics for a project at a point in time.
577///
578/// EVM formulas:
579/// - SV (Schedule Variance) = EV - PV
580/// - CV (Cost Variance) = EV - AC
581/// - SPI (Schedule Performance Index) = EV / PV
582/// - CPI (Cost Performance Index) = EV / AC
583/// - EAC (Estimate at Completion) = BAC / CPI
584/// - ETC (Estimate to Complete) = EAC - AC
585/// - TCPI (To-Complete Performance Index) = (BAC - EV) / (BAC - AC)
586#[derive(Debug, Clone, Serialize, Deserialize)]
587pub struct EarnedValueMetric {
588    /// Unique metric ID
589    pub id: String,
590    /// Project ID
591    pub project_id: String,
592    /// Measurement date
593    pub measurement_date: NaiveDate,
594    /// Budget at Completion (total baseline budget)
595    #[serde(with = "rust_decimal::serde::str")]
596    pub bac: Decimal,
597    /// Planned Value (BCWS — Budgeted Cost of Work Scheduled)
598    #[serde(with = "rust_decimal::serde::str")]
599    pub planned_value: Decimal,
600    /// Earned Value (BCWP — Budgeted Cost of Work Performed)
601    #[serde(with = "rust_decimal::serde::str")]
602    pub earned_value: Decimal,
603    /// Actual Cost (ACWP — Actual Cost of Work Performed)
604    #[serde(with = "rust_decimal::serde::str")]
605    pub actual_cost: Decimal,
606    /// Schedule Variance (EV - PV)
607    #[serde(with = "rust_decimal::serde::str")]
608    pub schedule_variance: Decimal,
609    /// Cost Variance (EV - AC)
610    #[serde(with = "rust_decimal::serde::str")]
611    pub cost_variance: Decimal,
612    /// Schedule Performance Index (EV / PV)
613    #[serde(with = "rust_decimal::serde::str")]
614    pub spi: Decimal,
615    /// Cost Performance Index (EV / AC)
616    #[serde(with = "rust_decimal::serde::str")]
617    pub cpi: Decimal,
618    /// Estimate at Completion (BAC / CPI)
619    #[serde(with = "rust_decimal::serde::str")]
620    pub eac: Decimal,
621    /// Estimate to Complete (EAC - AC)
622    #[serde(with = "rust_decimal::serde::str")]
623    pub etc: Decimal,
624    /// To-Complete Performance Index ((BAC - EV) / (BAC - AC))
625    #[serde(with = "rust_decimal::serde::str")]
626    pub tcpi: Decimal,
627}
628
629impl EarnedValueMetric {
630    /// Creates a new EVM metric by computing all derived values from
631    /// the three fundamental inputs: PV, EV, AC, and BAC.
632    pub fn compute(
633        id: impl Into<String>,
634        project_id: impl Into<String>,
635        measurement_date: NaiveDate,
636        bac: Decimal,
637        planned_value: Decimal,
638        earned_value: Decimal,
639        actual_cost: Decimal,
640    ) -> Self {
641        let sv = (earned_value - planned_value).round_dp(2);
642        let cv = (earned_value - actual_cost).round_dp(2);
643        let spi = if planned_value.is_zero() {
644            dec!(1.00)
645        } else {
646            (earned_value / planned_value).round_dp(4)
647        };
648        let cpi = if actual_cost.is_zero() {
649            dec!(1.00)
650        } else {
651            (earned_value / actual_cost).round_dp(4)
652        };
653        let eac = if cpi.is_zero() {
654            bac
655        } else {
656            (bac / cpi).round_dp(2)
657        };
658        let etc = (eac - actual_cost).round_dp(2);
659        let remaining_budget = bac - actual_cost;
660        let remaining_work = bac - earned_value;
661        let tcpi = if remaining_budget.is_zero() {
662            dec!(1.00)
663        } else {
664            (remaining_work / remaining_budget).round_dp(4)
665        };
666
667        Self {
668            id: id.into(),
669            project_id: project_id.into(),
670            measurement_date,
671            bac,
672            planned_value,
673            earned_value,
674            actual_cost,
675            schedule_variance: sv,
676            cost_variance: cv,
677            spi,
678            cpi,
679            eac,
680            etc,
681            tcpi,
682        }
683    }
684
685    /// Returns true if the project is ahead of schedule (SPI > 1.0).
686    pub fn is_ahead_of_schedule(&self) -> bool {
687        self.spi > dec!(1.00)
688    }
689
690    /// Returns true if the project is under budget (CPI > 1.0).
691    pub fn is_under_budget(&self) -> bool {
692        self.cpi > dec!(1.00)
693    }
694
695    /// Returns true if the project is both on schedule and on budget.
696    pub fn is_healthy(&self) -> bool {
697        self.spi >= dec!(0.90) && self.cpi >= dec!(0.90)
698    }
699
700    /// Returns the variance at completion (BAC - EAC).
701    pub fn variance_at_completion(&self) -> Decimal {
702        (self.bac - self.eac).round_dp(2)
703    }
704}
705
706// ---------------------------------------------------------------------------
707// Tests
708// ---------------------------------------------------------------------------
709
710#[cfg(test)]
711#[allow(clippy::unwrap_used)]
712mod tests {
713    use super::*;
714
715    fn d(s: &str) -> NaiveDate {
716        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
717    }
718
719    // -- Cost Lines --
720
721    #[test]
722    fn test_cost_line_creation() {
723        let line = ProjectCostLine::new(
724            "PCL-001",
725            "PRJ-001",
726            "PRJ-001.01",
727            "C001",
728            d("2025-03-15"),
729            CostCategory::Labor,
730            CostSourceType::TimeEntry,
731            "TE-001",
732            dec!(1500),
733            "USD",
734        )
735        .with_hours(dec!(20))
736        .with_description("Developer time - sprint 5");
737
738        assert_eq!(line.cost_category, CostCategory::Labor);
739        assert_eq!(line.source_type, CostSourceType::TimeEntry);
740        assert_eq!(line.amount, dec!(1500));
741        assert_eq!(line.hours, Some(dec!(20)));
742        assert_eq!(line.hourly_rate(), Some(dec!(75.00)));
743    }
744
745    #[test]
746    fn test_cost_line_hourly_rate_no_hours() {
747        let line = ProjectCostLine::new(
748            "PCL-002",
749            "PRJ-001",
750            "PRJ-001.02",
751            "C001",
752            d("2025-03-15"),
753            CostCategory::Material,
754            CostSourceType::PurchaseOrder,
755            "PO-001",
756            dec!(5000),
757            "USD",
758        );
759        assert_eq!(line.hourly_rate(), None);
760    }
761
762    // -- Revenue Recognition --
763
764    #[test]
765    fn test_revenue_poc_completion() {
766        let rev = ProjectRevenue {
767            id: "REV-001".to_string(),
768            project_id: "PRJ-001".to_string(),
769            entity_id: "C001".to_string(),
770            period_start: d("2025-01-01"),
771            period_end: d("2025-03-31"),
772            contract_value: dec!(1000000),
773            estimated_total_cost: dec!(800000),
774            costs_to_date: dec!(400000),
775            completion_pct: dec!(0.50),
776            method: RevenueMethod::PercentageOfCompletion,
777            measure: CompletionMeasure::CostToCost,
778            cumulative_revenue: dec!(500000),
779            period_revenue: dec!(200000),
780            billed_to_date: dec!(400000),
781            unbilled_revenue: dec!(100000),
782            gross_margin_pct: dec!(0.20),
783        };
784
785        // PoC: 400000 / 800000 = 0.50
786        assert_eq!(rev.computed_completion_pct(), dec!(0.5000));
787        // Cumulative revenue: 1000000 * 0.50 = 500000
788        assert_eq!(rev.computed_cumulative_revenue(), dec!(500000.00));
789        // Unbilled: 500000 - 400000 = 100000
790        assert_eq!(rev.computed_unbilled_revenue(), dec!(100000.00));
791        // Margin: (1000000 - 800000) / 1000000 = 0.20
792        assert_eq!(rev.computed_gross_margin_pct(), dec!(0.2000));
793    }
794
795    #[test]
796    fn test_revenue_zero_estimated_cost() {
797        let rev = ProjectRevenue {
798            id: "REV-002".to_string(),
799            project_id: "PRJ-002".to_string(),
800            entity_id: "C001".to_string(),
801            period_start: d("2025-01-01"),
802            period_end: d("2025-03-31"),
803            contract_value: dec!(100000),
804            estimated_total_cost: Decimal::ZERO,
805            costs_to_date: Decimal::ZERO,
806            completion_pct: Decimal::ZERO,
807            method: RevenueMethod::PercentageOfCompletion,
808            measure: CompletionMeasure::CostToCost,
809            cumulative_revenue: Decimal::ZERO,
810            period_revenue: Decimal::ZERO,
811            billed_to_date: Decimal::ZERO,
812            unbilled_revenue: Decimal::ZERO,
813            gross_margin_pct: Decimal::ZERO,
814        };
815        assert_eq!(rev.computed_completion_pct(), Decimal::ZERO);
816    }
817
818    // -- Milestones --
819
820    #[test]
821    fn test_milestone_creation() {
822        let ms = ProjectMilestone::new(
823            "MS-001",
824            "PRJ-001",
825            "Foundation Complete",
826            d("2025-06-30"),
827            1,
828        )
829        .with_wbs("PRJ-001.02")
830        .with_payment(dec!(50000))
831        .with_weight(dec!(0.25));
832
833        assert_eq!(ms.status, MilestoneStatus::Pending);
834        assert_eq!(ms.payment_amount, dec!(50000));
835        assert_eq!(ms.weight, dec!(0.25));
836        assert!(ms.is_overdue_on(d("2025-07-01")));
837        assert!(!ms.is_overdue_on(d("2025-06-15")));
838    }
839
840    #[test]
841    fn test_milestone_variance() {
842        let mut ms = ProjectMilestone::new("MS-002", "PRJ-001", "Testing", d("2025-09-30"), 3);
843        assert_eq!(ms.days_variance(), None);
844
845        // Complete 5 days late
846        ms.actual_date = Some(d("2025-10-05"));
847        ms.status = MilestoneStatus::Completed;
848        assert_eq!(ms.days_variance(), Some(5));
849
850        // Complete 3 days early
851        let mut ms2 = ProjectMilestone::new("MS-003", "PRJ-001", "Delivery", d("2025-12-31"), 4);
852        ms2.actual_date = Some(d("2025-12-28"));
853        ms2.status = MilestoneStatus::Completed;
854        assert_eq!(ms2.days_variance(), Some(-3));
855    }
856
857    // -- Change Orders --
858
859    #[test]
860    fn test_change_order_approval() {
861        let co = ChangeOrder::new(
862            "CO-001",
863            "PRJ-001",
864            1,
865            d("2025-04-15"),
866            ChangeReason::ScopeChange,
867            "Add additional floor to building",
868        )
869        .with_cost_impact(dec!(200000), dec!(180000))
870        .with_schedule_impact(30);
871
872        assert!(!co.is_approved());
873        assert_eq!(co.net_cost_impact(), Decimal::ZERO);
874
875        let co_approved = co.approve(d("2025-04-25"));
876        assert!(co_approved.is_approved());
877        assert_eq!(co_approved.net_cost_impact(), dec!(200000));
878        assert_eq!(co_approved.schedule_impact_days, 30);
879    }
880
881    // -- Retainage --
882
883    #[test]
884    fn test_retainage_hold_and_release() {
885        let mut ret = Retainage::new(
886            "RET-001",
887            "PRJ-001",
888            "C001",
889            "V-001",
890            dec!(0.10), // 10%
891            d("2025-01-15"),
892        );
893
894        // Add retainage from three payments
895        ret.add_from_payment(dec!(100000));
896        ret.add_from_payment(dec!(150000));
897        ret.add_from_payment(dec!(75000));
898
899        // Total held: (100000 + 150000 + 75000) * 0.10 = 32500
900        assert_eq!(ret.total_held, dec!(32500.00));
901        assert_eq!(ret.balance_held(), dec!(32500.00));
902        assert_eq!(ret.status, RetainageStatus::Held);
903
904        // Partial release
905        ret.release(dec!(15000), d("2025-06-30"));
906        assert_eq!(ret.balance_held(), dec!(17500.00));
907        assert_eq!(ret.status, RetainageStatus::PartiallyReleased);
908
909        // Full release
910        ret.release(dec!(17500), d("2025-12-31"));
911        assert_eq!(ret.balance_held(), dec!(0.00));
912        assert_eq!(ret.status, RetainageStatus::Released);
913    }
914
915    #[test]
916    fn test_retainage_release_capped() {
917        let mut ret = Retainage::new(
918            "RET-002",
919            "PRJ-001",
920            "C001",
921            "V-001",
922            dec!(0.10),
923            d("2025-01-15"),
924        );
925        ret.add_from_payment(dec!(100000)); // held = 10000
926
927        // Try to release more than held
928        ret.release(dec!(50000), d("2025-12-31"));
929        assert_eq!(ret.released_amount, dec!(10000.00)); // capped
930        assert_eq!(ret.balance_held(), dec!(0.00));
931        assert_eq!(ret.status, RetainageStatus::Released);
932    }
933
934    // -- Earned Value Management --
935
936    #[test]
937    fn test_evm_formulas() {
938        // Project: BAC=1,000,000, 50% scheduled, 40% earned, 450,000 spent
939        let evm = EarnedValueMetric::compute(
940            "EVM-001",
941            "PRJ-001",
942            d("2025-06-30"),
943            dec!(1000000), // BAC
944            dec!(500000),  // PV (50% scheduled)
945            dec!(400000),  // EV (40% earned)
946            dec!(450000),  // AC (450k spent)
947        );
948
949        // SV = EV - PV = 400000 - 500000 = -100000 (behind schedule)
950        assert_eq!(evm.schedule_variance, dec!(-100000.00));
951        // CV = EV - AC = 400000 - 450000 = -50000 (over budget)
952        assert_eq!(evm.cost_variance, dec!(-50000.00));
953        // SPI = EV / PV = 400000 / 500000 = 0.80
954        assert_eq!(evm.spi, dec!(0.8000));
955        // CPI = EV / AC = 400000 / 450000 = 0.8889
956        assert_eq!(evm.cpi, dec!(0.8889));
957        // EAC = BAC / CPI = 1000000 / 0.8889 ≈ 1124972.44
958        let expected_eac = (dec!(1000000) / dec!(0.8889)).round_dp(2);
959        assert_eq!(evm.eac, expected_eac);
960        // ETC = EAC - AC
961        assert_eq!(evm.etc, (evm.eac - dec!(450000)).round_dp(2));
962        // TCPI = (BAC - EV) / (BAC - AC) = 600000 / 550000 ≈ 1.0909
963        assert_eq!(evm.tcpi, dec!(1.0909));
964
965        assert!(!evm.is_ahead_of_schedule());
966        assert!(!evm.is_under_budget());
967        assert!(!evm.is_healthy());
968    }
969
970    #[test]
971    fn test_evm_healthy_project() {
972        let evm = EarnedValueMetric::compute(
973            "EVM-002",
974            "PRJ-002",
975            d("2025-06-30"),
976            dec!(500000), // BAC
977            dec!(250000), // PV (50% scheduled)
978            dec!(275000), // EV (55% earned — ahead)
979            dec!(240000), // AC (240k — under budget)
980        );
981
982        // SPI = 275000 / 250000 = 1.10
983        assert_eq!(evm.spi, dec!(1.1000));
984        // CPI = 275000 / 240000 ≈ 1.1458
985        assert_eq!(evm.cpi, dec!(1.1458));
986
987        assert!(evm.is_ahead_of_schedule());
988        assert!(evm.is_under_budget());
989        assert!(evm.is_healthy());
990
991        // VAC = BAC - EAC = 500000 - (500000 / 1.1458)
992        assert!(evm.variance_at_completion() > Decimal::ZERO);
993    }
994
995    #[test]
996    fn test_evm_zero_inputs() {
997        // Edge case: all zeros (project just started)
998        let evm = EarnedValueMetric::compute(
999            "EVM-003",
1000            "PRJ-003",
1001            d("2025-01-01"),
1002            dec!(1000000), // BAC
1003            Decimal::ZERO, // PV
1004            Decimal::ZERO, // EV
1005            Decimal::ZERO, // AC
1006        );
1007
1008        // With PV=0 and AC=0, SPI and CPI default to 1.0
1009        assert_eq!(evm.spi, dec!(1.00));
1010        assert_eq!(evm.cpi, dec!(1.00));
1011        assert_eq!(evm.eac, dec!(1000000));
1012    }
1013
1014    // -- Serde roundtrip --
1015
1016    #[test]
1017    fn test_cost_line_serde_roundtrip() {
1018        let line = ProjectCostLine::new(
1019            "PCL-100",
1020            "PRJ-001",
1021            "PRJ-001.01",
1022            "C001",
1023            d("2025-03-15"),
1024            CostCategory::Subcontractor,
1025            CostSourceType::VendorInvoice,
1026            "VI-099",
1027            dec!(25000),
1028            "EUR",
1029        );
1030        let json = serde_json::to_string(&line).unwrap();
1031        let deserialized: ProjectCostLine = serde_json::from_str(&json).unwrap();
1032        assert_eq!(deserialized.id, "PCL-100");
1033        assert_eq!(deserialized.cost_category, CostCategory::Subcontractor);
1034        assert_eq!(deserialized.amount, dec!(25000));
1035    }
1036
1037    #[test]
1038    fn test_evm_serde_roundtrip() {
1039        let evm = EarnedValueMetric::compute(
1040            "EVM-100",
1041            "PRJ-001",
1042            d("2025-06-30"),
1043            dec!(1000000),
1044            dec!(500000),
1045            dec!(400000),
1046            dec!(450000),
1047        );
1048        let json = serde_json::to_string(&evm).unwrap();
1049        let deserialized: EarnedValueMetric = serde_json::from_str(&json).unwrap();
1050        assert_eq!(deserialized.spi, evm.spi);
1051        assert_eq!(deserialized.cpi, evm.cpi);
1052        assert_eq!(deserialized.eac, evm.eac);
1053    }
1054}
1055
1056// ---------------------------------------------------------------------------
1057// ToNodeProperties implementations (DS-005)
1058// ---------------------------------------------------------------------------
1059
1060impl ToNodeProperties for ProjectCostLine {
1061    fn node_type_name(&self) -> &'static str {
1062        "project_cost_line"
1063    }
1064    fn node_type_code(&self) -> u16 {
1065        451
1066    }
1067    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1068        let mut p = HashMap::new();
1069        p.insert(
1070            "projectId".into(),
1071            GraphPropertyValue::String(self.project_id.clone()),
1072        );
1073        p.insert(
1074            "wbsElement".into(),
1075            GraphPropertyValue::String(self.wbs_id.clone()),
1076        );
1077        p.insert(
1078            "entityCode".into(),
1079            GraphPropertyValue::String(self.entity_id.clone()),
1080        );
1081        p.insert(
1082            "postingDate".into(),
1083            GraphPropertyValue::Date(self.posting_date),
1084        );
1085        p.insert(
1086            "costCategory".into(),
1087            GraphPropertyValue::String(format!("{:?}", self.cost_category)),
1088        );
1089        p.insert(
1090            "sourceType".into(),
1091            GraphPropertyValue::String(format!("{:?}", self.source_type)),
1092        );
1093        if !self.source_document_id.is_empty() {
1094            p.insert(
1095                "sourceDocumentId".into(),
1096                GraphPropertyValue::String(self.source_document_id.clone()),
1097            );
1098        }
1099        p.insert(
1100            "actualAmount".into(),
1101            GraphPropertyValue::Decimal(self.amount),
1102        );
1103        p.insert(
1104            "currency".into(),
1105            GraphPropertyValue::String(self.currency.clone()),
1106        );
1107        if let Some(hours) = self.hours {
1108            p.insert("hours".into(), GraphPropertyValue::Decimal(hours));
1109        }
1110        p
1111    }
1112}
1113
1114impl ToNodeProperties for ProjectRevenue {
1115    fn node_type_name(&self) -> &'static str {
1116        "project_revenue"
1117    }
1118    fn node_type_code(&self) -> u16 {
1119        452
1120    }
1121    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1122        let mut p = HashMap::new();
1123        p.insert(
1124            "projectId".into(),
1125            GraphPropertyValue::String(self.project_id.clone()),
1126        );
1127        p.insert(
1128            "entityCode".into(),
1129            GraphPropertyValue::String(self.entity_id.clone()),
1130        );
1131        p.insert(
1132            "periodStart".into(),
1133            GraphPropertyValue::Date(self.period_start),
1134        );
1135        p.insert(
1136            "periodEnd".into(),
1137            GraphPropertyValue::Date(self.period_end),
1138        );
1139        p.insert(
1140            "contractValue".into(),
1141            GraphPropertyValue::Decimal(self.contract_value),
1142        );
1143        p.insert(
1144            "completionPct".into(),
1145            GraphPropertyValue::Decimal(self.completion_pct),
1146        );
1147        p.insert(
1148            "method".into(),
1149            GraphPropertyValue::String(format!("{:?}", self.method)),
1150        );
1151        p.insert(
1152            "cumulativeRevenue".into(),
1153            GraphPropertyValue::Decimal(self.cumulative_revenue),
1154        );
1155        p.insert(
1156            "periodRevenue".into(),
1157            GraphPropertyValue::Decimal(self.period_revenue),
1158        );
1159        p.insert(
1160            "grossMarginPct".into(),
1161            GraphPropertyValue::Decimal(self.gross_margin_pct),
1162        );
1163        p
1164    }
1165}
1166
1167impl ToNodeProperties for ProjectMilestone {
1168    fn node_type_name(&self) -> &'static str {
1169        "project_milestone"
1170    }
1171    fn node_type_code(&self) -> u16 {
1172        455
1173    }
1174    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1175        let mut p = HashMap::new();
1176        p.insert(
1177            "projectId".into(),
1178            GraphPropertyValue::String(self.project_id.clone()),
1179        );
1180        if let Some(ref wbs) = self.wbs_id {
1181            p.insert("wbsElement".into(), GraphPropertyValue::String(wbs.clone()));
1182        }
1183        p.insert(
1184            "milestoneName".into(),
1185            GraphPropertyValue::String(self.name.clone()),
1186        );
1187        p.insert(
1188            "plannedDate".into(),
1189            GraphPropertyValue::Date(self.planned_date),
1190        );
1191        if let Some(actual) = self.actual_date {
1192            p.insert("actualDate".into(), GraphPropertyValue::Date(actual));
1193        }
1194        p.insert(
1195            "status".into(),
1196            GraphPropertyValue::String(format!("{:?}", self.status)),
1197        );
1198        p.insert(
1199            "paymentAmount".into(),
1200            GraphPropertyValue::Decimal(self.payment_amount),
1201        );
1202        p.insert("weightPct".into(), GraphPropertyValue::Decimal(self.weight));
1203        p.insert(
1204            "isComplete".into(),
1205            GraphPropertyValue::Bool(matches!(self.status, MilestoneStatus::Completed)),
1206        );
1207        p
1208    }
1209}
1210
1211impl ToNodeProperties for ChangeOrder {
1212    fn node_type_name(&self) -> &'static str {
1213        "change_order"
1214    }
1215    fn node_type_code(&self) -> u16 {
1216        454
1217    }
1218    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1219        let mut p = HashMap::new();
1220        p.insert(
1221            "projectId".into(),
1222            GraphPropertyValue::String(self.project_id.clone()),
1223        );
1224        p.insert(
1225            "orderNumber".into(),
1226            GraphPropertyValue::Int(self.number as i64),
1227        );
1228        p.insert(
1229            "submittedDate".into(),
1230            GraphPropertyValue::Date(self.submitted_date),
1231        );
1232        if let Some(approved) = self.approved_date {
1233            p.insert("approvedDate".into(), GraphPropertyValue::Date(approved));
1234        }
1235        p.insert(
1236            "status".into(),
1237            GraphPropertyValue::String(format!("{:?}", self.status)),
1238        );
1239        p.insert(
1240            "reason".into(),
1241            GraphPropertyValue::String(format!("{:?}", self.reason)),
1242        );
1243        p.insert(
1244            "costImpact".into(),
1245            GraphPropertyValue::Decimal(self.cost_impact),
1246        );
1247        p.insert(
1248            "scheduleImpactDays".into(),
1249            GraphPropertyValue::Int(self.schedule_impact_days as i64),
1250        );
1251        p.insert(
1252            "isApproved".into(),
1253            GraphPropertyValue::Bool(matches!(self.status, ChangeOrderStatus::Approved)),
1254        );
1255        p
1256    }
1257}
1258
1259impl ToNodeProperties for EarnedValueMetric {
1260    fn node_type_name(&self) -> &'static str {
1261        "earned_value_metric"
1262    }
1263    fn node_type_code(&self) -> u16 {
1264        453
1265    }
1266    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1267        let mut p = HashMap::new();
1268        p.insert(
1269            "projectId".into(),
1270            GraphPropertyValue::String(self.project_id.clone()),
1271        );
1272        p.insert(
1273            "measurementDate".into(),
1274            GraphPropertyValue::Date(self.measurement_date),
1275        );
1276        p.insert("bac".into(), GraphPropertyValue::Decimal(self.bac));
1277        p.insert(
1278            "plannedValue".into(),
1279            GraphPropertyValue::Decimal(self.planned_value),
1280        );
1281        p.insert(
1282            "earnedValue".into(),
1283            GraphPropertyValue::Decimal(self.earned_value),
1284        );
1285        p.insert(
1286            "actualCost".into(),
1287            GraphPropertyValue::Decimal(self.actual_cost),
1288        );
1289        p.insert("spi".into(), GraphPropertyValue::Decimal(self.spi));
1290        p.insert("cpi".into(), GraphPropertyValue::Decimal(self.cpi));
1291        p.insert("eac".into(), GraphPropertyValue::Decimal(self.eac));
1292        p
1293    }
1294}