1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
24#[serde(rename_all = "snake_case")]
25pub enum CostCategory {
26 #[default]
28 Labor,
29 Material,
31 Subcontractor,
33 Overhead,
35 Equipment,
37 Travel,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
43#[serde(rename_all = "snake_case")]
44pub enum CostSourceType {
45 #[default]
47 TimeEntry,
48 ExpenseReport,
50 PurchaseOrder,
52 VendorInvoice,
54 JournalEntry,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ProjectCostLine {
65 pub id: String,
67 pub project_id: String,
69 pub wbs_id: String,
71 pub entity_id: String,
73 pub posting_date: NaiveDate,
75 pub cost_category: CostCategory,
77 pub source_type: CostSourceType,
79 pub source_document_id: String,
81 #[serde(with = "rust_decimal::serde::str")]
83 pub amount: Decimal,
84 pub currency: String,
86 pub hours: Option<Decimal>,
88 pub description: String,
90}
91
92impl ProjectCostLine {
93 #[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 pub fn with_hours(mut self, hours: Decimal) -> Self {
125 self.hours = Some(hours);
126 self
127 }
128
129 pub fn with_description(mut self, description: impl Into<String>) -> Self {
131 self.description = description.into();
132 self
133 }
134
135 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
149#[serde(rename_all = "snake_case")]
150pub enum RevenueMethod {
151 #[default]
153 PercentageOfCompletion,
154 CompletedContract,
156 MilestoneBased,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
162#[serde(rename_all = "snake_case")]
163pub enum CompletionMeasure {
164 #[default]
166 CostToCost,
167 LaborHours,
169 PhysicalCompletion,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ProjectRevenue {
176 pub id: String,
178 pub project_id: String,
180 pub entity_id: String,
182 pub period_start: NaiveDate,
184 pub period_end: NaiveDate,
186 #[serde(with = "rust_decimal::serde::str")]
188 pub contract_value: Decimal,
189 #[serde(with = "rust_decimal::serde::str")]
191 pub estimated_total_cost: Decimal,
192 #[serde(with = "rust_decimal::serde::str")]
194 pub costs_to_date: Decimal,
195 #[serde(with = "rust_decimal::serde::str")]
197 pub completion_pct: Decimal,
198 pub method: RevenueMethod,
200 pub measure: CompletionMeasure,
202 #[serde(with = "rust_decimal::serde::str")]
204 pub cumulative_revenue: Decimal,
205 #[serde(with = "rust_decimal::serde::str")]
207 pub period_revenue: Decimal,
208 #[serde(with = "rust_decimal::serde::str")]
210 pub billed_to_date: Decimal,
211 #[serde(with = "rust_decimal::serde::str")]
213 pub unbilled_revenue: Decimal,
214 #[serde(with = "rust_decimal::serde::str")]
216 pub gross_margin_pct: Decimal,
217}
218
219impl ProjectRevenue {
220 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 pub fn computed_cumulative_revenue(&self) -> Decimal {
230 (self.contract_value * self.completion_pct).round_dp(2)
231 }
232
233 pub fn computed_unbilled_revenue(&self) -> Decimal {
235 (self.cumulative_revenue - self.billed_to_date).round_dp(2)
236 }
237
238 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
253#[serde(rename_all = "snake_case")]
254pub enum MilestoneStatus {
255 #[default]
257 Pending,
258 InProgress,
260 Completed,
262 Overdue,
264 Cancelled,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct ProjectMilestone {
271 pub id: String,
273 pub project_id: String,
275 pub wbs_id: Option<String>,
277 pub name: String,
279 pub planned_date: NaiveDate,
281 pub actual_date: Option<NaiveDate>,
283 pub status: MilestoneStatus,
285 #[serde(with = "rust_decimal::serde::str")]
287 pub payment_amount: Decimal,
288 #[serde(with = "rust_decimal::serde::str")]
290 pub weight: Decimal,
291 pub sequence: u32,
293}
294
295impl ProjectMilestone {
296 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 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 pub fn with_payment(mut self, amount: Decimal) -> Self {
326 self.payment_amount = amount;
327 self
328 }
329
330 pub fn with_weight(mut self, weight: Decimal) -> Self {
332 self.weight = weight;
333 self
334 }
335
336 pub fn is_overdue_on(&self, date: NaiveDate) -> bool {
338 self.actual_date.is_none() && date > self.planned_date
339 }
340
341 pub fn days_variance(&self) -> Option<i64> {
343 self.actual_date
344 .map(|actual| (actual - self.planned_date).num_days())
345 }
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
354#[serde(rename_all = "snake_case")]
355pub enum ChangeOrderStatus {
356 #[default]
358 Submitted,
359 UnderReview,
361 Approved,
363 Rejected,
365 Withdrawn,
367}
368
369#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
371#[serde(rename_all = "snake_case")]
372pub enum ChangeReason {
373 #[default]
375 ScopeChange,
376 UnforeseenConditions,
378 DesignError,
380 RegulatoryChange,
382 ValueEngineering,
384 ScheduleAcceleration,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct ChangeOrder {
391 pub id: String,
393 pub project_id: String,
395 pub number: u32,
397 pub submitted_date: NaiveDate,
399 pub approved_date: Option<NaiveDate>,
401 pub status: ChangeOrderStatus,
403 pub reason: ChangeReason,
405 pub description: String,
407 #[serde(with = "rust_decimal::serde::str")]
409 pub cost_impact: Decimal,
410 #[serde(with = "rust_decimal::serde::str")]
412 pub estimated_cost_impact: Decimal,
413 pub schedule_impact_days: i32,
415}
416
417impl ChangeOrder {
418 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 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 pub fn with_schedule_impact(mut self, days: i32) -> Self {
451 self.schedule_impact_days = days;
452 self
453 }
454
455 pub fn approve(mut self, date: NaiveDate) -> Self {
457 self.status = ChangeOrderStatus::Approved;
458 self.approved_date = Some(date);
459 self
460 }
461
462 pub fn is_approved(&self) -> bool {
464 self.status == ChangeOrderStatus::Approved
465 }
466
467 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
483#[serde(rename_all = "snake_case")]
484pub enum RetainageStatus {
485 #[default]
487 Held,
488 PartiallyReleased,
490 Released,
492 Forfeited,
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct Retainage {
499 pub id: String,
501 pub project_id: String,
503 pub entity_id: String,
505 pub vendor_id: String,
507 #[serde(with = "rust_decimal::serde::str")]
509 pub retainage_pct: Decimal,
510 #[serde(with = "rust_decimal::serde::str")]
512 pub total_held: Decimal,
513 #[serde(with = "rust_decimal::serde::str")]
515 pub released_amount: Decimal,
516 pub status: RetainageStatus,
518 pub inception_date: NaiveDate,
520 pub last_release_date: Option<NaiveDate>,
522}
523
524impl Retainage {
525 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 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 pub fn balance_held(&self) -> Decimal {
556 (self.total_held - self.released_amount).round_dp(2)
557 }
558
559 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#[derive(Debug, Clone, Serialize, Deserialize)]
587pub struct EarnedValueMetric {
588 pub id: String,
590 pub project_id: String,
592 pub measurement_date: NaiveDate,
594 #[serde(with = "rust_decimal::serde::str")]
596 pub bac: Decimal,
597 #[serde(with = "rust_decimal::serde::str")]
599 pub planned_value: Decimal,
600 #[serde(with = "rust_decimal::serde::str")]
602 pub earned_value: Decimal,
603 #[serde(with = "rust_decimal::serde::str")]
605 pub actual_cost: Decimal,
606 #[serde(with = "rust_decimal::serde::str")]
608 pub schedule_variance: Decimal,
609 #[serde(with = "rust_decimal::serde::str")]
611 pub cost_variance: Decimal,
612 #[serde(with = "rust_decimal::serde::str")]
614 pub spi: Decimal,
615 #[serde(with = "rust_decimal::serde::str")]
617 pub cpi: Decimal,
618 #[serde(with = "rust_decimal::serde::str")]
620 pub eac: Decimal,
621 #[serde(with = "rust_decimal::serde::str")]
623 pub etc: Decimal,
624 #[serde(with = "rust_decimal::serde::str")]
626 pub tcpi: Decimal,
627}
628
629impl EarnedValueMetric {
630 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 pub fn is_ahead_of_schedule(&self) -> bool {
687 self.spi > dec!(1.00)
688 }
689
690 pub fn is_under_budget(&self) -> bool {
692 self.cpi > dec!(1.00)
693 }
694
695 pub fn is_healthy(&self) -> bool {
697 self.spi >= dec!(0.90) && self.cpi >= dec!(0.90)
698 }
699
700 pub fn variance_at_completion(&self) -> Decimal {
702 (self.bac - self.eac).round_dp(2)
703 }
704}
705
706#[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 #[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 #[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 assert_eq!(rev.computed_completion_pct(), dec!(0.5000));
787 assert_eq!(rev.computed_cumulative_revenue(), dec!(500000.00));
789 assert_eq!(rev.computed_unbilled_revenue(), dec!(100000.00));
791 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 #[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 ms.actual_date = Some(d("2025-10-05"));
847 ms.status = MilestoneStatus::Completed;
848 assert_eq!(ms.days_variance(), Some(5));
849
850 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 #[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 #[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), d("2025-01-15"),
892 );
893
894 ret.add_from_payment(dec!(100000));
896 ret.add_from_payment(dec!(150000));
897 ret.add_from_payment(dec!(75000));
898
899 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 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 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)); ret.release(dec!(50000), d("2025-12-31"));
929 assert_eq!(ret.released_amount, dec!(10000.00)); assert_eq!(ret.balance_held(), dec!(0.00));
931 assert_eq!(ret.status, RetainageStatus::Released);
932 }
933
934 #[test]
937 fn test_evm_formulas() {
938 let evm = EarnedValueMetric::compute(
940 "EVM-001",
941 "PRJ-001",
942 d("2025-06-30"),
943 dec!(1000000), dec!(500000), dec!(400000), dec!(450000), );
948
949 assert_eq!(evm.schedule_variance, dec!(-100000.00));
951 assert_eq!(evm.cost_variance, dec!(-50000.00));
953 assert_eq!(evm.spi, dec!(0.8000));
955 assert_eq!(evm.cpi, dec!(0.8889));
957 let expected_eac = (dec!(1000000) / dec!(0.8889)).round_dp(2);
959 assert_eq!(evm.eac, expected_eac);
960 assert_eq!(evm.etc, (evm.eac - dec!(450000)).round_dp(2));
962 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), dec!(250000), dec!(275000), dec!(240000), );
981
982 assert_eq!(evm.spi, dec!(1.1000));
984 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 assert!(evm.variance_at_completion() > Decimal::ZERO);
993 }
994
995 #[test]
996 fn test_evm_zero_inputs() {
997 let evm = EarnedValueMetric::compute(
999 "EVM-003",
1000 "PRJ-003",
1001 d("2025-01-01"),
1002 dec!(1000000), Decimal::ZERO, Decimal::ZERO, Decimal::ZERO, );
1007
1008 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 #[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
1056impl 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}