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// ToNodeProperties implementations (DS-005)
708// ---------------------------------------------------------------------------
709
710impl ToNodeProperties for ProjectCostLine {
711    fn node_type_name(&self) -> &'static str {
712        "project_cost_line"
713    }
714    fn node_type_code(&self) -> u16 {
715        451
716    }
717    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
718        let mut p = HashMap::new();
719        p.insert(
720            "projectId".into(),
721            GraphPropertyValue::String(self.project_id.clone()),
722        );
723        p.insert(
724            "wbsElement".into(),
725            GraphPropertyValue::String(self.wbs_id.clone()),
726        );
727        p.insert(
728            "entityCode".into(),
729            GraphPropertyValue::String(self.entity_id.clone()),
730        );
731        p.insert(
732            "postingDate".into(),
733            GraphPropertyValue::Date(self.posting_date),
734        );
735        p.insert(
736            "costCategory".into(),
737            GraphPropertyValue::String(format!("{:?}", self.cost_category)),
738        );
739        p.insert(
740            "sourceType".into(),
741            GraphPropertyValue::String(format!("{:?}", self.source_type)),
742        );
743        if !self.source_document_id.is_empty() {
744            p.insert(
745                "sourceDocumentId".into(),
746                GraphPropertyValue::String(self.source_document_id.clone()),
747            );
748        }
749        p.insert(
750            "actualAmount".into(),
751            GraphPropertyValue::Decimal(self.amount),
752        );
753        p.insert(
754            "currency".into(),
755            GraphPropertyValue::String(self.currency.clone()),
756        );
757        if let Some(hours) = self.hours {
758            p.insert("hours".into(), GraphPropertyValue::Decimal(hours));
759        }
760        p
761    }
762}
763
764impl ToNodeProperties for ProjectRevenue {
765    fn node_type_name(&self) -> &'static str {
766        "project_revenue"
767    }
768    fn node_type_code(&self) -> u16 {
769        452
770    }
771    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
772        let mut p = HashMap::new();
773        p.insert(
774            "projectId".into(),
775            GraphPropertyValue::String(self.project_id.clone()),
776        );
777        p.insert(
778            "entityCode".into(),
779            GraphPropertyValue::String(self.entity_id.clone()),
780        );
781        p.insert(
782            "periodStart".into(),
783            GraphPropertyValue::Date(self.period_start),
784        );
785        p.insert(
786            "periodEnd".into(),
787            GraphPropertyValue::Date(self.period_end),
788        );
789        p.insert(
790            "contractValue".into(),
791            GraphPropertyValue::Decimal(self.contract_value),
792        );
793        p.insert(
794            "completionPct".into(),
795            GraphPropertyValue::Decimal(self.completion_pct),
796        );
797        p.insert(
798            "method".into(),
799            GraphPropertyValue::String(format!("{:?}", self.method)),
800        );
801        p.insert(
802            "cumulativeRevenue".into(),
803            GraphPropertyValue::Decimal(self.cumulative_revenue),
804        );
805        p.insert(
806            "periodRevenue".into(),
807            GraphPropertyValue::Decimal(self.period_revenue),
808        );
809        p.insert(
810            "grossMarginPct".into(),
811            GraphPropertyValue::Decimal(self.gross_margin_pct),
812        );
813        p
814    }
815}
816
817impl ToNodeProperties for ProjectMilestone {
818    fn node_type_name(&self) -> &'static str {
819        "project_milestone"
820    }
821    fn node_type_code(&self) -> u16 {
822        455
823    }
824    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
825        let mut p = HashMap::new();
826        p.insert(
827            "projectId".into(),
828            GraphPropertyValue::String(self.project_id.clone()),
829        );
830        if let Some(ref wbs) = self.wbs_id {
831            p.insert("wbsElement".into(), GraphPropertyValue::String(wbs.clone()));
832        }
833        p.insert(
834            "milestoneName".into(),
835            GraphPropertyValue::String(self.name.clone()),
836        );
837        p.insert(
838            "plannedDate".into(),
839            GraphPropertyValue::Date(self.planned_date),
840        );
841        if let Some(actual) = self.actual_date {
842            p.insert("actualDate".into(), GraphPropertyValue::Date(actual));
843        }
844        p.insert(
845            "status".into(),
846            GraphPropertyValue::String(format!("{:?}", self.status)),
847        );
848        p.insert(
849            "paymentAmount".into(),
850            GraphPropertyValue::Decimal(self.payment_amount),
851        );
852        p.insert("weightPct".into(), GraphPropertyValue::Decimal(self.weight));
853        p.insert(
854            "isComplete".into(),
855            GraphPropertyValue::Bool(matches!(self.status, MilestoneStatus::Completed)),
856        );
857        p
858    }
859}
860
861impl ToNodeProperties for ChangeOrder {
862    fn node_type_name(&self) -> &'static str {
863        "change_order"
864    }
865    fn node_type_code(&self) -> u16 {
866        454
867    }
868    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
869        let mut p = HashMap::new();
870        p.insert(
871            "projectId".into(),
872            GraphPropertyValue::String(self.project_id.clone()),
873        );
874        p.insert(
875            "orderNumber".into(),
876            GraphPropertyValue::Int(self.number as i64),
877        );
878        p.insert(
879            "submittedDate".into(),
880            GraphPropertyValue::Date(self.submitted_date),
881        );
882        if let Some(approved) = self.approved_date {
883            p.insert("approvedDate".into(), GraphPropertyValue::Date(approved));
884        }
885        p.insert(
886            "status".into(),
887            GraphPropertyValue::String(format!("{:?}", self.status)),
888        );
889        p.insert(
890            "reason".into(),
891            GraphPropertyValue::String(format!("{:?}", self.reason)),
892        );
893        p.insert(
894            "costImpact".into(),
895            GraphPropertyValue::Decimal(self.cost_impact),
896        );
897        p.insert(
898            "scheduleImpactDays".into(),
899            GraphPropertyValue::Int(self.schedule_impact_days as i64),
900        );
901        p.insert(
902            "isApproved".into(),
903            GraphPropertyValue::Bool(matches!(self.status, ChangeOrderStatus::Approved)),
904        );
905        p
906    }
907}
908
909impl ToNodeProperties for EarnedValueMetric {
910    fn node_type_name(&self) -> &'static str {
911        "earned_value_metric"
912    }
913    fn node_type_code(&self) -> u16 {
914        453
915    }
916    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
917        let mut p = HashMap::new();
918        p.insert(
919            "projectId".into(),
920            GraphPropertyValue::String(self.project_id.clone()),
921        );
922        p.insert(
923            "measurementDate".into(),
924            GraphPropertyValue::Date(self.measurement_date),
925        );
926        p.insert("bac".into(), GraphPropertyValue::Decimal(self.bac));
927        p.insert(
928            "plannedValue".into(),
929            GraphPropertyValue::Decimal(self.planned_value),
930        );
931        p.insert(
932            "earnedValue".into(),
933            GraphPropertyValue::Decimal(self.earned_value),
934        );
935        p.insert(
936            "actualCost".into(),
937            GraphPropertyValue::Decimal(self.actual_cost),
938        );
939        p.insert("spi".into(), GraphPropertyValue::Decimal(self.spi));
940        p.insert("cpi".into(), GraphPropertyValue::Decimal(self.cpi));
941        p.insert("eac".into(), GraphPropertyValue::Decimal(self.eac));
942        p
943    }
944}
945
946// ---------------------------------------------------------------------------
947// Tests
948// ---------------------------------------------------------------------------
949
950#[cfg(test)]
951#[allow(clippy::unwrap_used)]
952mod tests {
953    use super::*;
954
955    fn d(s: &str) -> NaiveDate {
956        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
957    }
958
959    // -- Cost Lines --
960
961    #[test]
962    fn test_cost_line_creation() {
963        let line = ProjectCostLine::new(
964            "PCL-001",
965            "PRJ-001",
966            "PRJ-001.01",
967            "C001",
968            d("2025-03-15"),
969            CostCategory::Labor,
970            CostSourceType::TimeEntry,
971            "TE-001",
972            dec!(1500),
973            "USD",
974        )
975        .with_hours(dec!(20))
976        .with_description("Developer time - sprint 5");
977
978        assert_eq!(line.cost_category, CostCategory::Labor);
979        assert_eq!(line.source_type, CostSourceType::TimeEntry);
980        assert_eq!(line.amount, dec!(1500));
981        assert_eq!(line.hours, Some(dec!(20)));
982        assert_eq!(line.hourly_rate(), Some(dec!(75.00)));
983    }
984
985    #[test]
986    fn test_cost_line_hourly_rate_no_hours() {
987        let line = ProjectCostLine::new(
988            "PCL-002",
989            "PRJ-001",
990            "PRJ-001.02",
991            "C001",
992            d("2025-03-15"),
993            CostCategory::Material,
994            CostSourceType::PurchaseOrder,
995            "PO-001",
996            dec!(5000),
997            "USD",
998        );
999        assert_eq!(line.hourly_rate(), None);
1000    }
1001
1002    // -- Revenue Recognition --
1003
1004    #[test]
1005    fn test_revenue_poc_completion() {
1006        let rev = ProjectRevenue {
1007            id: "REV-001".to_string(),
1008            project_id: "PRJ-001".to_string(),
1009            entity_id: "C001".to_string(),
1010            period_start: d("2025-01-01"),
1011            period_end: d("2025-03-31"),
1012            contract_value: dec!(1000000),
1013            estimated_total_cost: dec!(800000),
1014            costs_to_date: dec!(400000),
1015            completion_pct: dec!(0.50),
1016            method: RevenueMethod::PercentageOfCompletion,
1017            measure: CompletionMeasure::CostToCost,
1018            cumulative_revenue: dec!(500000),
1019            period_revenue: dec!(200000),
1020            billed_to_date: dec!(400000),
1021            unbilled_revenue: dec!(100000),
1022            gross_margin_pct: dec!(0.20),
1023        };
1024
1025        // PoC: 400000 / 800000 = 0.50
1026        assert_eq!(rev.computed_completion_pct(), dec!(0.5000));
1027        // Cumulative revenue: 1000000 * 0.50 = 500000
1028        assert_eq!(rev.computed_cumulative_revenue(), dec!(500000.00));
1029        // Unbilled: 500000 - 400000 = 100000
1030        assert_eq!(rev.computed_unbilled_revenue(), dec!(100000.00));
1031        // Margin: (1000000 - 800000) / 1000000 = 0.20
1032        assert_eq!(rev.computed_gross_margin_pct(), dec!(0.2000));
1033    }
1034
1035    #[test]
1036    fn test_revenue_zero_estimated_cost() {
1037        let rev = ProjectRevenue {
1038            id: "REV-002".to_string(),
1039            project_id: "PRJ-002".to_string(),
1040            entity_id: "C001".to_string(),
1041            period_start: d("2025-01-01"),
1042            period_end: d("2025-03-31"),
1043            contract_value: dec!(100000),
1044            estimated_total_cost: Decimal::ZERO,
1045            costs_to_date: Decimal::ZERO,
1046            completion_pct: Decimal::ZERO,
1047            method: RevenueMethod::PercentageOfCompletion,
1048            measure: CompletionMeasure::CostToCost,
1049            cumulative_revenue: Decimal::ZERO,
1050            period_revenue: Decimal::ZERO,
1051            billed_to_date: Decimal::ZERO,
1052            unbilled_revenue: Decimal::ZERO,
1053            gross_margin_pct: Decimal::ZERO,
1054        };
1055        assert_eq!(rev.computed_completion_pct(), Decimal::ZERO);
1056    }
1057
1058    // -- Milestones --
1059
1060    #[test]
1061    fn test_milestone_creation() {
1062        let ms = ProjectMilestone::new(
1063            "MS-001",
1064            "PRJ-001",
1065            "Foundation Complete",
1066            d("2025-06-30"),
1067            1,
1068        )
1069        .with_wbs("PRJ-001.02")
1070        .with_payment(dec!(50000))
1071        .with_weight(dec!(0.25));
1072
1073        assert_eq!(ms.status, MilestoneStatus::Pending);
1074        assert_eq!(ms.payment_amount, dec!(50000));
1075        assert_eq!(ms.weight, dec!(0.25));
1076        assert!(ms.is_overdue_on(d("2025-07-01")));
1077        assert!(!ms.is_overdue_on(d("2025-06-15")));
1078    }
1079
1080    #[test]
1081    fn test_milestone_variance() {
1082        let mut ms = ProjectMilestone::new("MS-002", "PRJ-001", "Testing", d("2025-09-30"), 3);
1083        assert_eq!(ms.days_variance(), None);
1084
1085        // Complete 5 days late
1086        ms.actual_date = Some(d("2025-10-05"));
1087        ms.status = MilestoneStatus::Completed;
1088        assert_eq!(ms.days_variance(), Some(5));
1089
1090        // Complete 3 days early
1091        let mut ms2 = ProjectMilestone::new("MS-003", "PRJ-001", "Delivery", d("2025-12-31"), 4);
1092        ms2.actual_date = Some(d("2025-12-28"));
1093        ms2.status = MilestoneStatus::Completed;
1094        assert_eq!(ms2.days_variance(), Some(-3));
1095    }
1096
1097    // -- Change Orders --
1098
1099    #[test]
1100    fn test_change_order_approval() {
1101        let co = ChangeOrder::new(
1102            "CO-001",
1103            "PRJ-001",
1104            1,
1105            d("2025-04-15"),
1106            ChangeReason::ScopeChange,
1107            "Add additional floor to building",
1108        )
1109        .with_cost_impact(dec!(200000), dec!(180000))
1110        .with_schedule_impact(30);
1111
1112        assert!(!co.is_approved());
1113        assert_eq!(co.net_cost_impact(), Decimal::ZERO);
1114
1115        let co_approved = co.approve(d("2025-04-25"));
1116        assert!(co_approved.is_approved());
1117        assert_eq!(co_approved.net_cost_impact(), dec!(200000));
1118        assert_eq!(co_approved.schedule_impact_days, 30);
1119    }
1120
1121    // -- Retainage --
1122
1123    #[test]
1124    fn test_retainage_hold_and_release() {
1125        let mut ret = Retainage::new(
1126            "RET-001",
1127            "PRJ-001",
1128            "C001",
1129            "V-001",
1130            dec!(0.10), // 10%
1131            d("2025-01-15"),
1132        );
1133
1134        // Add retainage from three payments
1135        ret.add_from_payment(dec!(100000));
1136        ret.add_from_payment(dec!(150000));
1137        ret.add_from_payment(dec!(75000));
1138
1139        // Total held: (100000 + 150000 + 75000) * 0.10 = 32500
1140        assert_eq!(ret.total_held, dec!(32500.00));
1141        assert_eq!(ret.balance_held(), dec!(32500.00));
1142        assert_eq!(ret.status, RetainageStatus::Held);
1143
1144        // Partial release
1145        ret.release(dec!(15000), d("2025-06-30"));
1146        assert_eq!(ret.balance_held(), dec!(17500.00));
1147        assert_eq!(ret.status, RetainageStatus::PartiallyReleased);
1148
1149        // Full release
1150        ret.release(dec!(17500), d("2025-12-31"));
1151        assert_eq!(ret.balance_held(), dec!(0.00));
1152        assert_eq!(ret.status, RetainageStatus::Released);
1153    }
1154
1155    #[test]
1156    fn test_retainage_release_capped() {
1157        let mut ret = Retainage::new(
1158            "RET-002",
1159            "PRJ-001",
1160            "C001",
1161            "V-001",
1162            dec!(0.10),
1163            d("2025-01-15"),
1164        );
1165        ret.add_from_payment(dec!(100000)); // held = 10000
1166
1167        // Try to release more than held
1168        ret.release(dec!(50000), d("2025-12-31"));
1169        assert_eq!(ret.released_amount, dec!(10000.00)); // capped
1170        assert_eq!(ret.balance_held(), dec!(0.00));
1171        assert_eq!(ret.status, RetainageStatus::Released);
1172    }
1173
1174    // -- Earned Value Management --
1175
1176    #[test]
1177    fn test_evm_formulas() {
1178        // Project: BAC=1,000,000, 50% scheduled, 40% earned, 450,000 spent
1179        let evm = EarnedValueMetric::compute(
1180            "EVM-001",
1181            "PRJ-001",
1182            d("2025-06-30"),
1183            dec!(1000000), // BAC
1184            dec!(500000),  // PV (50% scheduled)
1185            dec!(400000),  // EV (40% earned)
1186            dec!(450000),  // AC (450k spent)
1187        );
1188
1189        // SV = EV - PV = 400000 - 500000 = -100000 (behind schedule)
1190        assert_eq!(evm.schedule_variance, dec!(-100000.00));
1191        // CV = EV - AC = 400000 - 450000 = -50000 (over budget)
1192        assert_eq!(evm.cost_variance, dec!(-50000.00));
1193        // SPI = EV / PV = 400000 / 500000 = 0.80
1194        assert_eq!(evm.spi, dec!(0.8000));
1195        // CPI = EV / AC = 400000 / 450000 = 0.8889
1196        assert_eq!(evm.cpi, dec!(0.8889));
1197        // EAC = BAC / CPI = 1000000 / 0.8889 ≈ 1124972.44
1198        let expected_eac = (dec!(1000000) / dec!(0.8889)).round_dp(2);
1199        assert_eq!(evm.eac, expected_eac);
1200        // ETC = EAC - AC
1201        assert_eq!(evm.etc, (evm.eac - dec!(450000)).round_dp(2));
1202        // TCPI = (BAC - EV) / (BAC - AC) = 600000 / 550000 ≈ 1.0909
1203        assert_eq!(evm.tcpi, dec!(1.0909));
1204
1205        assert!(!evm.is_ahead_of_schedule());
1206        assert!(!evm.is_under_budget());
1207        assert!(!evm.is_healthy());
1208    }
1209
1210    #[test]
1211    fn test_evm_healthy_project() {
1212        let evm = EarnedValueMetric::compute(
1213            "EVM-002",
1214            "PRJ-002",
1215            d("2025-06-30"),
1216            dec!(500000), // BAC
1217            dec!(250000), // PV (50% scheduled)
1218            dec!(275000), // EV (55% earned — ahead)
1219            dec!(240000), // AC (240k — under budget)
1220        );
1221
1222        // SPI = 275000 / 250000 = 1.10
1223        assert_eq!(evm.spi, dec!(1.1000));
1224        // CPI = 275000 / 240000 ≈ 1.1458
1225        assert_eq!(evm.cpi, dec!(1.1458));
1226
1227        assert!(evm.is_ahead_of_schedule());
1228        assert!(evm.is_under_budget());
1229        assert!(evm.is_healthy());
1230
1231        // VAC = BAC - EAC = 500000 - (500000 / 1.1458)
1232        assert!(evm.variance_at_completion() > Decimal::ZERO);
1233    }
1234
1235    #[test]
1236    fn test_evm_zero_inputs() {
1237        // Edge case: all zeros (project just started)
1238        let evm = EarnedValueMetric::compute(
1239            "EVM-003",
1240            "PRJ-003",
1241            d("2025-01-01"),
1242            dec!(1000000), // BAC
1243            Decimal::ZERO, // PV
1244            Decimal::ZERO, // EV
1245            Decimal::ZERO, // AC
1246        );
1247
1248        // With PV=0 and AC=0, SPI and CPI default to 1.0
1249        assert_eq!(evm.spi, dec!(1.00));
1250        assert_eq!(evm.cpi, dec!(1.00));
1251        assert_eq!(evm.eac, dec!(1000000));
1252    }
1253
1254    // -- Serde roundtrip --
1255
1256    #[test]
1257    fn test_cost_line_serde_roundtrip() {
1258        let line = ProjectCostLine::new(
1259            "PCL-100",
1260            "PRJ-001",
1261            "PRJ-001.01",
1262            "C001",
1263            d("2025-03-15"),
1264            CostCategory::Subcontractor,
1265            CostSourceType::VendorInvoice,
1266            "VI-099",
1267            dec!(25000),
1268            "EUR",
1269        );
1270        let json = serde_json::to_string(&line).unwrap();
1271        let deserialized: ProjectCostLine = serde_json::from_str(&json).unwrap();
1272        assert_eq!(deserialized.id, "PCL-100");
1273        assert_eq!(deserialized.cost_category, CostCategory::Subcontractor);
1274        assert_eq!(deserialized.amount, dec!(25000));
1275    }
1276
1277    #[test]
1278    fn test_evm_serde_roundtrip() {
1279        let evm = EarnedValueMetric::compute(
1280            "EVM-100",
1281            "PRJ-001",
1282            d("2025-06-30"),
1283            dec!(1000000),
1284            dec!(500000),
1285            dec!(400000),
1286            dec!(450000),
1287        );
1288        let json = serde_json::to_string(&evm).unwrap();
1289        let deserialized: EarnedValueMetric = serde_json::from_str(&json).unwrap();
1290        assert_eq!(deserialized.spi, evm.spi);
1291        assert_eq!(deserialized.cpi, evm.cpi);
1292        assert_eq!(deserialized.eac, evm.eac);
1293    }
1294}