1use chrono::NaiveDate;
11use rust_decimal::Decimal;
12use rust_decimal_macros::dec;
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
21#[serde(rename_all = "snake_case")]
22pub enum CostCategory {
23 #[default]
25 Labor,
26 Material,
28 Subcontractor,
30 Overhead,
32 Equipment,
34 Travel,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
40#[serde(rename_all = "snake_case")]
41pub enum CostSourceType {
42 #[default]
44 TimeEntry,
45 ExpenseReport,
47 PurchaseOrder,
49 VendorInvoice,
51 JournalEntry,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ProjectCostLine {
62 pub id: String,
64 pub project_id: String,
66 pub wbs_id: String,
68 pub entity_id: String,
70 pub posting_date: NaiveDate,
72 pub cost_category: CostCategory,
74 pub source_type: CostSourceType,
76 pub source_document_id: String,
78 #[serde(with = "rust_decimal::serde::str")]
80 pub amount: Decimal,
81 pub currency: String,
83 pub hours: Option<Decimal>,
85 pub description: String,
87}
88
89impl ProjectCostLine {
90 #[allow(clippy::too_many_arguments)]
92 pub fn new(
93 id: impl Into<String>,
94 project_id: impl Into<String>,
95 wbs_id: impl Into<String>,
96 entity_id: impl Into<String>,
97 posting_date: NaiveDate,
98 cost_category: CostCategory,
99 source_type: CostSourceType,
100 source_document_id: impl Into<String>,
101 amount: Decimal,
102 currency: impl Into<String>,
103 ) -> Self {
104 Self {
105 id: id.into(),
106 project_id: project_id.into(),
107 wbs_id: wbs_id.into(),
108 entity_id: entity_id.into(),
109 posting_date,
110 cost_category,
111 source_type,
112 source_document_id: source_document_id.into(),
113 amount,
114 currency: currency.into(),
115 hours: None,
116 description: String::new(),
117 }
118 }
119
120 pub fn with_hours(mut self, hours: Decimal) -> Self {
122 self.hours = Some(hours);
123 self
124 }
125
126 pub fn with_description(mut self, description: impl Into<String>) -> Self {
128 self.description = description.into();
129 self
130 }
131
132 pub fn hourly_rate(&self) -> Option<Decimal> {
134 self.hours
135 .filter(|h| !h.is_zero())
136 .map(|h| (self.amount / h).round_dp(2))
137 }
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
146#[serde(rename_all = "snake_case")]
147pub enum RevenueMethod {
148 #[default]
150 PercentageOfCompletion,
151 CompletedContract,
153 MilestoneBased,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
159#[serde(rename_all = "snake_case")]
160pub enum CompletionMeasure {
161 #[default]
163 CostToCost,
164 LaborHours,
166 PhysicalCompletion,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct ProjectRevenue {
173 pub id: String,
175 pub project_id: String,
177 pub entity_id: String,
179 pub period_start: NaiveDate,
181 pub period_end: NaiveDate,
183 #[serde(with = "rust_decimal::serde::str")]
185 pub contract_value: Decimal,
186 #[serde(with = "rust_decimal::serde::str")]
188 pub estimated_total_cost: Decimal,
189 #[serde(with = "rust_decimal::serde::str")]
191 pub costs_to_date: Decimal,
192 #[serde(with = "rust_decimal::serde::str")]
194 pub completion_pct: Decimal,
195 pub method: RevenueMethod,
197 pub measure: CompletionMeasure,
199 #[serde(with = "rust_decimal::serde::str")]
201 pub cumulative_revenue: Decimal,
202 #[serde(with = "rust_decimal::serde::str")]
204 pub period_revenue: Decimal,
205 #[serde(with = "rust_decimal::serde::str")]
207 pub billed_to_date: Decimal,
208 #[serde(with = "rust_decimal::serde::str")]
210 pub unbilled_revenue: Decimal,
211 #[serde(with = "rust_decimal::serde::str")]
213 pub gross_margin_pct: Decimal,
214}
215
216impl ProjectRevenue {
217 pub fn computed_completion_pct(&self) -> Decimal {
219 if self.estimated_total_cost.is_zero() {
220 return Decimal::ZERO;
221 }
222 (self.costs_to_date / self.estimated_total_cost).round_dp(4)
223 }
224
225 pub fn computed_cumulative_revenue(&self) -> Decimal {
227 (self.contract_value * self.completion_pct).round_dp(2)
228 }
229
230 pub fn computed_unbilled_revenue(&self) -> Decimal {
232 (self.cumulative_revenue - self.billed_to_date).round_dp(2)
233 }
234
235 pub fn computed_gross_margin_pct(&self) -> Decimal {
237 if self.contract_value.is_zero() {
238 return Decimal::ZERO;
239 }
240 ((self.contract_value - self.estimated_total_cost) / self.contract_value).round_dp(4)
241 }
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
250#[serde(rename_all = "snake_case")]
251pub enum MilestoneStatus {
252 #[default]
254 Pending,
255 InProgress,
257 Completed,
259 Overdue,
261 Cancelled,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct ProjectMilestone {
268 pub id: String,
270 pub project_id: String,
272 pub wbs_id: Option<String>,
274 pub name: String,
276 pub planned_date: NaiveDate,
278 pub actual_date: Option<NaiveDate>,
280 pub status: MilestoneStatus,
282 #[serde(with = "rust_decimal::serde::str")]
284 pub payment_amount: Decimal,
285 #[serde(with = "rust_decimal::serde::str")]
287 pub weight: Decimal,
288 pub sequence: u32,
290}
291
292impl ProjectMilestone {
293 pub fn new(
295 id: impl Into<String>,
296 project_id: impl Into<String>,
297 name: impl Into<String>,
298 planned_date: NaiveDate,
299 sequence: u32,
300 ) -> Self {
301 Self {
302 id: id.into(),
303 project_id: project_id.into(),
304 wbs_id: None,
305 name: name.into(),
306 planned_date,
307 actual_date: None,
308 status: MilestoneStatus::Pending,
309 payment_amount: Decimal::ZERO,
310 weight: Decimal::ZERO,
311 sequence,
312 }
313 }
314
315 pub fn with_wbs(mut self, wbs_id: impl Into<String>) -> Self {
317 self.wbs_id = Some(wbs_id.into());
318 self
319 }
320
321 pub fn with_payment(mut self, amount: Decimal) -> Self {
323 self.payment_amount = amount;
324 self
325 }
326
327 pub fn with_weight(mut self, weight: Decimal) -> Self {
329 self.weight = weight;
330 self
331 }
332
333 pub fn is_overdue_on(&self, date: NaiveDate) -> bool {
335 self.actual_date.is_none() && date > self.planned_date
336 }
337
338 pub fn days_variance(&self) -> Option<i64> {
340 self.actual_date
341 .map(|actual| (actual - self.planned_date).num_days())
342 }
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
351#[serde(rename_all = "snake_case")]
352pub enum ChangeOrderStatus {
353 #[default]
355 Submitted,
356 UnderReview,
358 Approved,
360 Rejected,
362 Withdrawn,
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
368#[serde(rename_all = "snake_case")]
369pub enum ChangeReason {
370 #[default]
372 ScopeChange,
373 UnforeseenConditions,
375 DesignError,
377 RegulatoryChange,
379 ValueEngineering,
381 ScheduleAcceleration,
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct ChangeOrder {
388 pub id: String,
390 pub project_id: String,
392 pub number: u32,
394 pub submitted_date: NaiveDate,
396 pub approved_date: Option<NaiveDate>,
398 pub status: ChangeOrderStatus,
400 pub reason: ChangeReason,
402 pub description: String,
404 #[serde(with = "rust_decimal::serde::str")]
406 pub cost_impact: Decimal,
407 #[serde(with = "rust_decimal::serde::str")]
409 pub estimated_cost_impact: Decimal,
410 pub schedule_impact_days: i32,
412}
413
414impl ChangeOrder {
415 pub fn new(
417 id: impl Into<String>,
418 project_id: impl Into<String>,
419 number: u32,
420 submitted_date: NaiveDate,
421 reason: ChangeReason,
422 description: impl Into<String>,
423 ) -> Self {
424 Self {
425 id: id.into(),
426 project_id: project_id.into(),
427 number,
428 submitted_date,
429 approved_date: None,
430 status: ChangeOrderStatus::Submitted,
431 reason,
432 description: description.into(),
433 cost_impact: Decimal::ZERO,
434 estimated_cost_impact: Decimal::ZERO,
435 schedule_impact_days: 0,
436 }
437 }
438
439 pub fn with_cost_impact(mut self, contract_impact: Decimal, estimated_impact: Decimal) -> Self {
441 self.cost_impact = contract_impact;
442 self.estimated_cost_impact = estimated_impact;
443 self
444 }
445
446 pub fn with_schedule_impact(mut self, days: i32) -> Self {
448 self.schedule_impact_days = days;
449 self
450 }
451
452 pub fn approve(mut self, date: NaiveDate) -> Self {
454 self.status = ChangeOrderStatus::Approved;
455 self.approved_date = Some(date);
456 self
457 }
458
459 pub fn is_approved(&self) -> bool {
461 self.status == ChangeOrderStatus::Approved
462 }
463
464 pub fn net_cost_impact(&self) -> Decimal {
466 if self.is_approved() {
467 self.cost_impact
468 } else {
469 Decimal::ZERO
470 }
471 }
472}
473
474#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
480#[serde(rename_all = "snake_case")]
481pub enum RetainageStatus {
482 #[default]
484 Held,
485 PartiallyReleased,
487 Released,
489 Forfeited,
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct Retainage {
496 pub id: String,
498 pub project_id: String,
500 pub entity_id: String,
502 pub vendor_id: String,
504 #[serde(with = "rust_decimal::serde::str")]
506 pub retainage_pct: Decimal,
507 #[serde(with = "rust_decimal::serde::str")]
509 pub total_held: Decimal,
510 #[serde(with = "rust_decimal::serde::str")]
512 pub released_amount: Decimal,
513 pub status: RetainageStatus,
515 pub inception_date: NaiveDate,
517 pub last_release_date: Option<NaiveDate>,
519}
520
521impl Retainage {
522 pub fn new(
524 id: impl Into<String>,
525 project_id: impl Into<String>,
526 entity_id: impl Into<String>,
527 vendor_id: impl Into<String>,
528 retainage_pct: Decimal,
529 inception_date: NaiveDate,
530 ) -> Self {
531 Self {
532 id: id.into(),
533 project_id: project_id.into(),
534 entity_id: entity_id.into(),
535 vendor_id: vendor_id.into(),
536 retainage_pct,
537 total_held: Decimal::ZERO,
538 released_amount: Decimal::ZERO,
539 status: RetainageStatus::Held,
540 inception_date,
541 last_release_date: None,
542 }
543 }
544
545 pub fn add_from_payment(&mut self, payment_amount: Decimal) {
547 let held = (payment_amount * self.retainage_pct).round_dp(2);
548 self.total_held += held;
549 }
550
551 pub fn balance_held(&self) -> Decimal {
553 (self.total_held - self.released_amount).round_dp(2)
554 }
555
556 pub fn release(&mut self, amount: Decimal, date: NaiveDate) {
558 let release = amount.min(self.balance_held());
559 self.released_amount += release;
560 self.last_release_date = Some(date);
561 if self.balance_held().is_zero() {
562 self.status = RetainageStatus::Released;
563 } else {
564 self.status = RetainageStatus::PartiallyReleased;
565 }
566 }
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize)]
584pub struct EarnedValueMetric {
585 pub id: String,
587 pub project_id: String,
589 pub measurement_date: NaiveDate,
591 #[serde(with = "rust_decimal::serde::str")]
593 pub bac: Decimal,
594 #[serde(with = "rust_decimal::serde::str")]
596 pub planned_value: Decimal,
597 #[serde(with = "rust_decimal::serde::str")]
599 pub earned_value: Decimal,
600 #[serde(with = "rust_decimal::serde::str")]
602 pub actual_cost: Decimal,
603 #[serde(with = "rust_decimal::serde::str")]
605 pub schedule_variance: Decimal,
606 #[serde(with = "rust_decimal::serde::str")]
608 pub cost_variance: Decimal,
609 #[serde(with = "rust_decimal::serde::str")]
611 pub spi: Decimal,
612 #[serde(with = "rust_decimal::serde::str")]
614 pub cpi: Decimal,
615 #[serde(with = "rust_decimal::serde::str")]
617 pub eac: Decimal,
618 #[serde(with = "rust_decimal::serde::str")]
620 pub etc: Decimal,
621 #[serde(with = "rust_decimal::serde::str")]
623 pub tcpi: Decimal,
624}
625
626impl EarnedValueMetric {
627 pub fn compute(
630 id: impl Into<String>,
631 project_id: impl Into<String>,
632 measurement_date: NaiveDate,
633 bac: Decimal,
634 planned_value: Decimal,
635 earned_value: Decimal,
636 actual_cost: Decimal,
637 ) -> Self {
638 let sv = (earned_value - planned_value).round_dp(2);
639 let cv = (earned_value - actual_cost).round_dp(2);
640 let spi = if planned_value.is_zero() {
641 dec!(1.00)
642 } else {
643 (earned_value / planned_value).round_dp(4)
644 };
645 let cpi = if actual_cost.is_zero() {
646 dec!(1.00)
647 } else {
648 (earned_value / actual_cost).round_dp(4)
649 };
650 let eac = if cpi.is_zero() {
651 bac
652 } else {
653 (bac / cpi).round_dp(2)
654 };
655 let etc = (eac - actual_cost).round_dp(2);
656 let remaining_budget = bac - actual_cost;
657 let remaining_work = bac - earned_value;
658 let tcpi = if remaining_budget.is_zero() {
659 dec!(1.00)
660 } else {
661 (remaining_work / remaining_budget).round_dp(4)
662 };
663
664 Self {
665 id: id.into(),
666 project_id: project_id.into(),
667 measurement_date,
668 bac,
669 planned_value,
670 earned_value,
671 actual_cost,
672 schedule_variance: sv,
673 cost_variance: cv,
674 spi,
675 cpi,
676 eac,
677 etc,
678 tcpi,
679 }
680 }
681
682 pub fn is_ahead_of_schedule(&self) -> bool {
684 self.spi > dec!(1.00)
685 }
686
687 pub fn is_under_budget(&self) -> bool {
689 self.cpi > dec!(1.00)
690 }
691
692 pub fn is_healthy(&self) -> bool {
694 self.spi >= dec!(0.90) && self.cpi >= dec!(0.90)
695 }
696
697 pub fn variance_at_completion(&self) -> Decimal {
699 (self.bac - self.eac).round_dp(2)
700 }
701}
702
703#[cfg(test)]
708#[allow(clippy::unwrap_used)]
709mod tests {
710 use super::*;
711
712 fn d(s: &str) -> NaiveDate {
713 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
714 }
715
716 #[test]
719 fn test_cost_line_creation() {
720 let line = ProjectCostLine::new(
721 "PCL-001",
722 "PRJ-001",
723 "PRJ-001.01",
724 "C001",
725 d("2025-03-15"),
726 CostCategory::Labor,
727 CostSourceType::TimeEntry,
728 "TE-001",
729 dec!(1500),
730 "USD",
731 )
732 .with_hours(dec!(20))
733 .with_description("Developer time - sprint 5");
734
735 assert_eq!(line.cost_category, CostCategory::Labor);
736 assert_eq!(line.source_type, CostSourceType::TimeEntry);
737 assert_eq!(line.amount, dec!(1500));
738 assert_eq!(line.hours, Some(dec!(20)));
739 assert_eq!(line.hourly_rate(), Some(dec!(75.00)));
740 }
741
742 #[test]
743 fn test_cost_line_hourly_rate_no_hours() {
744 let line = ProjectCostLine::new(
745 "PCL-002",
746 "PRJ-001",
747 "PRJ-001.02",
748 "C001",
749 d("2025-03-15"),
750 CostCategory::Material,
751 CostSourceType::PurchaseOrder,
752 "PO-001",
753 dec!(5000),
754 "USD",
755 );
756 assert_eq!(line.hourly_rate(), None);
757 }
758
759 #[test]
762 fn test_revenue_poc_completion() {
763 let rev = ProjectRevenue {
764 id: "REV-001".to_string(),
765 project_id: "PRJ-001".to_string(),
766 entity_id: "C001".to_string(),
767 period_start: d("2025-01-01"),
768 period_end: d("2025-03-31"),
769 contract_value: dec!(1000000),
770 estimated_total_cost: dec!(800000),
771 costs_to_date: dec!(400000),
772 completion_pct: dec!(0.50),
773 method: RevenueMethod::PercentageOfCompletion,
774 measure: CompletionMeasure::CostToCost,
775 cumulative_revenue: dec!(500000),
776 period_revenue: dec!(200000),
777 billed_to_date: dec!(400000),
778 unbilled_revenue: dec!(100000),
779 gross_margin_pct: dec!(0.20),
780 };
781
782 assert_eq!(rev.computed_completion_pct(), dec!(0.5000));
784 assert_eq!(rev.computed_cumulative_revenue(), dec!(500000.00));
786 assert_eq!(rev.computed_unbilled_revenue(), dec!(100000.00));
788 assert_eq!(rev.computed_gross_margin_pct(), dec!(0.2000));
790 }
791
792 #[test]
793 fn test_revenue_zero_estimated_cost() {
794 let rev = ProjectRevenue {
795 id: "REV-002".to_string(),
796 project_id: "PRJ-002".to_string(),
797 entity_id: "C001".to_string(),
798 period_start: d("2025-01-01"),
799 period_end: d("2025-03-31"),
800 contract_value: dec!(100000),
801 estimated_total_cost: Decimal::ZERO,
802 costs_to_date: Decimal::ZERO,
803 completion_pct: Decimal::ZERO,
804 method: RevenueMethod::PercentageOfCompletion,
805 measure: CompletionMeasure::CostToCost,
806 cumulative_revenue: Decimal::ZERO,
807 period_revenue: Decimal::ZERO,
808 billed_to_date: Decimal::ZERO,
809 unbilled_revenue: Decimal::ZERO,
810 gross_margin_pct: Decimal::ZERO,
811 };
812 assert_eq!(rev.computed_completion_pct(), Decimal::ZERO);
813 }
814
815 #[test]
818 fn test_milestone_creation() {
819 let ms = ProjectMilestone::new(
820 "MS-001",
821 "PRJ-001",
822 "Foundation Complete",
823 d("2025-06-30"),
824 1,
825 )
826 .with_wbs("PRJ-001.02")
827 .with_payment(dec!(50000))
828 .with_weight(dec!(0.25));
829
830 assert_eq!(ms.status, MilestoneStatus::Pending);
831 assert_eq!(ms.payment_amount, dec!(50000));
832 assert_eq!(ms.weight, dec!(0.25));
833 assert!(ms.is_overdue_on(d("2025-07-01")));
834 assert!(!ms.is_overdue_on(d("2025-06-15")));
835 }
836
837 #[test]
838 fn test_milestone_variance() {
839 let mut ms = ProjectMilestone::new("MS-002", "PRJ-001", "Testing", d("2025-09-30"), 3);
840 assert_eq!(ms.days_variance(), None);
841
842 ms.actual_date = Some(d("2025-10-05"));
844 ms.status = MilestoneStatus::Completed;
845 assert_eq!(ms.days_variance(), Some(5));
846
847 let mut ms2 = ProjectMilestone::new("MS-003", "PRJ-001", "Delivery", d("2025-12-31"), 4);
849 ms2.actual_date = Some(d("2025-12-28"));
850 ms2.status = MilestoneStatus::Completed;
851 assert_eq!(ms2.days_variance(), Some(-3));
852 }
853
854 #[test]
857 fn test_change_order_approval() {
858 let co = ChangeOrder::new(
859 "CO-001",
860 "PRJ-001",
861 1,
862 d("2025-04-15"),
863 ChangeReason::ScopeChange,
864 "Add additional floor to building",
865 )
866 .with_cost_impact(dec!(200000), dec!(180000))
867 .with_schedule_impact(30);
868
869 assert!(!co.is_approved());
870 assert_eq!(co.net_cost_impact(), Decimal::ZERO);
871
872 let co_approved = co.approve(d("2025-04-25"));
873 assert!(co_approved.is_approved());
874 assert_eq!(co_approved.net_cost_impact(), dec!(200000));
875 assert_eq!(co_approved.schedule_impact_days, 30);
876 }
877
878 #[test]
881 fn test_retainage_hold_and_release() {
882 let mut ret = Retainage::new(
883 "RET-001",
884 "PRJ-001",
885 "C001",
886 "V-001",
887 dec!(0.10), d("2025-01-15"),
889 );
890
891 ret.add_from_payment(dec!(100000));
893 ret.add_from_payment(dec!(150000));
894 ret.add_from_payment(dec!(75000));
895
896 assert_eq!(ret.total_held, dec!(32500.00));
898 assert_eq!(ret.balance_held(), dec!(32500.00));
899 assert_eq!(ret.status, RetainageStatus::Held);
900
901 ret.release(dec!(15000), d("2025-06-30"));
903 assert_eq!(ret.balance_held(), dec!(17500.00));
904 assert_eq!(ret.status, RetainageStatus::PartiallyReleased);
905
906 ret.release(dec!(17500), d("2025-12-31"));
908 assert_eq!(ret.balance_held(), dec!(0.00));
909 assert_eq!(ret.status, RetainageStatus::Released);
910 }
911
912 #[test]
913 fn test_retainage_release_capped() {
914 let mut ret = Retainage::new(
915 "RET-002",
916 "PRJ-001",
917 "C001",
918 "V-001",
919 dec!(0.10),
920 d("2025-01-15"),
921 );
922 ret.add_from_payment(dec!(100000)); ret.release(dec!(50000), d("2025-12-31"));
926 assert_eq!(ret.released_amount, dec!(10000.00)); assert_eq!(ret.balance_held(), dec!(0.00));
928 assert_eq!(ret.status, RetainageStatus::Released);
929 }
930
931 #[test]
934 fn test_evm_formulas() {
935 let evm = EarnedValueMetric::compute(
937 "EVM-001",
938 "PRJ-001",
939 d("2025-06-30"),
940 dec!(1000000), dec!(500000), dec!(400000), dec!(450000), );
945
946 assert_eq!(evm.schedule_variance, dec!(-100000.00));
948 assert_eq!(evm.cost_variance, dec!(-50000.00));
950 assert_eq!(evm.spi, dec!(0.8000));
952 assert_eq!(evm.cpi, dec!(0.8889));
954 let expected_eac = (dec!(1000000) / dec!(0.8889)).round_dp(2);
956 assert_eq!(evm.eac, expected_eac);
957 assert_eq!(evm.etc, (evm.eac - dec!(450000)).round_dp(2));
959 assert_eq!(evm.tcpi, dec!(1.0909));
961
962 assert!(!evm.is_ahead_of_schedule());
963 assert!(!evm.is_under_budget());
964 assert!(!evm.is_healthy());
965 }
966
967 #[test]
968 fn test_evm_healthy_project() {
969 let evm = EarnedValueMetric::compute(
970 "EVM-002",
971 "PRJ-002",
972 d("2025-06-30"),
973 dec!(500000), dec!(250000), dec!(275000), dec!(240000), );
978
979 assert_eq!(evm.spi, dec!(1.1000));
981 assert_eq!(evm.cpi, dec!(1.1458));
983
984 assert!(evm.is_ahead_of_schedule());
985 assert!(evm.is_under_budget());
986 assert!(evm.is_healthy());
987
988 assert!(evm.variance_at_completion() > Decimal::ZERO);
990 }
991
992 #[test]
993 fn test_evm_zero_inputs() {
994 let evm = EarnedValueMetric::compute(
996 "EVM-003",
997 "PRJ-003",
998 d("2025-01-01"),
999 dec!(1000000), Decimal::ZERO, Decimal::ZERO, Decimal::ZERO, );
1004
1005 assert_eq!(evm.spi, dec!(1.00));
1007 assert_eq!(evm.cpi, dec!(1.00));
1008 assert_eq!(evm.eac, dec!(1000000));
1009 }
1010
1011 #[test]
1014 fn test_cost_line_serde_roundtrip() {
1015 let line = ProjectCostLine::new(
1016 "PCL-100",
1017 "PRJ-001",
1018 "PRJ-001.01",
1019 "C001",
1020 d("2025-03-15"),
1021 CostCategory::Subcontractor,
1022 CostSourceType::VendorInvoice,
1023 "VI-099",
1024 dec!(25000),
1025 "EUR",
1026 );
1027 let json = serde_json::to_string(&line).unwrap();
1028 let deserialized: ProjectCostLine = serde_json::from_str(&json).unwrap();
1029 assert_eq!(deserialized.id, "PCL-100");
1030 assert_eq!(deserialized.cost_category, CostCategory::Subcontractor);
1031 assert_eq!(deserialized.amount, dec!(25000));
1032 }
1033
1034 #[test]
1035 fn test_evm_serde_roundtrip() {
1036 let evm = EarnedValueMetric::compute(
1037 "EVM-100",
1038 "PRJ-001",
1039 d("2025-06-30"),
1040 dec!(1000000),
1041 dec!(500000),
1042 dec!(400000),
1043 dec!(450000),
1044 );
1045 let json = serde_json::to_string(&evm).unwrap();
1046 let deserialized: EarnedValueMetric = serde_json::from_str(&json).unwrap();
1047 assert_eq!(deserialized.spi, evm.spi);
1048 assert_eq!(deserialized.cpi, evm.cpi);
1049 assert_eq!(deserialized.eac, evm.eac);
1050 }
1051}