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
706impl 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#[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 #[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 #[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 assert_eq!(rev.computed_completion_pct(), dec!(0.5000));
1027 assert_eq!(rev.computed_cumulative_revenue(), dec!(500000.00));
1029 assert_eq!(rev.computed_unbilled_revenue(), dec!(100000.00));
1031 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 #[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 ms.actual_date = Some(d("2025-10-05"));
1087 ms.status = MilestoneStatus::Completed;
1088 assert_eq!(ms.days_variance(), Some(5));
1089
1090 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 #[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 #[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), d("2025-01-15"),
1132 );
1133
1134 ret.add_from_payment(dec!(100000));
1136 ret.add_from_payment(dec!(150000));
1137 ret.add_from_payment(dec!(75000));
1138
1139 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 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 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)); ret.release(dec!(50000), d("2025-12-31"));
1169 assert_eq!(ret.released_amount, dec!(10000.00)); assert_eq!(ret.balance_held(), dec!(0.00));
1171 assert_eq!(ret.status, RetainageStatus::Released);
1172 }
1173
1174 #[test]
1177 fn test_evm_formulas() {
1178 let evm = EarnedValueMetric::compute(
1180 "EVM-001",
1181 "PRJ-001",
1182 d("2025-06-30"),
1183 dec!(1000000), dec!(500000), dec!(400000), dec!(450000), );
1188
1189 assert_eq!(evm.schedule_variance, dec!(-100000.00));
1191 assert_eq!(evm.cost_variance, dec!(-50000.00));
1193 assert_eq!(evm.spi, dec!(0.8000));
1195 assert_eq!(evm.cpi, dec!(0.8889));
1197 let expected_eac = (dec!(1000000) / dec!(0.8889)).round_dp(2);
1199 assert_eq!(evm.eac, expected_eac);
1200 assert_eq!(evm.etc, (evm.eac - dec!(450000)).round_dp(2));
1202 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), dec!(250000), dec!(275000), dec!(240000), );
1221
1222 assert_eq!(evm.spi, dec!(1.1000));
1224 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 assert!(evm.variance_at_completion() > Decimal::ZERO);
1233 }
1234
1235 #[test]
1236 fn test_evm_zero_inputs() {
1237 let evm = EarnedValueMetric::compute(
1239 "EVM-003",
1240 "PRJ-003",
1241 d("2025-01-01"),
1242 dec!(1000000), Decimal::ZERO, Decimal::ZERO, Decimal::ZERO, );
1247
1248 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 #[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}