1use chrono::NaiveDate;
10use rust_decimal::Decimal;
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum GraphEntityType {
21 Company,
23 Vendor,
25 Customer,
27 Employee,
29 Department,
31 CostCenter,
33 Project,
35 Contract,
37 Asset,
39 BankAccount,
41 Material,
43 GlAccount,
45 PurchaseOrder,
47 SalesOrder,
49 Invoice,
51 Payment,
53 SourcingProject,
55 RfxEvent,
57 ProductionOrder,
59 BankReconciliation,
61
62 TaxJurisdiction,
65 TaxCode,
67 TaxLine,
69 TaxReturn,
71 TaxProvision,
73 WithholdingTaxRecord,
75 UncertainTaxPosition,
77
78 CashPosition,
81 CashForecast,
83 CashPool,
85 CashPoolSweep,
87 HedgingInstrument,
89 HedgeRelationship,
91 DebtInstrument,
93 DebtCovenant,
95
96 EmissionRecord,
99 EnergyConsumption,
101 WaterUsage,
103 WasteRecord,
105 WorkforceDiversityMetric,
107 PayEquityMetric,
109 SafetyIncident,
111 SafetyMetric,
113 GovernanceMetric,
115 SupplierEsgAssessment,
117 MaterialityAssessment,
119 EsgDisclosure,
121 ClimateScenario,
123
124 ProjectCostLine,
127 ProjectRevenue,
129 EarnedValueMetric,
131 ChangeOrder,
133 ProjectMilestone,
135
136 SupplierBid,
139 BidEvaluation,
141 ProcurementContract,
143 SupplierQualification,
145
146 PayrollRun,
149 TimeEntry,
151 ExpenseReport,
153 BenefitEnrollment,
155
156 QualityInspection,
159 CycleCount,
161 BomComponent,
163 InventoryMovement,
165
166 CosoComponent,
169 CosoPrinciple,
171 SoxAssertion,
173 AuditEngagement,
175 ProfessionalJudgment,
177}
178
179impl GraphEntityType {
180 pub fn code(&self) -> &'static str {
182 match self {
183 Self::Company => "CO",
184 Self::Vendor => "VN",
185 Self::Customer => "CU",
186 Self::Employee => "EM",
187 Self::Department => "DP",
188 Self::CostCenter => "CC",
189 Self::Project => "PJ",
190 Self::Contract => "CT",
191 Self::Asset => "AS",
192 Self::BankAccount => "BA",
193 Self::Material => "MT",
194 Self::GlAccount => "GL",
195 Self::PurchaseOrder => "PO",
196 Self::SalesOrder => "SO",
197 Self::Invoice => "IV",
198 Self::Payment => "PM",
199 Self::SourcingProject => "SP",
200 Self::RfxEvent => "RX",
201 Self::ProductionOrder => "PR",
202 Self::BankReconciliation => "BR",
203 Self::TaxJurisdiction => "TJ",
205 Self::TaxCode => "TC",
206 Self::TaxLine => "TL",
207 Self::TaxReturn => "TR",
208 Self::TaxProvision => "TP",
209 Self::WithholdingTaxRecord => "WH",
210 Self::UncertainTaxPosition => "UT",
211 Self::CashPosition => "CP",
213 Self::CashForecast => "CF",
214 Self::CashPool => "CL",
215 Self::CashPoolSweep => "CS",
216 Self::HedgingInstrument => "HI",
217 Self::HedgeRelationship => "HR",
218 Self::DebtInstrument => "DI",
219 Self::DebtCovenant => "DC",
220 Self::EmissionRecord => "ER",
222 Self::EnergyConsumption => "EC",
223 Self::WaterUsage => "WU",
224 Self::WasteRecord => "WR",
225 Self::WorkforceDiversityMetric => "WD",
226 Self::PayEquityMetric => "PE",
227 Self::SafetyIncident => "SI",
228 Self::SafetyMetric => "SM",
229 Self::GovernanceMetric => "GM",
230 Self::SupplierEsgAssessment => "SE",
231 Self::MaterialityAssessment => "MA",
232 Self::EsgDisclosure => "ED",
233 Self::ClimateScenario => "CZ",
234 Self::ProjectCostLine => "PL",
236 Self::ProjectRevenue => "PV",
237 Self::EarnedValueMetric => "EV",
238 Self::ChangeOrder => "CR",
239 Self::ProjectMilestone => "MS",
240 Self::SupplierBid => "BD",
242 Self::BidEvaluation => "BE",
243 Self::ProcurementContract => "PC",
244 Self::SupplierQualification => "SQ",
245 Self::PayrollRun => "PY",
247 Self::TimeEntry => "TE",
248 Self::ExpenseReport => "EX",
249 Self::BenefitEnrollment => "BN",
250 Self::QualityInspection => "QI",
252 Self::CycleCount => "CY",
253 Self::BomComponent => "BC",
254 Self::InventoryMovement => "IM",
255 Self::CosoComponent => "GC",
257 Self::CosoPrinciple => "GP",
258 Self::SoxAssertion => "SA",
259 Self::AuditEngagement => "AE",
260 Self::ProfessionalJudgment => "JD",
261 }
262 }
263
264 pub fn numeric_code(&self) -> u16 {
266 match self {
267 Self::Company => 100,
269 Self::Vendor => 101,
270 Self::Material => 102,
271 Self::Customer => 103,
272 Self::Employee => 104,
273 Self::InventoryMovement => 105,
274 Self::GlAccount => 106,
275 Self::Department => 107,
276 Self::CostCenter => 108,
277 Self::Project => 109,
278 Self::Contract => 110,
279 Self::Asset => 111,
280 Self::BankAccount => 112,
281 Self::PurchaseOrder => 200,
282 Self::SalesOrder => 201,
283 Self::Invoice => 202,
284 Self::Payment => 203,
285 Self::BankReconciliation => 210,
286 Self::SourcingProject => 320,
288 Self::RfxEvent => 321,
289 Self::SupplierBid => 322,
290 Self::BidEvaluation => 323,
291 Self::ProcurementContract => 324,
292 Self::SupplierQualification => 325,
293 Self::PayrollRun => 330,
295 Self::TimeEntry => 331,
296 Self::ExpenseReport => 332,
297 Self::BenefitEnrollment => 333,
298 Self::ProductionOrder => 340,
300 Self::QualityInspection => 341,
301 Self::CycleCount => 342,
302 Self::BomComponent => 343,
303 Self::TaxJurisdiction => 410,
305 Self::TaxCode => 411,
306 Self::TaxLine => 412,
307 Self::TaxReturn => 413,
308 Self::TaxProvision => 414,
309 Self::WithholdingTaxRecord => 415,
310 Self::UncertainTaxPosition => 416,
311 Self::CashPosition => 420,
313 Self::CashForecast => 421,
314 Self::CashPool => 422,
315 Self::CashPoolSweep => 423,
316 Self::HedgingInstrument => 424,
317 Self::HedgeRelationship => 425,
318 Self::DebtInstrument => 426,
319 Self::DebtCovenant => 427,
320 Self::EmissionRecord => 430,
322 Self::EnergyConsumption => 431,
323 Self::WaterUsage => 432,
324 Self::WasteRecord => 433,
325 Self::WorkforceDiversityMetric => 434,
326 Self::PayEquityMetric => 435,
327 Self::SafetyIncident => 436,
328 Self::SafetyMetric => 437,
329 Self::GovernanceMetric => 438,
330 Self::SupplierEsgAssessment => 439,
331 Self::MaterialityAssessment => 440,
332 Self::EsgDisclosure => 441,
333 Self::ClimateScenario => 442,
334 Self::ProjectCostLine => 451,
336 Self::ProjectRevenue => 452,
337 Self::EarnedValueMetric => 453,
338 Self::ChangeOrder => 454,
339 Self::ProjectMilestone => 455,
340 Self::CosoComponent => 500,
342 Self::CosoPrinciple => 501,
343 Self::SoxAssertion => 502,
344 Self::AuditEngagement => 360,
345 Self::ProfessionalJudgment => 365,
346 }
347 }
348
349 pub fn node_type_name(&self) -> &'static str {
351 match self {
352 Self::Company => "company",
353 Self::Vendor => "vendor",
354 Self::Customer => "customer",
355 Self::Employee => "employee",
356 Self::Department => "department",
357 Self::CostCenter => "cost_center",
358 Self::Project => "project",
359 Self::Contract => "contract",
360 Self::Asset => "asset",
361 Self::BankAccount => "bank_account",
362 Self::Material => "material",
363 Self::GlAccount => "gl_account",
364 Self::PurchaseOrder => "purchase_order",
365 Self::SalesOrder => "sales_order",
366 Self::Invoice => "invoice",
367 Self::Payment => "payment",
368 Self::SourcingProject => "sourcing_project",
369 Self::RfxEvent => "rfx_event",
370 Self::ProductionOrder => "production_order",
371 Self::BankReconciliation => "bank_reconciliation",
372 Self::TaxJurisdiction => "tax_jurisdiction",
374 Self::TaxCode => "tax_code",
375 Self::TaxLine => "tax_line",
376 Self::TaxReturn => "tax_return",
377 Self::TaxProvision => "tax_provision",
378 Self::WithholdingTaxRecord => "withholding_tax_record",
379 Self::UncertainTaxPosition => "uncertain_tax_position",
380 Self::CashPosition => "cash_position",
382 Self::CashForecast => "cash_forecast",
383 Self::CashPool => "cash_pool",
384 Self::CashPoolSweep => "cash_pool_sweep",
385 Self::HedgingInstrument => "hedging_instrument",
386 Self::HedgeRelationship => "hedge_relationship",
387 Self::DebtInstrument => "debt_instrument",
388 Self::DebtCovenant => "debt_covenant",
389 Self::EmissionRecord => "emission_record",
391 Self::EnergyConsumption => "energy_consumption",
392 Self::WaterUsage => "water_usage",
393 Self::WasteRecord => "waste_record",
394 Self::WorkforceDiversityMetric => "workforce_diversity_metric",
395 Self::PayEquityMetric => "pay_equity_metric",
396 Self::SafetyIncident => "safety_incident",
397 Self::SafetyMetric => "safety_metric",
398 Self::GovernanceMetric => "governance_metric",
399 Self::SupplierEsgAssessment => "supplier_esg_assessment",
400 Self::MaterialityAssessment => "materiality_assessment",
401 Self::EsgDisclosure => "esg_disclosure",
402 Self::ClimateScenario => "climate_scenario",
403 Self::ProjectCostLine => "project_cost_line",
405 Self::ProjectRevenue => "project_revenue",
406 Self::EarnedValueMetric => "earned_value_metric",
407 Self::ChangeOrder => "change_order",
408 Self::ProjectMilestone => "project_milestone",
409 Self::SupplierBid => "supplier_bid",
411 Self::BidEvaluation => "bid_evaluation",
412 Self::ProcurementContract => "procurement_contract",
413 Self::SupplierQualification => "supplier_qualification",
414 Self::PayrollRun => "payroll_run",
416 Self::TimeEntry => "time_entry",
417 Self::ExpenseReport => "expense_report",
418 Self::BenefitEnrollment => "benefit_enrollment",
419 Self::QualityInspection => "quality_inspection",
421 Self::CycleCount => "cycle_count",
422 Self::BomComponent => "bom_component",
423 Self::InventoryMovement => "inventory_movement",
424 Self::CosoComponent => "coso_component",
426 Self::CosoPrinciple => "coso_principle",
427 Self::SoxAssertion => "sox_assertion",
428 Self::AuditEngagement => "audit_engagement",
429 Self::ProfessionalJudgment => "professional_judgment",
430 }
431 }
432
433 pub fn from_numeric_code(code: u16) -> Option<Self> {
435 Self::all_types()
436 .iter()
437 .find(|t| t.numeric_code() == code)
438 .copied()
439 }
440
441 pub fn from_node_type_name(name: &str) -> Option<Self> {
443 Self::all_types()
444 .iter()
445 .find(|t| t.node_type_name() == name)
446 .copied()
447 }
448
449 pub fn all_types() -> &'static [GraphEntityType] {
451 &[
452 Self::Company,
454 Self::Vendor,
455 Self::Customer,
456 Self::Employee,
457 Self::Department,
458 Self::CostCenter,
459 Self::Project,
460 Self::Contract,
461 Self::Asset,
462 Self::BankAccount,
463 Self::Material,
464 Self::GlAccount,
465 Self::PurchaseOrder,
466 Self::SalesOrder,
467 Self::Invoice,
468 Self::Payment,
469 Self::SourcingProject,
470 Self::RfxEvent,
471 Self::ProductionOrder,
472 Self::BankReconciliation,
473 Self::TaxJurisdiction,
475 Self::TaxCode,
476 Self::TaxLine,
477 Self::TaxReturn,
478 Self::TaxProvision,
479 Self::WithholdingTaxRecord,
480 Self::UncertainTaxPosition,
481 Self::CashPosition,
483 Self::CashForecast,
484 Self::CashPool,
485 Self::CashPoolSweep,
486 Self::HedgingInstrument,
487 Self::HedgeRelationship,
488 Self::DebtInstrument,
489 Self::DebtCovenant,
490 Self::EmissionRecord,
492 Self::EnergyConsumption,
493 Self::WaterUsage,
494 Self::WasteRecord,
495 Self::WorkforceDiversityMetric,
496 Self::PayEquityMetric,
497 Self::SafetyIncident,
498 Self::SafetyMetric,
499 Self::GovernanceMetric,
500 Self::SupplierEsgAssessment,
501 Self::MaterialityAssessment,
502 Self::EsgDisclosure,
503 Self::ClimateScenario,
504 Self::ProjectCostLine,
506 Self::ProjectRevenue,
507 Self::EarnedValueMetric,
508 Self::ChangeOrder,
509 Self::ProjectMilestone,
510 Self::SupplierBid,
512 Self::BidEvaluation,
513 Self::ProcurementContract,
514 Self::SupplierQualification,
515 Self::PayrollRun,
517 Self::TimeEntry,
518 Self::ExpenseReport,
519 Self::BenefitEnrollment,
520 Self::QualityInspection,
522 Self::CycleCount,
523 Self::BomComponent,
524 Self::InventoryMovement,
525 Self::CosoComponent,
527 Self::CosoPrinciple,
528 Self::SoxAssertion,
529 Self::AuditEngagement,
530 Self::ProfessionalJudgment,
531 ]
532 }
533
534 pub fn is_tax(&self) -> bool {
536 matches!(
537 self,
538 Self::TaxJurisdiction
539 | Self::TaxCode
540 | Self::TaxLine
541 | Self::TaxReturn
542 | Self::TaxProvision
543 | Self::WithholdingTaxRecord
544 | Self::UncertainTaxPosition
545 )
546 }
547
548 pub fn is_treasury(&self) -> bool {
550 matches!(
551 self,
552 Self::CashPosition
553 | Self::CashForecast
554 | Self::CashPool
555 | Self::CashPoolSweep
556 | Self::HedgingInstrument
557 | Self::HedgeRelationship
558 | Self::DebtInstrument
559 | Self::DebtCovenant
560 )
561 }
562
563 pub fn is_esg(&self) -> bool {
565 matches!(
566 self,
567 Self::EmissionRecord
568 | Self::EnergyConsumption
569 | Self::WaterUsage
570 | Self::WasteRecord
571 | Self::WorkforceDiversityMetric
572 | Self::PayEquityMetric
573 | Self::SafetyIncident
574 | Self::SafetyMetric
575 | Self::GovernanceMetric
576 | Self::SupplierEsgAssessment
577 | Self::MaterialityAssessment
578 | Self::EsgDisclosure
579 | Self::ClimateScenario
580 )
581 }
582
583 pub fn is_project(&self) -> bool {
585 matches!(
586 self,
587 Self::Project
588 | Self::ProjectCostLine
589 | Self::ProjectRevenue
590 | Self::EarnedValueMetric
591 | Self::ChangeOrder
592 | Self::ProjectMilestone
593 )
594 }
595
596 pub fn is_h2r(&self) -> bool {
598 matches!(
599 self,
600 Self::PayrollRun | Self::TimeEntry | Self::ExpenseReport | Self::BenefitEnrollment
601 )
602 }
603
604 pub fn is_mfg(&self) -> bool {
606 matches!(
607 self,
608 Self::ProductionOrder
609 | Self::QualityInspection
610 | Self::CycleCount
611 | Self::BomComponent
612 | Self::Material
613 | Self::InventoryMovement
614 )
615 }
616
617 pub fn is_governance(&self) -> bool {
619 matches!(
620 self,
621 Self::CosoComponent
622 | Self::CosoPrinciple
623 | Self::SoxAssertion
624 | Self::AuditEngagement
625 | Self::ProfessionalJudgment
626 )
627 }
628
629 pub fn is_master_data(&self) -> bool {
631 matches!(
632 self,
633 Self::Company
634 | Self::Vendor
635 | Self::Customer
636 | Self::Employee
637 | Self::Department
638 | Self::CostCenter
639 | Self::Material
640 | Self::GlAccount
641 | Self::TaxJurisdiction
642 | Self::TaxCode
643 | Self::BankAccount
644 )
645 }
646
647 pub fn is_transactional(&self) -> bool {
649 matches!(
650 self,
651 Self::PurchaseOrder
652 | Self::SalesOrder
653 | Self::Invoice
654 | Self::Payment
655 | Self::TaxLine
656 | Self::TaxReturn
657 | Self::CashPoolSweep
658 | Self::InventoryMovement
659 )
660 }
661}
662
663#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
665#[serde(rename_all = "snake_case")]
666pub enum RelationshipType {
667 BuysFrom,
670 SellsTo,
672 PaysTo,
674 ReceivesFrom,
676 SuppliesTo,
678 SourcesFrom,
680
681 ReportsTo,
684 Manages,
686 BelongsTo,
688 OwnedBy,
690 WorksIn,
692 ResponsibleFor,
694
695 ReferredBy,
698 PartnersWith,
700 AffiliatedWith,
702 Intercompany,
704
705 References,
708 ReferencedBy,
710 Fulfills,
712 FulfilledBy,
714 AppliesTo,
716 AppliedBy,
718
719 InventoryLink,
722 UsedIn,
724 SourcedVia,
726
727 AwardedTo,
730 GovernsOrder,
732 EvaluatedBy,
734 QualifiedAs,
736 ScoredBy,
738 SourcedThrough,
740 CatalogItemOf,
742
743 ProducedBy,
746
747 ReconciledWith,
750
751 PlacedWith,
754 MatchesOrder,
756 PaysInvoice,
758
759 PlacedBy,
762 BillsOrder,
764
765 RfxBelongsToProject,
768 RespondsTo,
770 AwardedFrom,
772
773 RecordedBy,
776 PayrollIncludes,
778 SubmittedBy,
780 EnrolledBy,
782
783 Produces,
786 Inspects,
788 PartOf,
790
791 TaxLineBelongsTo,
794 ProvisionAppliesTo,
796 WithheldFrom,
798
799 SweepsTo,
802 HedgesInstrument,
804 GovernsInstrument,
806
807 EmissionReportedBy,
810 AssessesSupplier,
812
813 CostChargedTo,
816 MilestoneOf,
818 ModifiesProject,
820
821 PrincipleUnder,
824 AssertionCovers,
826 JudgmentWithin,
828}
829
830impl RelationshipType {
831 pub fn code(&self) -> &'static str {
833 match self {
834 Self::BuysFrom => "BF",
835 Self::SellsTo => "ST",
836 Self::PaysTo => "PT",
837 Self::ReceivesFrom => "RF",
838 Self::SuppliesTo => "SP",
839 Self::SourcesFrom => "SF",
840 Self::ReportsTo => "RT",
841 Self::Manages => "MG",
842 Self::BelongsTo => "BT",
843 Self::OwnedBy => "OB",
844 Self::WorksIn => "WI",
845 Self::ResponsibleFor => "RS",
846 Self::ReferredBy => "RB",
847 Self::PartnersWith => "PW",
848 Self::AffiliatedWith => "AW",
849 Self::Intercompany => "IC",
850 Self::References => "REF",
851 Self::ReferencedBy => "RBY",
852 Self::Fulfills => "FL",
853 Self::FulfilledBy => "FLB",
854 Self::AppliesTo => "AP",
855 Self::AppliedBy => "APB",
856 Self::InventoryLink => "INV",
857 Self::UsedIn => "UI",
858 Self::SourcedVia => "SV",
859 Self::AwardedTo => "AT",
860 Self::GovernsOrder => "GO",
861 Self::EvaluatedBy => "EB",
862 Self::QualifiedAs => "QA",
863 Self::ScoredBy => "SB",
864 Self::SourcedThrough => "STH",
865 Self::CatalogItemOf => "CIO",
866 Self::ProducedBy => "PB",
867 Self::ReconciledWith => "RW",
868 Self::PlacedWith => "PWI",
870 Self::MatchesOrder => "MO",
871 Self::PaysInvoice => "PI",
872 Self::PlacedBy => "PLB",
874 Self::BillsOrder => "BO",
875 Self::RfxBelongsToProject => "RBP",
877 Self::RespondsTo => "RTO",
878 Self::AwardedFrom => "AFR",
879 Self::RecordedBy => "RCB",
881 Self::PayrollIncludes => "PYI",
882 Self::SubmittedBy => "SUB",
883 Self::EnrolledBy => "ENB",
884 Self::Produces => "PRD",
886 Self::Inspects => "INS",
887 Self::PartOf => "POF",
888 Self::TaxLineBelongsTo => "TLB",
890 Self::ProvisionAppliesTo => "PAT",
891 Self::WithheldFrom => "WHF",
892 Self::SweepsTo => "SWT",
894 Self::HedgesInstrument => "HDG",
895 Self::GovernsInstrument => "GVI",
896 Self::EmissionReportedBy => "ERB",
898 Self::AssessesSupplier => "ASS",
899 Self::CostChargedTo => "CCT",
901 Self::MilestoneOf => "MLO",
902 Self::ModifiesProject => "MPJ",
903 Self::PrincipleUnder => "PUN",
905 Self::AssertionCovers => "ACO",
906 Self::JudgmentWithin => "JWI",
907 }
908 }
909
910 pub fn inverse(&self) -> Self {
912 match self {
913 Self::BuysFrom => Self::SellsTo,
914 Self::SellsTo => Self::BuysFrom,
915 Self::PaysTo => Self::ReceivesFrom,
916 Self::ReceivesFrom => Self::PaysTo,
917 Self::SuppliesTo => Self::SourcesFrom,
918 Self::SourcesFrom => Self::SuppliesTo,
919 Self::ReportsTo => Self::Manages,
920 Self::Manages => Self::ReportsTo,
921 Self::BelongsTo => Self::OwnedBy,
922 Self::OwnedBy => Self::BelongsTo,
923 Self::References => Self::ReferencedBy,
924 Self::ReferencedBy => Self::References,
925 Self::Fulfills => Self::FulfilledBy,
926 Self::FulfilledBy => Self::Fulfills,
927 Self::AppliesTo => Self::AppliedBy,
928 Self::AppliedBy => Self::AppliesTo,
929 Self::WorksIn => Self::WorksIn,
931 Self::ResponsibleFor => Self::ResponsibleFor,
932 Self::ReferredBy => Self::ReferredBy,
933 Self::PartnersWith => Self::PartnersWith,
934 Self::AffiliatedWith => Self::AffiliatedWith,
935 Self::Intercompany => Self::Intercompany,
936 Self::InventoryLink => Self::InventoryLink,
937 Self::UsedIn => Self::UsedIn,
938 Self::SourcedVia => Self::SourcedVia,
939 Self::AwardedTo => Self::AwardedTo,
941 Self::GovernsOrder => Self::GovernsOrder,
942 Self::EvaluatedBy => Self::EvaluatedBy,
943 Self::QualifiedAs => Self::QualifiedAs,
944 Self::ScoredBy => Self::ScoredBy,
945 Self::SourcedThrough => Self::SourcedThrough,
946 Self::CatalogItemOf => Self::CatalogItemOf,
947 Self::ProducedBy => Self::ProducedBy,
948 Self::ReconciledWith => Self::ReconciledWith,
949 Self::PlacedWith => Self::PlacedWith,
951 Self::MatchesOrder => Self::MatchesOrder,
952 Self::PaysInvoice => Self::PaysInvoice,
953 Self::PlacedBy => Self::PlacedBy,
954 Self::BillsOrder => Self::BillsOrder,
955 Self::RfxBelongsToProject => Self::RfxBelongsToProject,
956 Self::RespondsTo => Self::RespondsTo,
957 Self::AwardedFrom => Self::AwardedFrom,
958 Self::RecordedBy => Self::RecordedBy,
959 Self::PayrollIncludes => Self::PayrollIncludes,
960 Self::SubmittedBy => Self::SubmittedBy,
961 Self::EnrolledBy => Self::EnrolledBy,
962 Self::Produces => Self::Produces,
963 Self::Inspects => Self::Inspects,
964 Self::PartOf => Self::PartOf,
965 Self::TaxLineBelongsTo => Self::TaxLineBelongsTo,
966 Self::ProvisionAppliesTo => Self::ProvisionAppliesTo,
967 Self::WithheldFrom => Self::WithheldFrom,
968 Self::SweepsTo => Self::SweepsTo,
969 Self::HedgesInstrument => Self::HedgesInstrument,
970 Self::GovernsInstrument => Self::GovernsInstrument,
971 Self::EmissionReportedBy => Self::EmissionReportedBy,
972 Self::AssessesSupplier => Self::AssessesSupplier,
973 Self::CostChargedTo => Self::CostChargedTo,
974 Self::MilestoneOf => Self::MilestoneOf,
975 Self::ModifiesProject => Self::ModifiesProject,
976 Self::PrincipleUnder => Self::PrincipleUnder,
977 Self::AssertionCovers => Self::AssertionCovers,
978 Self::JudgmentWithin => Self::JudgmentWithin,
979 }
980 }
981
982 pub fn is_transactional(&self) -> bool {
984 matches!(
985 self,
986 Self::BuysFrom
987 | Self::SellsTo
988 | Self::PaysTo
989 | Self::ReceivesFrom
990 | Self::SuppliesTo
991 | Self::SourcesFrom
992 )
993 }
994
995 pub fn is_organizational(&self) -> bool {
997 matches!(
998 self,
999 Self::ReportsTo
1000 | Self::Manages
1001 | Self::BelongsTo
1002 | Self::OwnedBy
1003 | Self::WorksIn
1004 | Self::ResponsibleFor
1005 )
1006 }
1007
1008 pub fn is_document(&self) -> bool {
1010 matches!(
1011 self,
1012 Self::References
1013 | Self::ReferencedBy
1014 | Self::Fulfills
1015 | Self::FulfilledBy
1016 | Self::AppliesTo
1017 | Self::AppliedBy
1018 )
1019 }
1020
1021 pub fn constraint(&self) -> Option<EdgeConstraint> {
1023 let c = |src: GraphEntityType, tgt: GraphEntityType, card: Cardinality| EdgeConstraint {
1024 relationship_type: *self,
1025 source_type: src,
1026 target_type: tgt,
1027 cardinality: card,
1028 edge_properties: &[],
1029 };
1030 use Cardinality::*;
1031 use GraphEntityType as E;
1032 match self {
1033 Self::PlacedWith => Some(c(E::PurchaseOrder, E::Vendor, ManyToOne)),
1035 Self::MatchesOrder => Some(c(E::Invoice, E::PurchaseOrder, ManyToOne)),
1036 Self::PaysInvoice => Some(c(E::Payment, E::Invoice, ManyToMany)),
1037 Self::PlacedBy => Some(c(E::SalesOrder, E::Customer, ManyToOne)),
1039 Self::BillsOrder => Some(c(E::Invoice, E::SalesOrder, ManyToOne)),
1040 Self::RfxBelongsToProject => Some(c(E::RfxEvent, E::SourcingProject, ManyToOne)),
1042 Self::RespondsTo => Some(c(E::SupplierBid, E::RfxEvent, ManyToOne)),
1043 Self::AwardedFrom => Some(c(E::ProcurementContract, E::BidEvaluation, OneToOne)),
1044 Self::RecordedBy => Some(c(E::TimeEntry, E::Employee, ManyToOne)),
1046 Self::PayrollIncludes => Some(c(E::PayrollRun, E::Employee, ManyToMany)),
1047 Self::SubmittedBy => Some(c(E::ExpenseReport, E::Employee, ManyToOne)),
1048 Self::EnrolledBy => Some(c(E::BenefitEnrollment, E::Employee, ManyToOne)),
1049 Self::Produces => Some(c(E::ProductionOrder, E::Material, ManyToOne)),
1051 Self::Inspects => Some(c(E::QualityInspection, E::ProductionOrder, ManyToOne)),
1052 Self::PartOf => Some(c(E::BomComponent, E::Material, ManyToOne)),
1053 Self::TaxLineBelongsTo => Some(c(E::TaxLine, E::TaxReturn, ManyToOne)),
1055 Self::ProvisionAppliesTo => Some(c(E::TaxProvision, E::TaxJurisdiction, ManyToOne)),
1056 Self::WithheldFrom => Some(c(E::WithholdingTaxRecord, E::Vendor, ManyToOne)),
1057 Self::SweepsTo => Some(c(E::CashPoolSweep, E::CashPool, ManyToOne)),
1059 Self::HedgesInstrument => {
1060 Some(c(E::HedgeRelationship, E::HedgingInstrument, ManyToOne))
1061 }
1062 Self::GovernsInstrument => Some(c(E::DebtCovenant, E::DebtInstrument, ManyToOne)),
1063 Self::EmissionReportedBy => Some(c(E::EmissionRecord, E::Company, ManyToOne)),
1065 Self::AssessesSupplier => Some(c(E::SupplierEsgAssessment, E::Vendor, ManyToOne)),
1066 Self::CostChargedTo => Some(c(E::ProjectCostLine, E::Project, ManyToOne)),
1068 Self::MilestoneOf => Some(c(E::ProjectMilestone, E::Project, ManyToOne)),
1069 Self::ModifiesProject => Some(c(E::ChangeOrder, E::Project, ManyToOne)),
1070 Self::PrincipleUnder => Some(c(E::CosoPrinciple, E::CosoComponent, ManyToOne)),
1072 Self::AssertionCovers => Some(c(E::SoxAssertion, E::GlAccount, ManyToMany)),
1073 Self::JudgmentWithin => Some(c(E::ProfessionalJudgment, E::AuditEngagement, ManyToOne)),
1074 _ => None,
1076 }
1077 }
1078
1079 pub fn all_constraints() -> Vec<EdgeConstraint> {
1081 let all_types = [
1082 Self::PlacedWith,
1083 Self::MatchesOrder,
1084 Self::PaysInvoice,
1085 Self::PlacedBy,
1086 Self::BillsOrder,
1087 Self::RfxBelongsToProject,
1088 Self::RespondsTo,
1089 Self::AwardedFrom,
1090 Self::RecordedBy,
1091 Self::PayrollIncludes,
1092 Self::SubmittedBy,
1093 Self::EnrolledBy,
1094 Self::Produces,
1095 Self::Inspects,
1096 Self::PartOf,
1097 Self::TaxLineBelongsTo,
1098 Self::ProvisionAppliesTo,
1099 Self::WithheldFrom,
1100 Self::SweepsTo,
1101 Self::HedgesInstrument,
1102 Self::GovernsInstrument,
1103 Self::EmissionReportedBy,
1104 Self::AssessesSupplier,
1105 Self::CostChargedTo,
1106 Self::MilestoneOf,
1107 Self::ModifiesProject,
1108 Self::PrincipleUnder,
1109 Self::AssertionCovers,
1110 Self::JudgmentWithin,
1111 ];
1112 all_types
1113 .iter()
1114 .filter_map(RelationshipType::constraint)
1115 .collect()
1116 }
1117}
1118
1119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1121pub enum Cardinality {
1122 OneToOne,
1123 ManyToOne,
1124 ManyToMany,
1125}
1126
1127#[derive(Debug, Clone)]
1129pub struct EdgeConstraint {
1130 pub relationship_type: RelationshipType,
1131 pub source_type: GraphEntityType,
1132 pub target_type: GraphEntityType,
1133 pub cardinality: Cardinality,
1134 pub edge_properties: &'static [&'static str],
1135}
1136
1137#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1142pub struct GraphEntityId {
1143 pub entity_type: GraphEntityType,
1145 pub id: String,
1147}
1148
1149impl GraphEntityId {
1150 pub fn new(entity_type: GraphEntityType, id: impl Into<String>) -> Self {
1152 Self {
1153 entity_type,
1154 id: id.into(),
1155 }
1156 }
1157
1158 pub fn key(&self) -> String {
1160 format!("{}:{}", self.entity_type.code(), self.id)
1161 }
1162}
1163
1164#[derive(Debug, Clone, Serialize, Deserialize)]
1166pub struct EntityNode {
1167 pub entity_id: GraphEntityId,
1169 pub name: String,
1171 pub attributes: HashMap<String, String>,
1173 pub created_date: NaiveDate,
1175 pub is_active: bool,
1177 pub company_code: Option<String>,
1179}
1180
1181impl EntityNode {
1182 pub fn new(entity_id: GraphEntityId, name: impl Into<String>, created_date: NaiveDate) -> Self {
1184 Self {
1185 entity_id,
1186 name: name.into(),
1187 attributes: HashMap::new(),
1188 created_date,
1189 is_active: true,
1190 company_code: None,
1191 }
1192 }
1193
1194 pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1196 self.attributes.insert(key.into(), value.into());
1197 self
1198 }
1199
1200 pub fn with_company(mut self, company_code: impl Into<String>) -> Self {
1202 self.company_code = Some(company_code.into());
1203 self
1204 }
1205}
1206
1207#[derive(Debug, Clone, Serialize, Deserialize)]
1209pub struct RelationshipEdge {
1210 pub from_id: GraphEntityId,
1212 pub to_id: GraphEntityId,
1214 pub relationship_type: RelationshipType,
1216 pub strength: f64,
1218 pub start_date: NaiveDate,
1220 pub end_date: Option<NaiveDate>,
1222 pub attributes: HashMap<String, String>,
1224 pub strength_components: Option<StrengthComponents>,
1226}
1227
1228impl RelationshipEdge {
1229 pub fn new(
1231 from_id: GraphEntityId,
1232 to_id: GraphEntityId,
1233 relationship_type: RelationshipType,
1234 start_date: NaiveDate,
1235 ) -> Self {
1236 Self {
1237 from_id,
1238 to_id,
1239 relationship_type,
1240 strength: 0.5, start_date,
1242 end_date: None,
1243 attributes: HashMap::new(),
1244 strength_components: None,
1245 }
1246 }
1247
1248 pub fn with_strength(mut self, strength: f64) -> Self {
1250 self.strength = strength.clamp(0.0, 1.0);
1251 self
1252 }
1253
1254 pub fn with_strength_components(mut self, components: StrengthComponents) -> Self {
1256 self.strength = components.total();
1257 self.strength_components = Some(components);
1258 self
1259 }
1260
1261 pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1263 self.attributes.insert(key.into(), value.into());
1264 self
1265 }
1266
1267 pub fn is_active(&self) -> bool {
1269 self.end_date.is_none()
1270 }
1271
1272 pub fn key(&self) -> String {
1274 format!(
1275 "{}->{}:{}",
1276 self.from_id.key(),
1277 self.to_id.key(),
1278 self.relationship_type.code()
1279 )
1280 }
1281}
1282
1283#[derive(Debug, Clone, Serialize, Deserialize)]
1285pub struct StrengthComponents {
1286 pub transaction_volume: f64,
1288 pub transaction_count: f64,
1290 pub duration: f64,
1292 pub recency: f64,
1294 pub mutual_connections: f64,
1296}
1297
1298impl StrengthComponents {
1299 pub fn new(
1301 transaction_volume: f64,
1302 transaction_count: f64,
1303 duration: f64,
1304 recency: f64,
1305 mutual_connections: f64,
1306 ) -> Self {
1307 Self {
1308 transaction_volume: transaction_volume.clamp(0.0, 1.0),
1309 transaction_count: transaction_count.clamp(0.0, 1.0),
1310 duration: duration.clamp(0.0, 1.0),
1311 recency: recency.clamp(0.0, 1.0),
1312 mutual_connections: mutual_connections.clamp(0.0, 1.0),
1313 }
1314 }
1315
1316 pub fn total(&self) -> f64 {
1318 self.total_weighted(RelationshipStrengthCalculator::default_weights())
1319 }
1320
1321 pub fn total_weighted(&self, weights: &StrengthWeights) -> f64 {
1323 let total = self.transaction_volume * weights.transaction_volume_weight
1324 + self.transaction_count * weights.transaction_count_weight
1325 + self.duration * weights.duration_weight
1326 + self.recency * weights.recency_weight
1327 + self.mutual_connections * weights.mutual_connections_weight;
1328
1329 total.clamp(0.0, 1.0)
1330 }
1331}
1332
1333#[derive(Debug, Clone, Serialize, Deserialize)]
1335pub struct StrengthWeights {
1336 pub transaction_volume_weight: f64,
1338 pub transaction_count_weight: f64,
1340 pub duration_weight: f64,
1342 pub recency_weight: f64,
1344 pub mutual_connections_weight: f64,
1346}
1347
1348impl Default for StrengthWeights {
1349 fn default() -> Self {
1350 Self {
1351 transaction_volume_weight: 0.30,
1352 transaction_count_weight: 0.25,
1353 duration_weight: 0.20,
1354 recency_weight: 0.15,
1355 mutual_connections_weight: 0.10,
1356 }
1357 }
1358}
1359
1360impl StrengthWeights {
1361 pub fn validate(&self) -> Result<(), String> {
1363 let sum = self.transaction_volume_weight
1364 + self.transaction_count_weight
1365 + self.duration_weight
1366 + self.recency_weight
1367 + self.mutual_connections_weight;
1368
1369 if (sum - 1.0).abs() > 0.01 {
1370 Err(format!("Strength weights must sum to 1.0, got {sum}"))
1371 } else {
1372 Ok(())
1373 }
1374 }
1375}
1376
1377#[derive(Debug, Clone, Serialize, Deserialize)]
1379pub struct RelationshipStrengthCalculator {
1380 pub weights: StrengthWeights,
1382 pub recency_half_life_days: u32,
1384 pub max_transaction_volume: Decimal,
1386 pub max_transaction_count: u32,
1388 pub max_duration_days: u32,
1390}
1391
1392impl Default for RelationshipStrengthCalculator {
1393 fn default() -> Self {
1394 Self {
1395 weights: StrengthWeights::default(),
1396 recency_half_life_days: 90,
1397 max_transaction_volume: Decimal::from(10_000_000),
1398 max_transaction_count: 1000,
1399 max_duration_days: 3650, }
1401 }
1402}
1403
1404impl RelationshipStrengthCalculator {
1405 pub fn default_weights() -> &'static StrengthWeights {
1407 static WEIGHTS: std::sync::OnceLock<StrengthWeights> = std::sync::OnceLock::new();
1408 WEIGHTS.get_or_init(StrengthWeights::default)
1409 }
1410
1411 pub fn calculate(
1413 &self,
1414 transaction_volume: Decimal,
1415 transaction_count: u32,
1416 relationship_days: u32,
1417 days_since_last_transaction: u32,
1418 mutual_connections: usize,
1419 total_possible_connections: usize,
1420 ) -> StrengthComponents {
1421 let volume_normalized = if transaction_volume > Decimal::ZERO
1423 && self.max_transaction_volume > Decimal::ZERO
1424 {
1425 let log_vol = (transaction_volume.to_string().parse::<f64>().unwrap_or(1.0) + 1.0).ln();
1426 let log_max = (self
1427 .max_transaction_volume
1428 .to_string()
1429 .parse::<f64>()
1430 .unwrap_or(1.0)
1431 + 1.0)
1432 .ln();
1433 (log_vol / log_max).min(1.0)
1434 } else {
1435 0.0
1436 };
1437
1438 let count_normalized = if self.max_transaction_count > 0 {
1440 let sqrt_count = (transaction_count as f64).sqrt();
1441 let sqrt_max = (self.max_transaction_count as f64).sqrt();
1442 (sqrt_count / sqrt_max).min(1.0)
1443 } else {
1444 0.0
1445 };
1446
1447 let duration_normalized = if self.max_duration_days > 0 {
1449 (relationship_days as f64 / self.max_duration_days as f64).min(1.0)
1450 } else {
1451 0.0
1452 };
1453
1454 let recency_normalized = if self.recency_half_life_days > 0 {
1456 let decay_rate = 0.693 / self.recency_half_life_days as f64; (-decay_rate * days_since_last_transaction as f64).exp()
1458 } else {
1459 1.0
1460 };
1461
1462 let mutual_normalized = if total_possible_connections > 0 {
1464 mutual_connections as f64 / total_possible_connections as f64
1465 } else {
1466 0.0
1467 };
1468
1469 StrengthComponents::new(
1470 volume_normalized,
1471 count_normalized,
1472 duration_normalized,
1473 recency_normalized,
1474 mutual_normalized,
1475 )
1476 }
1477}
1478
1479#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1481#[serde(rename_all = "snake_case")]
1482pub enum RelationshipStrength {
1483 Strong,
1485 Moderate,
1487 Weak,
1489 Dormant,
1491}
1492
1493impl RelationshipStrength {
1494 pub fn from_value(strength: f64) -> Self {
1496 if strength >= 0.7 {
1497 Self::Strong
1498 } else if strength >= 0.4 {
1499 Self::Moderate
1500 } else if strength >= 0.1 {
1501 Self::Weak
1502 } else {
1503 Self::Dormant
1504 }
1505 }
1506
1507 pub fn min_threshold(&self) -> f64 {
1509 match self {
1510 Self::Strong => 0.7,
1511 Self::Moderate => 0.4,
1512 Self::Weak => 0.1,
1513 Self::Dormant => 0.0,
1514 }
1515 }
1516}
1517
1518#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1520pub struct GraphIndexes {
1521 pub outgoing_edges: HashMap<String, Vec<usize>>,
1523 pub incoming_edges: HashMap<String, Vec<usize>>,
1525 pub edges_by_type: HashMap<RelationshipType, Vec<usize>>,
1527 pub nodes_by_type: HashMap<GraphEntityType, Vec<String>>,
1529}
1530
1531#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1533pub struct EntityGraph {
1534 pub nodes: HashMap<String, EntityNode>,
1536 pub edges: Vec<RelationshipEdge>,
1538 #[serde(skip)]
1540 pub indexes: GraphIndexes,
1541 pub metadata: GraphMetadata,
1543}
1544
1545#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1547pub struct GraphMetadata {
1548 pub company_code: Option<String>,
1550 pub created_date: Option<NaiveDate>,
1552 #[serde(with = "rust_decimal::serde::str")]
1554 pub total_transaction_volume: Decimal,
1555 pub date_range: Option<(NaiveDate, NaiveDate)>,
1557}
1558
1559impl EntityGraph {
1560 pub fn new() -> Self {
1562 Self::default()
1563 }
1564
1565 pub fn add_node(&mut self, node: EntityNode) {
1567 let key = node.entity_id.key();
1568 let entity_type = node.entity_id.entity_type;
1569
1570 self.nodes.insert(key.clone(), node);
1571 self.indexes
1572 .nodes_by_type
1573 .entry(entity_type)
1574 .or_default()
1575 .push(key);
1576 }
1577
1578 pub fn add_edge(&mut self, edge: RelationshipEdge) {
1580 let edge_idx = self.edges.len();
1581 let from_key = edge.from_id.key();
1582 let to_key = edge.to_id.key();
1583 let rel_type = edge.relationship_type;
1584
1585 self.indexes
1586 .outgoing_edges
1587 .entry(from_key)
1588 .or_default()
1589 .push(edge_idx);
1590 self.indexes
1591 .incoming_edges
1592 .entry(to_key)
1593 .or_default()
1594 .push(edge_idx);
1595 self.indexes
1596 .edges_by_type
1597 .entry(rel_type)
1598 .or_default()
1599 .push(edge_idx);
1600
1601 self.edges.push(edge);
1602 }
1603
1604 pub fn get_node(&self, entity_id: &GraphEntityId) -> Option<&EntityNode> {
1606 self.nodes.get(&entity_id.key())
1607 }
1608
1609 pub fn get_outgoing_edges(&self, entity_id: &GraphEntityId) -> Vec<&RelationshipEdge> {
1611 self.indexes
1612 .outgoing_edges
1613 .get(&entity_id.key())
1614 .map(|indices| indices.iter().map(|&idx| &self.edges[idx]).collect())
1615 .unwrap_or_default()
1616 }
1617
1618 pub fn get_incoming_edges(&self, entity_id: &GraphEntityId) -> Vec<&RelationshipEdge> {
1620 self.indexes
1621 .incoming_edges
1622 .get(&entity_id.key())
1623 .map(|indices| indices.iter().map(|&idx| &self.edges[idx]).collect())
1624 .unwrap_or_default()
1625 }
1626
1627 pub fn get_edges_by_type(&self, rel_type: RelationshipType) -> Vec<&RelationshipEdge> {
1629 self.indexes
1630 .edges_by_type
1631 .get(&rel_type)
1632 .map(|indices| indices.iter().map(|&idx| &self.edges[idx]).collect())
1633 .unwrap_or_default()
1634 }
1635
1636 pub fn get_nodes_by_type(&self, entity_type: GraphEntityType) -> Vec<&EntityNode> {
1638 self.indexes
1639 .nodes_by_type
1640 .get(&entity_type)
1641 .map(|keys| keys.iter().filter_map(|k| self.nodes.get(k)).collect())
1642 .unwrap_or_default()
1643 }
1644
1645 pub fn get_neighbors(&self, entity_id: &GraphEntityId) -> Vec<&EntityNode> {
1647 let mut neighbor_ids: HashSet<String> = HashSet::new();
1648
1649 for edge in self.get_outgoing_edges(entity_id) {
1651 neighbor_ids.insert(edge.to_id.key());
1652 }
1653
1654 for edge in self.get_incoming_edges(entity_id) {
1656 neighbor_ids.insert(edge.from_id.key());
1657 }
1658
1659 neighbor_ids
1660 .iter()
1661 .filter_map(|key| self.nodes.get(key))
1662 .collect()
1663 }
1664
1665 pub fn node_degree(&self, entity_id: &GraphEntityId) -> usize {
1667 let key = entity_id.key();
1668 let out_degree = self
1669 .indexes
1670 .outgoing_edges
1671 .get(&key)
1672 .map(std::vec::Vec::len)
1673 .unwrap_or(0);
1674 let in_degree = self
1675 .indexes
1676 .incoming_edges
1677 .get(&key)
1678 .map(std::vec::Vec::len)
1679 .unwrap_or(0);
1680 out_degree + in_degree
1681 }
1682
1683 pub fn rebuild_indexes(&mut self) {
1685 self.indexes = GraphIndexes::default();
1686
1687 for (key, node) in &self.nodes {
1689 self.indexes
1690 .nodes_by_type
1691 .entry(node.entity_id.entity_type)
1692 .or_default()
1693 .push(key.clone());
1694 }
1695
1696 for (idx, edge) in self.edges.iter().enumerate() {
1698 self.indexes
1699 .outgoing_edges
1700 .entry(edge.from_id.key())
1701 .or_default()
1702 .push(idx);
1703 self.indexes
1704 .incoming_edges
1705 .entry(edge.to_id.key())
1706 .or_default()
1707 .push(idx);
1708 self.indexes
1709 .edges_by_type
1710 .entry(edge.relationship_type)
1711 .or_default()
1712 .push(idx);
1713 }
1714 }
1715
1716 pub fn statistics(&self) -> GraphStatistics {
1718 let node_count = self.nodes.len();
1719 let edge_count = self.edges.len();
1720
1721 let avg_degree = if node_count > 0 {
1723 (2.0 * edge_count as f64) / node_count as f64
1724 } else {
1725 0.0
1726 };
1727
1728 let avg_strength = if edge_count > 0 {
1730 self.edges.iter().map(|e| e.strength).sum::<f64>() / edge_count as f64
1731 } else {
1732 0.0
1733 };
1734
1735 let mut node_counts: HashMap<String, usize> = HashMap::new();
1737 for node in self.nodes.values() {
1738 *node_counts
1739 .entry(format!("{:?}", node.entity_id.entity_type))
1740 .or_insert(0) += 1;
1741 }
1742
1743 let mut edge_counts: HashMap<String, usize> = HashMap::new();
1745 for edge in &self.edges {
1746 *edge_counts
1747 .entry(format!("{:?}", edge.relationship_type))
1748 .or_insert(0) += 1;
1749 }
1750
1751 let mut strength_distribution: HashMap<String, usize> = HashMap::new();
1753 for edge in &self.edges {
1754 let classification = RelationshipStrength::from_value(edge.strength);
1755 *strength_distribution
1756 .entry(format!("{classification:?}"))
1757 .or_insert(0) += 1;
1758 }
1759
1760 GraphStatistics {
1761 node_count,
1762 edge_count,
1763 avg_degree,
1764 avg_strength,
1765 node_counts,
1766 edge_counts,
1767 strength_distribution,
1768 }
1769 }
1770}
1771
1772#[derive(Debug, Clone, Serialize, Deserialize)]
1774pub struct GraphStatistics {
1775 pub node_count: usize,
1777 pub edge_count: usize,
1779 pub avg_degree: f64,
1781 pub avg_strength: f64,
1783 pub node_counts: HashMap<String, usize>,
1785 pub edge_counts: HashMap<String, usize>,
1787 pub strength_distribution: HashMap<String, usize>,
1789}
1790
1791#[derive(Debug, Clone, Serialize, Deserialize)]
1793pub struct CrossProcessLink {
1794 pub material_id: String,
1796 pub source_process: String,
1798 pub source_document_id: String,
1800 pub target_process: String,
1802 pub target_document_id: String,
1804 pub link_type: CrossProcessLinkType,
1806 #[serde(with = "rust_decimal::serde::str")]
1808 pub quantity: Decimal,
1809 pub link_date: NaiveDate,
1811}
1812
1813#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1815#[serde(rename_all = "snake_case")]
1816pub enum CrossProcessLinkType {
1817 InventoryMovement,
1819 ReturnFlow,
1821 PaymentReconciliation,
1823 IntercompanyBilateral,
1825}
1826
1827impl CrossProcessLink {
1828 #[allow(clippy::too_many_arguments)]
1830 pub fn new(
1831 material_id: impl Into<String>,
1832 source_process: impl Into<String>,
1833 source_document_id: impl Into<String>,
1834 target_process: impl Into<String>,
1835 target_document_id: impl Into<String>,
1836 link_type: CrossProcessLinkType,
1837 quantity: Decimal,
1838 link_date: NaiveDate,
1839 ) -> Self {
1840 Self {
1841 material_id: material_id.into(),
1842 source_process: source_process.into(),
1843 source_document_id: source_document_id.into(),
1844 target_process: target_process.into(),
1845 target_document_id: target_document_id.into(),
1846 link_type,
1847 quantity,
1848 link_date,
1849 }
1850 }
1851}
1852
1853#[cfg(test)]
1854#[allow(clippy::unwrap_used)]
1855mod tests {
1856 use super::*;
1857
1858 #[test]
1859 fn test_entity_id() {
1860 let id = GraphEntityId::new(GraphEntityType::Vendor, "V-001234");
1861 assert_eq!(id.key(), "VN:V-001234");
1862 }
1863
1864 #[test]
1865 fn test_relationship_type_inverse() {
1866 assert_eq!(
1867 RelationshipType::BuysFrom.inverse(),
1868 RelationshipType::SellsTo
1869 );
1870 assert_eq!(
1871 RelationshipType::SellsTo.inverse(),
1872 RelationshipType::BuysFrom
1873 );
1874 assert_eq!(
1875 RelationshipType::ReportsTo.inverse(),
1876 RelationshipType::Manages
1877 );
1878 }
1879
1880 #[test]
1881 fn test_strength_weights_validation() {
1882 let valid_weights = StrengthWeights::default();
1883 assert!(valid_weights.validate().is_ok());
1884
1885 let invalid_weights = StrengthWeights {
1886 transaction_volume_weight: 0.5,
1887 transaction_count_weight: 0.5,
1888 duration_weight: 0.5,
1889 recency_weight: 0.5,
1890 mutual_connections_weight: 0.5,
1891 };
1892 assert!(invalid_weights.validate().is_err());
1893 }
1894
1895 #[test]
1896 fn test_strength_calculator() {
1897 let calc = RelationshipStrengthCalculator::default();
1898 let components = calc.calculate(Decimal::from(100000), 50, 365, 30, 5, 20);
1899
1900 assert!(components.transaction_volume > 0.0);
1901 assert!(components.transaction_count > 0.0);
1902 assert!(components.duration > 0.0);
1903 assert!(components.recency > 0.0);
1904 assert!(components.mutual_connections > 0.0);
1905 assert!(components.total() <= 1.0);
1906 }
1907
1908 #[test]
1909 fn test_relationship_strength_classification() {
1910 assert_eq!(
1911 RelationshipStrength::from_value(0.8),
1912 RelationshipStrength::Strong
1913 );
1914 assert_eq!(
1915 RelationshipStrength::from_value(0.5),
1916 RelationshipStrength::Moderate
1917 );
1918 assert_eq!(
1919 RelationshipStrength::from_value(0.2),
1920 RelationshipStrength::Weak
1921 );
1922 assert_eq!(
1923 RelationshipStrength::from_value(0.05),
1924 RelationshipStrength::Dormant
1925 );
1926 }
1927
1928 #[test]
1929 fn test_entity_graph() {
1930 let mut graph = EntityGraph::new();
1931
1932 let vendor_id = GraphEntityId::new(GraphEntityType::Vendor, "V-001");
1933 let customer_id = GraphEntityId::new(GraphEntityType::Customer, "C-001");
1934
1935 graph.add_node(EntityNode::new(
1936 vendor_id.clone(),
1937 "Acme Supplies",
1938 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1939 ));
1940
1941 graph.add_node(EntityNode::new(
1942 customer_id.clone(),
1943 "Contoso Corp",
1944 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1945 ));
1946
1947 graph.add_edge(
1948 RelationshipEdge::new(
1949 vendor_id.clone(),
1950 customer_id.clone(),
1951 RelationshipType::SellsTo,
1952 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1953 )
1954 .with_strength(0.7),
1955 );
1956
1957 assert_eq!(graph.nodes.len(), 2);
1958 assert_eq!(graph.edges.len(), 1);
1959
1960 let neighbors = graph.get_neighbors(&vendor_id);
1961 assert_eq!(neighbors.len(), 1);
1962 assert_eq!(neighbors[0].entity_id.id, "C-001");
1963
1964 assert_eq!(graph.node_degree(&vendor_id), 1);
1965 assert_eq!(graph.node_degree(&customer_id), 1);
1966 }
1967
1968 #[test]
1969 fn test_graph_statistics() {
1970 let mut graph = EntityGraph::new();
1971
1972 for i in 0..10 {
1973 let id = GraphEntityId::new(GraphEntityType::Vendor, format!("V-{:03}", i));
1974 graph.add_node(EntityNode::new(
1975 id,
1976 format!("Vendor {}", i),
1977 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1978 ));
1979 }
1980
1981 for i in 0..5 {
1982 let from_id = GraphEntityId::new(GraphEntityType::Vendor, format!("V-{:03}", i));
1983 let to_id = GraphEntityId::new(GraphEntityType::Vendor, format!("V-{:03}", i + 5));
1984 graph.add_edge(
1985 RelationshipEdge::new(
1986 from_id,
1987 to_id,
1988 RelationshipType::PartnersWith,
1989 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1990 )
1991 .with_strength(0.6),
1992 );
1993 }
1994
1995 let stats = graph.statistics();
1996 assert_eq!(stats.node_count, 10);
1997 assert_eq!(stats.edge_count, 5);
1998 assert!((stats.avg_degree - 1.0).abs() < 0.01);
1999 assert!((stats.avg_strength - 0.6).abs() < 0.01);
2000 }
2001
2002 #[test]
2003 fn test_numeric_code_roundtrip() {
2004 for &entity_type in GraphEntityType::all_types() {
2005 let code = entity_type.numeric_code();
2006 let recovered = GraphEntityType::from_numeric_code(code);
2007 assert_eq!(
2008 recovered,
2009 Some(entity_type),
2010 "Failed roundtrip for {:?} with code {}",
2011 entity_type,
2012 code
2013 );
2014 }
2015 }
2016
2017 #[test]
2018 fn test_node_type_name_roundtrip() {
2019 for &entity_type in GraphEntityType::all_types() {
2020 let name = entity_type.node_type_name();
2021 let recovered = GraphEntityType::from_node_type_name(name);
2022 assert_eq!(
2023 recovered,
2024 Some(entity_type),
2025 "Failed roundtrip for {:?} with name {}",
2026 entity_type,
2027 name
2028 );
2029 }
2030 }
2031
2032 #[test]
2033 fn test_all_types_unique_codes() {
2034 let mut codes = std::collections::HashSet::new();
2035 for &entity_type in GraphEntityType::all_types() {
2036 assert!(
2037 codes.insert(entity_type.numeric_code()),
2038 "Duplicate numeric code {} for {:?}",
2039 entity_type.numeric_code(),
2040 entity_type
2041 );
2042 }
2043 }
2044
2045 #[test]
2046 fn test_all_types_unique_names() {
2047 let mut names = std::collections::HashSet::new();
2048 for &entity_type in GraphEntityType::all_types() {
2049 assert!(
2050 names.insert(entity_type.node_type_name()),
2051 "Duplicate name {} for {:?}",
2052 entity_type.node_type_name(),
2053 entity_type
2054 );
2055 }
2056 }
2057
2058 #[test]
2059 fn test_all_types_unique_letter_codes() {
2060 let mut codes = std::collections::HashSet::new();
2061 for &entity_type in GraphEntityType::all_types() {
2062 assert!(
2063 codes.insert(entity_type.code()),
2064 "Duplicate letter code {} for {:?}",
2065 entity_type.code(),
2066 entity_type
2067 );
2068 }
2069 }
2070
2071 #[test]
2072 fn test_category_helpers() {
2073 assert!(GraphEntityType::TaxJurisdiction.is_tax());
2074 assert!(GraphEntityType::UncertainTaxPosition.is_tax());
2075 assert!(!GraphEntityType::CashPosition.is_tax());
2076
2077 assert!(GraphEntityType::CashPosition.is_treasury());
2078 assert!(GraphEntityType::DebtCovenant.is_treasury());
2079 assert!(!GraphEntityType::EmissionRecord.is_treasury());
2080
2081 assert!(GraphEntityType::EmissionRecord.is_esg());
2082 assert!(GraphEntityType::ClimateScenario.is_esg());
2083 assert!(!GraphEntityType::TaxCode.is_esg());
2084
2085 assert!(GraphEntityType::Project.is_project());
2086 assert!(GraphEntityType::ProjectMilestone.is_project());
2087
2088 assert!(GraphEntityType::PayrollRun.is_h2r());
2089 assert!(GraphEntityType::BenefitEnrollment.is_h2r());
2090
2091 assert!(GraphEntityType::ProductionOrder.is_mfg());
2092 assert!(GraphEntityType::BomComponent.is_mfg());
2093
2094 assert!(GraphEntityType::CosoComponent.is_governance());
2095 assert!(GraphEntityType::ProfessionalJudgment.is_governance());
2096 }
2097
2098 #[test]
2099 fn test_edge_constraint_validity() {
2100 let constraints = RelationshipType::all_constraints();
2101 assert_eq!(
2102 constraints.len(),
2103 29,
2104 "Expected 29 domain-specific edge constraints"
2105 );
2106 for constraint in &constraints {
2107 assert!(constraint.source_type.numeric_code() > 0);
2109 assert!(constraint.target_type.numeric_code() > 0);
2110 }
2111 }
2112
2113 #[test]
2114 fn test_all_process_families_have_edges() {
2115 let constraints = RelationshipType::all_constraints();
2116 assert!(constraints
2118 .iter()
2119 .any(|c| c.relationship_type == RelationshipType::PlacedWith));
2120 assert!(constraints
2121 .iter()
2122 .any(|c| c.relationship_type == RelationshipType::PaysInvoice));
2123 assert!(constraints
2125 .iter()
2126 .any(|c| c.relationship_type == RelationshipType::PlacedBy));
2127 assert!(constraints
2129 .iter()
2130 .any(|c| c.relationship_type == RelationshipType::TaxLineBelongsTo));
2131 assert!(constraints
2132 .iter()
2133 .any(|c| c.relationship_type == RelationshipType::WithheldFrom));
2134 assert!(constraints
2136 .iter()
2137 .any(|c| c.relationship_type == RelationshipType::SweepsTo));
2138 assert!(constraints
2139 .iter()
2140 .any(|c| c.relationship_type == RelationshipType::GovernsInstrument));
2141 assert!(constraints
2143 .iter()
2144 .any(|c| c.relationship_type == RelationshipType::EmissionReportedBy));
2145 assert!(constraints
2147 .iter()
2148 .any(|c| c.relationship_type == RelationshipType::CostChargedTo));
2149 assert!(constraints
2151 .iter()
2152 .any(|c| c.relationship_type == RelationshipType::PrincipleUnder));
2153 assert!(constraints
2154 .iter()
2155 .any(|c| c.relationship_type == RelationshipType::JudgmentWithin));
2156 }
2157
2158 #[test]
2159 fn test_edge_source_target_types() {
2160 let placed_with = RelationshipType::PlacedWith.constraint().unwrap();
2161 assert_eq!(placed_with.source_type, GraphEntityType::PurchaseOrder);
2162 assert_eq!(placed_with.target_type, GraphEntityType::Vendor);
2163 assert_eq!(placed_with.cardinality, Cardinality::ManyToOne);
2164
2165 let awarded_from = RelationshipType::AwardedFrom.constraint().unwrap();
2166 assert_eq!(
2167 awarded_from.source_type,
2168 GraphEntityType::ProcurementContract
2169 );
2170 assert_eq!(awarded_from.target_type, GraphEntityType::BidEvaluation);
2171 assert_eq!(awarded_from.cardinality, Cardinality::OneToOne);
2172
2173 let pays_invoice = RelationshipType::PaysInvoice.constraint().unwrap();
2174 assert_eq!(pays_invoice.cardinality, Cardinality::ManyToMany);
2175 }
2176
2177 #[test]
2178 fn test_existing_types_no_constraint() {
2179 assert!(RelationshipType::BuysFrom.constraint().is_none());
2181 assert!(RelationshipType::SellsTo.constraint().is_none());
2182 assert!(RelationshipType::ReportsTo.constraint().is_none());
2183 }
2184
2185 #[test]
2186 fn test_specific_entity_codes() {
2187 assert_eq!(GraphEntityType::UncertainTaxPosition.numeric_code(), 416);
2188 assert_eq!(
2189 GraphEntityType::UncertainTaxPosition.node_type_name(),
2190 "uncertain_tax_position"
2191 );
2192 assert_eq!(GraphEntityType::DebtCovenant.numeric_code(), 427);
2193 assert_eq!(GraphEntityType::BenefitEnrollment.numeric_code(), 333);
2194 assert_eq!(GraphEntityType::BomComponent.numeric_code(), 343);
2195 }
2196
2197 #[test]
2198 fn test_all_types_count() {
2199 assert_eq!(GraphEntityType::all_types().len(), 70);
2202 }
2203
2204 #[test]
2205 fn test_cross_process_link() {
2206 let link = CrossProcessLink::new(
2207 "MAT-001",
2208 "P2P",
2209 "GR-12345",
2210 "O2C",
2211 "DEL-67890",
2212 CrossProcessLinkType::InventoryMovement,
2213 Decimal::from(100),
2214 NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(),
2215 );
2216
2217 assert_eq!(link.material_id, "MAT-001");
2218 assert_eq!(link.link_type, CrossProcessLinkType::InventoryMovement);
2219 }
2220}