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.iter().filter_map(|t| t.constraint()).collect()
1113 }
1114}
1115
1116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1118pub enum Cardinality {
1119 OneToOne,
1120 ManyToOne,
1121 ManyToMany,
1122}
1123
1124#[derive(Debug, Clone)]
1126pub struct EdgeConstraint {
1127 pub relationship_type: RelationshipType,
1128 pub source_type: GraphEntityType,
1129 pub target_type: GraphEntityType,
1130 pub cardinality: Cardinality,
1131 pub edge_properties: &'static [&'static str],
1132}
1133
1134#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1139pub struct GraphEntityId {
1140 pub entity_type: GraphEntityType,
1142 pub id: String,
1144}
1145
1146impl GraphEntityId {
1147 pub fn new(entity_type: GraphEntityType, id: impl Into<String>) -> Self {
1149 Self {
1150 entity_type,
1151 id: id.into(),
1152 }
1153 }
1154
1155 pub fn key(&self) -> String {
1157 format!("{}:{}", self.entity_type.code(), self.id)
1158 }
1159}
1160
1161#[derive(Debug, Clone, Serialize, Deserialize)]
1163pub struct EntityNode {
1164 pub entity_id: GraphEntityId,
1166 pub name: String,
1168 pub attributes: HashMap<String, String>,
1170 pub created_date: NaiveDate,
1172 pub is_active: bool,
1174 pub company_code: Option<String>,
1176}
1177
1178impl EntityNode {
1179 pub fn new(entity_id: GraphEntityId, name: impl Into<String>, created_date: NaiveDate) -> Self {
1181 Self {
1182 entity_id,
1183 name: name.into(),
1184 attributes: HashMap::new(),
1185 created_date,
1186 is_active: true,
1187 company_code: None,
1188 }
1189 }
1190
1191 pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1193 self.attributes.insert(key.into(), value.into());
1194 self
1195 }
1196
1197 pub fn with_company(mut self, company_code: impl Into<String>) -> Self {
1199 self.company_code = Some(company_code.into());
1200 self
1201 }
1202}
1203
1204#[derive(Debug, Clone, Serialize, Deserialize)]
1206pub struct RelationshipEdge {
1207 pub from_id: GraphEntityId,
1209 pub to_id: GraphEntityId,
1211 pub relationship_type: RelationshipType,
1213 pub strength: f64,
1215 pub start_date: NaiveDate,
1217 pub end_date: Option<NaiveDate>,
1219 pub attributes: HashMap<String, String>,
1221 pub strength_components: Option<StrengthComponents>,
1223}
1224
1225impl RelationshipEdge {
1226 pub fn new(
1228 from_id: GraphEntityId,
1229 to_id: GraphEntityId,
1230 relationship_type: RelationshipType,
1231 start_date: NaiveDate,
1232 ) -> Self {
1233 Self {
1234 from_id,
1235 to_id,
1236 relationship_type,
1237 strength: 0.5, start_date,
1239 end_date: None,
1240 attributes: HashMap::new(),
1241 strength_components: None,
1242 }
1243 }
1244
1245 pub fn with_strength(mut self, strength: f64) -> Self {
1247 self.strength = strength.clamp(0.0, 1.0);
1248 self
1249 }
1250
1251 pub fn with_strength_components(mut self, components: StrengthComponents) -> Self {
1253 self.strength = components.total();
1254 self.strength_components = Some(components);
1255 self
1256 }
1257
1258 pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1260 self.attributes.insert(key.into(), value.into());
1261 self
1262 }
1263
1264 pub fn is_active(&self) -> bool {
1266 self.end_date.is_none()
1267 }
1268
1269 pub fn key(&self) -> String {
1271 format!(
1272 "{}->{}:{}",
1273 self.from_id.key(),
1274 self.to_id.key(),
1275 self.relationship_type.code()
1276 )
1277 }
1278}
1279
1280#[derive(Debug, Clone, Serialize, Deserialize)]
1282pub struct StrengthComponents {
1283 pub transaction_volume: f64,
1285 pub transaction_count: f64,
1287 pub duration: f64,
1289 pub recency: f64,
1291 pub mutual_connections: f64,
1293}
1294
1295impl StrengthComponents {
1296 pub fn new(
1298 transaction_volume: f64,
1299 transaction_count: f64,
1300 duration: f64,
1301 recency: f64,
1302 mutual_connections: f64,
1303 ) -> Self {
1304 Self {
1305 transaction_volume: transaction_volume.clamp(0.0, 1.0),
1306 transaction_count: transaction_count.clamp(0.0, 1.0),
1307 duration: duration.clamp(0.0, 1.0),
1308 recency: recency.clamp(0.0, 1.0),
1309 mutual_connections: mutual_connections.clamp(0.0, 1.0),
1310 }
1311 }
1312
1313 pub fn total(&self) -> f64 {
1315 self.total_weighted(RelationshipStrengthCalculator::default_weights())
1316 }
1317
1318 pub fn total_weighted(&self, weights: &StrengthWeights) -> f64 {
1320 let total = self.transaction_volume * weights.transaction_volume_weight
1321 + self.transaction_count * weights.transaction_count_weight
1322 + self.duration * weights.duration_weight
1323 + self.recency * weights.recency_weight
1324 + self.mutual_connections * weights.mutual_connections_weight;
1325
1326 total.clamp(0.0, 1.0)
1327 }
1328}
1329
1330#[derive(Debug, Clone, Serialize, Deserialize)]
1332pub struct StrengthWeights {
1333 pub transaction_volume_weight: f64,
1335 pub transaction_count_weight: f64,
1337 pub duration_weight: f64,
1339 pub recency_weight: f64,
1341 pub mutual_connections_weight: f64,
1343}
1344
1345impl Default for StrengthWeights {
1346 fn default() -> Self {
1347 Self {
1348 transaction_volume_weight: 0.30,
1349 transaction_count_weight: 0.25,
1350 duration_weight: 0.20,
1351 recency_weight: 0.15,
1352 mutual_connections_weight: 0.10,
1353 }
1354 }
1355}
1356
1357impl StrengthWeights {
1358 pub fn validate(&self) -> Result<(), String> {
1360 let sum = self.transaction_volume_weight
1361 + self.transaction_count_weight
1362 + self.duration_weight
1363 + self.recency_weight
1364 + self.mutual_connections_weight;
1365
1366 if (sum - 1.0).abs() > 0.01 {
1367 Err(format!("Strength weights must sum to 1.0, got {}", sum))
1368 } else {
1369 Ok(())
1370 }
1371 }
1372}
1373
1374#[derive(Debug, Clone, Serialize, Deserialize)]
1376pub struct RelationshipStrengthCalculator {
1377 pub weights: StrengthWeights,
1379 pub recency_half_life_days: u32,
1381 pub max_transaction_volume: Decimal,
1383 pub max_transaction_count: u32,
1385 pub max_duration_days: u32,
1387}
1388
1389impl Default for RelationshipStrengthCalculator {
1390 fn default() -> Self {
1391 Self {
1392 weights: StrengthWeights::default(),
1393 recency_half_life_days: 90,
1394 max_transaction_volume: Decimal::from(10_000_000),
1395 max_transaction_count: 1000,
1396 max_duration_days: 3650, }
1398 }
1399}
1400
1401impl RelationshipStrengthCalculator {
1402 pub fn default_weights() -> &'static StrengthWeights {
1404 static WEIGHTS: std::sync::OnceLock<StrengthWeights> = std::sync::OnceLock::new();
1405 WEIGHTS.get_or_init(StrengthWeights::default)
1406 }
1407
1408 pub fn calculate(
1410 &self,
1411 transaction_volume: Decimal,
1412 transaction_count: u32,
1413 relationship_days: u32,
1414 days_since_last_transaction: u32,
1415 mutual_connections: usize,
1416 total_possible_connections: usize,
1417 ) -> StrengthComponents {
1418 let volume_normalized = if transaction_volume > Decimal::ZERO
1420 && self.max_transaction_volume > Decimal::ZERO
1421 {
1422 let log_vol = (transaction_volume.to_string().parse::<f64>().unwrap_or(1.0) + 1.0).ln();
1423 let log_max = (self
1424 .max_transaction_volume
1425 .to_string()
1426 .parse::<f64>()
1427 .unwrap_or(1.0)
1428 + 1.0)
1429 .ln();
1430 (log_vol / log_max).min(1.0)
1431 } else {
1432 0.0
1433 };
1434
1435 let count_normalized = if self.max_transaction_count > 0 {
1437 let sqrt_count = (transaction_count as f64).sqrt();
1438 let sqrt_max = (self.max_transaction_count as f64).sqrt();
1439 (sqrt_count / sqrt_max).min(1.0)
1440 } else {
1441 0.0
1442 };
1443
1444 let duration_normalized = if self.max_duration_days > 0 {
1446 (relationship_days as f64 / self.max_duration_days as f64).min(1.0)
1447 } else {
1448 0.0
1449 };
1450
1451 let recency_normalized = if self.recency_half_life_days > 0 {
1453 let decay_rate = 0.693 / self.recency_half_life_days as f64; (-decay_rate * days_since_last_transaction as f64).exp()
1455 } else {
1456 1.0
1457 };
1458
1459 let mutual_normalized = if total_possible_connections > 0 {
1461 mutual_connections as f64 / total_possible_connections as f64
1462 } else {
1463 0.0
1464 };
1465
1466 StrengthComponents::new(
1467 volume_normalized,
1468 count_normalized,
1469 duration_normalized,
1470 recency_normalized,
1471 mutual_normalized,
1472 )
1473 }
1474}
1475
1476#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1478#[serde(rename_all = "snake_case")]
1479pub enum RelationshipStrength {
1480 Strong,
1482 Moderate,
1484 Weak,
1486 Dormant,
1488}
1489
1490impl RelationshipStrength {
1491 pub fn from_value(strength: f64) -> Self {
1493 if strength >= 0.7 {
1494 Self::Strong
1495 } else if strength >= 0.4 {
1496 Self::Moderate
1497 } else if strength >= 0.1 {
1498 Self::Weak
1499 } else {
1500 Self::Dormant
1501 }
1502 }
1503
1504 pub fn min_threshold(&self) -> f64 {
1506 match self {
1507 Self::Strong => 0.7,
1508 Self::Moderate => 0.4,
1509 Self::Weak => 0.1,
1510 Self::Dormant => 0.0,
1511 }
1512 }
1513}
1514
1515#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1517pub struct GraphIndexes {
1518 pub outgoing_edges: HashMap<String, Vec<usize>>,
1520 pub incoming_edges: HashMap<String, Vec<usize>>,
1522 pub edges_by_type: HashMap<RelationshipType, Vec<usize>>,
1524 pub nodes_by_type: HashMap<GraphEntityType, Vec<String>>,
1526}
1527
1528#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1530pub struct EntityGraph {
1531 pub nodes: HashMap<String, EntityNode>,
1533 pub edges: Vec<RelationshipEdge>,
1535 #[serde(skip)]
1537 pub indexes: GraphIndexes,
1538 pub metadata: GraphMetadata,
1540}
1541
1542#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1544pub struct GraphMetadata {
1545 pub company_code: Option<String>,
1547 pub created_date: Option<NaiveDate>,
1549 #[serde(with = "rust_decimal::serde::str")]
1551 pub total_transaction_volume: Decimal,
1552 pub date_range: Option<(NaiveDate, NaiveDate)>,
1554}
1555
1556impl EntityGraph {
1557 pub fn new() -> Self {
1559 Self::default()
1560 }
1561
1562 pub fn add_node(&mut self, node: EntityNode) {
1564 let key = node.entity_id.key();
1565 let entity_type = node.entity_id.entity_type;
1566
1567 self.nodes.insert(key.clone(), node);
1568 self.indexes
1569 .nodes_by_type
1570 .entry(entity_type)
1571 .or_default()
1572 .push(key);
1573 }
1574
1575 pub fn add_edge(&mut self, edge: RelationshipEdge) {
1577 let edge_idx = self.edges.len();
1578 let from_key = edge.from_id.key();
1579 let to_key = edge.to_id.key();
1580 let rel_type = edge.relationship_type;
1581
1582 self.indexes
1583 .outgoing_edges
1584 .entry(from_key)
1585 .or_default()
1586 .push(edge_idx);
1587 self.indexes
1588 .incoming_edges
1589 .entry(to_key)
1590 .or_default()
1591 .push(edge_idx);
1592 self.indexes
1593 .edges_by_type
1594 .entry(rel_type)
1595 .or_default()
1596 .push(edge_idx);
1597
1598 self.edges.push(edge);
1599 }
1600
1601 pub fn get_node(&self, entity_id: &GraphEntityId) -> Option<&EntityNode> {
1603 self.nodes.get(&entity_id.key())
1604 }
1605
1606 pub fn get_outgoing_edges(&self, entity_id: &GraphEntityId) -> Vec<&RelationshipEdge> {
1608 self.indexes
1609 .outgoing_edges
1610 .get(&entity_id.key())
1611 .map(|indices| indices.iter().map(|&idx| &self.edges[idx]).collect())
1612 .unwrap_or_default()
1613 }
1614
1615 pub fn get_incoming_edges(&self, entity_id: &GraphEntityId) -> Vec<&RelationshipEdge> {
1617 self.indexes
1618 .incoming_edges
1619 .get(&entity_id.key())
1620 .map(|indices| indices.iter().map(|&idx| &self.edges[idx]).collect())
1621 .unwrap_or_default()
1622 }
1623
1624 pub fn get_edges_by_type(&self, rel_type: RelationshipType) -> Vec<&RelationshipEdge> {
1626 self.indexes
1627 .edges_by_type
1628 .get(&rel_type)
1629 .map(|indices| indices.iter().map(|&idx| &self.edges[idx]).collect())
1630 .unwrap_or_default()
1631 }
1632
1633 pub fn get_nodes_by_type(&self, entity_type: GraphEntityType) -> Vec<&EntityNode> {
1635 self.indexes
1636 .nodes_by_type
1637 .get(&entity_type)
1638 .map(|keys| keys.iter().filter_map(|k| self.nodes.get(k)).collect())
1639 .unwrap_or_default()
1640 }
1641
1642 pub fn get_neighbors(&self, entity_id: &GraphEntityId) -> Vec<&EntityNode> {
1644 let mut neighbor_ids: HashSet<String> = HashSet::new();
1645
1646 for edge in self.get_outgoing_edges(entity_id) {
1648 neighbor_ids.insert(edge.to_id.key());
1649 }
1650
1651 for edge in self.get_incoming_edges(entity_id) {
1653 neighbor_ids.insert(edge.from_id.key());
1654 }
1655
1656 neighbor_ids
1657 .iter()
1658 .filter_map(|key| self.nodes.get(key))
1659 .collect()
1660 }
1661
1662 pub fn node_degree(&self, entity_id: &GraphEntityId) -> usize {
1664 let key = entity_id.key();
1665 let out_degree = self
1666 .indexes
1667 .outgoing_edges
1668 .get(&key)
1669 .map(|v| v.len())
1670 .unwrap_or(0);
1671 let in_degree = self
1672 .indexes
1673 .incoming_edges
1674 .get(&key)
1675 .map(|v| v.len())
1676 .unwrap_or(0);
1677 out_degree + in_degree
1678 }
1679
1680 pub fn rebuild_indexes(&mut self) {
1682 self.indexes = GraphIndexes::default();
1683
1684 for (key, node) in &self.nodes {
1686 self.indexes
1687 .nodes_by_type
1688 .entry(node.entity_id.entity_type)
1689 .or_default()
1690 .push(key.clone());
1691 }
1692
1693 for (idx, edge) in self.edges.iter().enumerate() {
1695 self.indexes
1696 .outgoing_edges
1697 .entry(edge.from_id.key())
1698 .or_default()
1699 .push(idx);
1700 self.indexes
1701 .incoming_edges
1702 .entry(edge.to_id.key())
1703 .or_default()
1704 .push(idx);
1705 self.indexes
1706 .edges_by_type
1707 .entry(edge.relationship_type)
1708 .or_default()
1709 .push(idx);
1710 }
1711 }
1712
1713 pub fn statistics(&self) -> GraphStatistics {
1715 let node_count = self.nodes.len();
1716 let edge_count = self.edges.len();
1717
1718 let avg_degree = if node_count > 0 {
1720 (2.0 * edge_count as f64) / node_count as f64
1721 } else {
1722 0.0
1723 };
1724
1725 let avg_strength = if edge_count > 0 {
1727 self.edges.iter().map(|e| e.strength).sum::<f64>() / edge_count as f64
1728 } else {
1729 0.0
1730 };
1731
1732 let mut node_counts: HashMap<String, usize> = HashMap::new();
1734 for node in self.nodes.values() {
1735 *node_counts
1736 .entry(format!("{:?}", node.entity_id.entity_type))
1737 .or_insert(0) += 1;
1738 }
1739
1740 let mut edge_counts: HashMap<String, usize> = HashMap::new();
1742 for edge in &self.edges {
1743 *edge_counts
1744 .entry(format!("{:?}", edge.relationship_type))
1745 .or_insert(0) += 1;
1746 }
1747
1748 let mut strength_distribution: HashMap<String, usize> = HashMap::new();
1750 for edge in &self.edges {
1751 let classification = RelationshipStrength::from_value(edge.strength);
1752 *strength_distribution
1753 .entry(format!("{:?}", classification))
1754 .or_insert(0) += 1;
1755 }
1756
1757 GraphStatistics {
1758 node_count,
1759 edge_count,
1760 avg_degree,
1761 avg_strength,
1762 node_counts,
1763 edge_counts,
1764 strength_distribution,
1765 }
1766 }
1767}
1768
1769#[derive(Debug, Clone, Serialize, Deserialize)]
1771pub struct GraphStatistics {
1772 pub node_count: usize,
1774 pub edge_count: usize,
1776 pub avg_degree: f64,
1778 pub avg_strength: f64,
1780 pub node_counts: HashMap<String, usize>,
1782 pub edge_counts: HashMap<String, usize>,
1784 pub strength_distribution: HashMap<String, usize>,
1786}
1787
1788#[derive(Debug, Clone, Serialize, Deserialize)]
1790pub struct CrossProcessLink {
1791 pub material_id: String,
1793 pub source_process: String,
1795 pub source_document_id: String,
1797 pub target_process: String,
1799 pub target_document_id: String,
1801 pub link_type: CrossProcessLinkType,
1803 #[serde(with = "rust_decimal::serde::str")]
1805 pub quantity: Decimal,
1806 pub link_date: NaiveDate,
1808}
1809
1810#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1812#[serde(rename_all = "snake_case")]
1813pub enum CrossProcessLinkType {
1814 InventoryMovement,
1816 ReturnFlow,
1818 PaymentReconciliation,
1820 IntercompanyBilateral,
1822}
1823
1824impl CrossProcessLink {
1825 #[allow(clippy::too_many_arguments)]
1827 pub fn new(
1828 material_id: impl Into<String>,
1829 source_process: impl Into<String>,
1830 source_document_id: impl Into<String>,
1831 target_process: impl Into<String>,
1832 target_document_id: impl Into<String>,
1833 link_type: CrossProcessLinkType,
1834 quantity: Decimal,
1835 link_date: NaiveDate,
1836 ) -> Self {
1837 Self {
1838 material_id: material_id.into(),
1839 source_process: source_process.into(),
1840 source_document_id: source_document_id.into(),
1841 target_process: target_process.into(),
1842 target_document_id: target_document_id.into(),
1843 link_type,
1844 quantity,
1845 link_date,
1846 }
1847 }
1848}
1849
1850#[cfg(test)]
1851#[allow(clippy::unwrap_used)]
1852mod tests {
1853 use super::*;
1854
1855 #[test]
1856 fn test_entity_id() {
1857 let id = GraphEntityId::new(GraphEntityType::Vendor, "V-001234");
1858 assert_eq!(id.key(), "VN:V-001234");
1859 }
1860
1861 #[test]
1862 fn test_relationship_type_inverse() {
1863 assert_eq!(
1864 RelationshipType::BuysFrom.inverse(),
1865 RelationshipType::SellsTo
1866 );
1867 assert_eq!(
1868 RelationshipType::SellsTo.inverse(),
1869 RelationshipType::BuysFrom
1870 );
1871 assert_eq!(
1872 RelationshipType::ReportsTo.inverse(),
1873 RelationshipType::Manages
1874 );
1875 }
1876
1877 #[test]
1878 fn test_strength_weights_validation() {
1879 let valid_weights = StrengthWeights::default();
1880 assert!(valid_weights.validate().is_ok());
1881
1882 let invalid_weights = StrengthWeights {
1883 transaction_volume_weight: 0.5,
1884 transaction_count_weight: 0.5,
1885 duration_weight: 0.5,
1886 recency_weight: 0.5,
1887 mutual_connections_weight: 0.5,
1888 };
1889 assert!(invalid_weights.validate().is_err());
1890 }
1891
1892 #[test]
1893 fn test_strength_calculator() {
1894 let calc = RelationshipStrengthCalculator::default();
1895 let components = calc.calculate(Decimal::from(100000), 50, 365, 30, 5, 20);
1896
1897 assert!(components.transaction_volume > 0.0);
1898 assert!(components.transaction_count > 0.0);
1899 assert!(components.duration > 0.0);
1900 assert!(components.recency > 0.0);
1901 assert!(components.mutual_connections > 0.0);
1902 assert!(components.total() <= 1.0);
1903 }
1904
1905 #[test]
1906 fn test_relationship_strength_classification() {
1907 assert_eq!(
1908 RelationshipStrength::from_value(0.8),
1909 RelationshipStrength::Strong
1910 );
1911 assert_eq!(
1912 RelationshipStrength::from_value(0.5),
1913 RelationshipStrength::Moderate
1914 );
1915 assert_eq!(
1916 RelationshipStrength::from_value(0.2),
1917 RelationshipStrength::Weak
1918 );
1919 assert_eq!(
1920 RelationshipStrength::from_value(0.05),
1921 RelationshipStrength::Dormant
1922 );
1923 }
1924
1925 #[test]
1926 fn test_entity_graph() {
1927 let mut graph = EntityGraph::new();
1928
1929 let vendor_id = GraphEntityId::new(GraphEntityType::Vendor, "V-001");
1930 let customer_id = GraphEntityId::new(GraphEntityType::Customer, "C-001");
1931
1932 graph.add_node(EntityNode::new(
1933 vendor_id.clone(),
1934 "Acme Supplies",
1935 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1936 ));
1937
1938 graph.add_node(EntityNode::new(
1939 customer_id.clone(),
1940 "Contoso Corp",
1941 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1942 ));
1943
1944 graph.add_edge(
1945 RelationshipEdge::new(
1946 vendor_id.clone(),
1947 customer_id.clone(),
1948 RelationshipType::SellsTo,
1949 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1950 )
1951 .with_strength(0.7),
1952 );
1953
1954 assert_eq!(graph.nodes.len(), 2);
1955 assert_eq!(graph.edges.len(), 1);
1956
1957 let neighbors = graph.get_neighbors(&vendor_id);
1958 assert_eq!(neighbors.len(), 1);
1959 assert_eq!(neighbors[0].entity_id.id, "C-001");
1960
1961 assert_eq!(graph.node_degree(&vendor_id), 1);
1962 assert_eq!(graph.node_degree(&customer_id), 1);
1963 }
1964
1965 #[test]
1966 fn test_graph_statistics() {
1967 let mut graph = EntityGraph::new();
1968
1969 for i in 0..10 {
1970 let id = GraphEntityId::new(GraphEntityType::Vendor, format!("V-{:03}", i));
1971 graph.add_node(EntityNode::new(
1972 id,
1973 format!("Vendor {}", i),
1974 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1975 ));
1976 }
1977
1978 for i in 0..5 {
1979 let from_id = GraphEntityId::new(GraphEntityType::Vendor, format!("V-{:03}", i));
1980 let to_id = GraphEntityId::new(GraphEntityType::Vendor, format!("V-{:03}", i + 5));
1981 graph.add_edge(
1982 RelationshipEdge::new(
1983 from_id,
1984 to_id,
1985 RelationshipType::PartnersWith,
1986 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1987 )
1988 .with_strength(0.6),
1989 );
1990 }
1991
1992 let stats = graph.statistics();
1993 assert_eq!(stats.node_count, 10);
1994 assert_eq!(stats.edge_count, 5);
1995 assert!((stats.avg_degree - 1.0).abs() < 0.01);
1996 assert!((stats.avg_strength - 0.6).abs() < 0.01);
1997 }
1998
1999 #[test]
2000 fn test_numeric_code_roundtrip() {
2001 for &entity_type in GraphEntityType::all_types() {
2002 let code = entity_type.numeric_code();
2003 let recovered = GraphEntityType::from_numeric_code(code);
2004 assert_eq!(
2005 recovered,
2006 Some(entity_type),
2007 "Failed roundtrip for {:?} with code {}",
2008 entity_type,
2009 code
2010 );
2011 }
2012 }
2013
2014 #[test]
2015 fn test_node_type_name_roundtrip() {
2016 for &entity_type in GraphEntityType::all_types() {
2017 let name = entity_type.node_type_name();
2018 let recovered = GraphEntityType::from_node_type_name(name);
2019 assert_eq!(
2020 recovered,
2021 Some(entity_type),
2022 "Failed roundtrip for {:?} with name {}",
2023 entity_type,
2024 name
2025 );
2026 }
2027 }
2028
2029 #[test]
2030 fn test_all_types_unique_codes() {
2031 let mut codes = std::collections::HashSet::new();
2032 for &entity_type in GraphEntityType::all_types() {
2033 assert!(
2034 codes.insert(entity_type.numeric_code()),
2035 "Duplicate numeric code {} for {:?}",
2036 entity_type.numeric_code(),
2037 entity_type
2038 );
2039 }
2040 }
2041
2042 #[test]
2043 fn test_all_types_unique_names() {
2044 let mut names = std::collections::HashSet::new();
2045 for &entity_type in GraphEntityType::all_types() {
2046 assert!(
2047 names.insert(entity_type.node_type_name()),
2048 "Duplicate name {} for {:?}",
2049 entity_type.node_type_name(),
2050 entity_type
2051 );
2052 }
2053 }
2054
2055 #[test]
2056 fn test_all_types_unique_letter_codes() {
2057 let mut codes = std::collections::HashSet::new();
2058 for &entity_type in GraphEntityType::all_types() {
2059 assert!(
2060 codes.insert(entity_type.code()),
2061 "Duplicate letter code {} for {:?}",
2062 entity_type.code(),
2063 entity_type
2064 );
2065 }
2066 }
2067
2068 #[test]
2069 fn test_category_helpers() {
2070 assert!(GraphEntityType::TaxJurisdiction.is_tax());
2071 assert!(GraphEntityType::UncertainTaxPosition.is_tax());
2072 assert!(!GraphEntityType::CashPosition.is_tax());
2073
2074 assert!(GraphEntityType::CashPosition.is_treasury());
2075 assert!(GraphEntityType::DebtCovenant.is_treasury());
2076 assert!(!GraphEntityType::EmissionRecord.is_treasury());
2077
2078 assert!(GraphEntityType::EmissionRecord.is_esg());
2079 assert!(GraphEntityType::ClimateScenario.is_esg());
2080 assert!(!GraphEntityType::TaxCode.is_esg());
2081
2082 assert!(GraphEntityType::Project.is_project());
2083 assert!(GraphEntityType::ProjectMilestone.is_project());
2084
2085 assert!(GraphEntityType::PayrollRun.is_h2r());
2086 assert!(GraphEntityType::BenefitEnrollment.is_h2r());
2087
2088 assert!(GraphEntityType::ProductionOrder.is_mfg());
2089 assert!(GraphEntityType::BomComponent.is_mfg());
2090
2091 assert!(GraphEntityType::CosoComponent.is_governance());
2092 assert!(GraphEntityType::ProfessionalJudgment.is_governance());
2093 }
2094
2095 #[test]
2096 fn test_edge_constraint_validity() {
2097 let constraints = RelationshipType::all_constraints();
2098 assert_eq!(
2099 constraints.len(),
2100 29,
2101 "Expected 29 domain-specific edge constraints"
2102 );
2103 for constraint in &constraints {
2104 assert!(constraint.source_type.numeric_code() > 0);
2106 assert!(constraint.target_type.numeric_code() > 0);
2107 }
2108 }
2109
2110 #[test]
2111 fn test_all_process_families_have_edges() {
2112 let constraints = RelationshipType::all_constraints();
2113 assert!(constraints
2115 .iter()
2116 .any(|c| c.relationship_type == RelationshipType::PlacedWith));
2117 assert!(constraints
2118 .iter()
2119 .any(|c| c.relationship_type == RelationshipType::PaysInvoice));
2120 assert!(constraints
2122 .iter()
2123 .any(|c| c.relationship_type == RelationshipType::PlacedBy));
2124 assert!(constraints
2126 .iter()
2127 .any(|c| c.relationship_type == RelationshipType::TaxLineBelongsTo));
2128 assert!(constraints
2129 .iter()
2130 .any(|c| c.relationship_type == RelationshipType::WithheldFrom));
2131 assert!(constraints
2133 .iter()
2134 .any(|c| c.relationship_type == RelationshipType::SweepsTo));
2135 assert!(constraints
2136 .iter()
2137 .any(|c| c.relationship_type == RelationshipType::GovernsInstrument));
2138 assert!(constraints
2140 .iter()
2141 .any(|c| c.relationship_type == RelationshipType::EmissionReportedBy));
2142 assert!(constraints
2144 .iter()
2145 .any(|c| c.relationship_type == RelationshipType::CostChargedTo));
2146 assert!(constraints
2148 .iter()
2149 .any(|c| c.relationship_type == RelationshipType::PrincipleUnder));
2150 assert!(constraints
2151 .iter()
2152 .any(|c| c.relationship_type == RelationshipType::JudgmentWithin));
2153 }
2154
2155 #[test]
2156 fn test_edge_source_target_types() {
2157 let placed_with = RelationshipType::PlacedWith.constraint().unwrap();
2158 assert_eq!(placed_with.source_type, GraphEntityType::PurchaseOrder);
2159 assert_eq!(placed_with.target_type, GraphEntityType::Vendor);
2160 assert_eq!(placed_with.cardinality, Cardinality::ManyToOne);
2161
2162 let awarded_from = RelationshipType::AwardedFrom.constraint().unwrap();
2163 assert_eq!(
2164 awarded_from.source_type,
2165 GraphEntityType::ProcurementContract
2166 );
2167 assert_eq!(awarded_from.target_type, GraphEntityType::BidEvaluation);
2168 assert_eq!(awarded_from.cardinality, Cardinality::OneToOne);
2169
2170 let pays_invoice = RelationshipType::PaysInvoice.constraint().unwrap();
2171 assert_eq!(pays_invoice.cardinality, Cardinality::ManyToMany);
2172 }
2173
2174 #[test]
2175 fn test_existing_types_no_constraint() {
2176 assert!(RelationshipType::BuysFrom.constraint().is_none());
2178 assert!(RelationshipType::SellsTo.constraint().is_none());
2179 assert!(RelationshipType::ReportsTo.constraint().is_none());
2180 }
2181
2182 #[test]
2183 fn test_specific_entity_codes() {
2184 assert_eq!(GraphEntityType::UncertainTaxPosition.numeric_code(), 416);
2185 assert_eq!(
2186 GraphEntityType::UncertainTaxPosition.node_type_name(),
2187 "uncertain_tax_position"
2188 );
2189 assert_eq!(GraphEntityType::DebtCovenant.numeric_code(), 427);
2190 assert_eq!(GraphEntityType::BenefitEnrollment.numeric_code(), 333);
2191 assert_eq!(GraphEntityType::BomComponent.numeric_code(), 343);
2192 }
2193
2194 #[test]
2195 fn test_all_types_count() {
2196 assert_eq!(GraphEntityType::all_types().len(), 70);
2199 }
2200
2201 #[test]
2202 fn test_cross_process_link() {
2203 let link = CrossProcessLink::new(
2204 "MAT-001",
2205 "P2P",
2206 "GR-12345",
2207 "O2C",
2208 "DEL-67890",
2209 CrossProcessLinkType::InventoryMovement,
2210 Decimal::from(100),
2211 NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(),
2212 );
2213
2214 assert_eq!(link.material_id, "MAT-001");
2215 assert_eq!(link.link_type, CrossProcessLinkType::InventoryMovement);
2216 }
2217}