Skip to main content

datasynth_core/models/
relationship.rs

1//! Entity relationship graph models.
2//!
3//! Provides comprehensive relationship modeling including:
4//! - Entity graph with typed nodes and edges
5//! - Relationship strength calculation
6//! - Cross-process linkages (P2P ↔ O2C via inventory)
7//! - Network analysis support
8
9use chrono::NaiveDate;
10use rust_decimal::Decimal;
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13
14/// Type of entity in the relationship graph.
15///
16/// This is separate from `entity_registry::EntityType` as it represents
17/// the entity types specifically used in graph/network analysis.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum GraphEntityType {
21    /// Company/legal entity
22    Company,
23    /// Vendor/supplier
24    Vendor,
25    /// Customer
26    Customer,
27    /// Employee
28    Employee,
29    /// Department
30    Department,
31    /// Cost center
32    CostCenter,
33    /// Project
34    Project,
35    /// Contract
36    Contract,
37    /// Fixed asset
38    Asset,
39    /// Bank account
40    BankAccount,
41    /// Material/inventory item
42    Material,
43    /// GL account
44    GlAccount,
45    /// Purchase order
46    PurchaseOrder,
47    /// Sales order
48    SalesOrder,
49    /// Invoice
50    Invoice,
51    /// Payment
52    Payment,
53    /// Sourcing project
54    SourcingProject,
55    /// RFx event
56    RfxEvent,
57    /// Production order
58    ProductionOrder,
59    /// Bank reconciliation
60    BankReconciliation,
61
62    // ===== Tax entities (DS-001, DS-002) =====
63    /// Tax jurisdiction
64    TaxJurisdiction,
65    /// Tax code
66    TaxCode,
67    /// Tax line item
68    TaxLine,
69    /// Tax return
70    TaxReturn,
71    /// Tax provision
72    TaxProvision,
73    /// Withholding tax record
74    WithholdingTaxRecord,
75    /// Uncertain tax position
76    UncertainTaxPosition,
77
78    // ===== Treasury entities (DS-003) =====
79    /// Cash position
80    CashPosition,
81    /// Cash forecast
82    CashForecast,
83    /// Cash pool
84    CashPool,
85    /// Cash pool sweep
86    CashPoolSweep,
87    /// Hedging instrument
88    HedgingInstrument,
89    /// Hedge relationship
90    HedgeRelationship,
91    /// Debt instrument
92    DebtInstrument,
93    /// Debt covenant
94    DebtCovenant,
95
96    // ===== ESG entities (DS-004) =====
97    /// Emission record
98    EmissionRecord,
99    /// Energy consumption
100    EnergyConsumption,
101    /// Water usage
102    WaterUsage,
103    /// Waste record
104    WasteRecord,
105    /// Workforce diversity metric
106    WorkforceDiversityMetric,
107    /// Pay equity metric
108    PayEquityMetric,
109    /// Safety incident
110    SafetyIncident,
111    /// Safety metric
112    SafetyMetric,
113    /// Governance metric
114    GovernanceMetric,
115    /// Supplier ESG assessment
116    SupplierEsgAssessment,
117    /// Materiality assessment
118    MaterialityAssessment,
119    /// ESG disclosure
120    EsgDisclosure,
121    /// Climate scenario
122    ClimateScenario,
123
124    // ===== Project entities (DS-005) =====
125    /// Project cost line
126    ProjectCostLine,
127    /// Project revenue
128    ProjectRevenue,
129    /// Earned value metric
130    EarnedValueMetric,
131    /// Change order
132    ChangeOrder,
133    /// Project milestone
134    ProjectMilestone,
135
136    // ===== S2C entities (DS-006) =====
137    /// Supplier bid
138    SupplierBid,
139    /// Bid evaluation
140    BidEvaluation,
141    /// Procurement contract
142    ProcurementContract,
143    /// Supplier qualification
144    SupplierQualification,
145
146    // ===== H2R entities (DS-007) =====
147    /// Payroll run
148    PayrollRun,
149    /// Time entry
150    TimeEntry,
151    /// Expense report
152    ExpenseReport,
153    /// Benefit enrollment
154    BenefitEnrollment,
155
156    // ===== MFG entities (DS-008) =====
157    /// Quality inspection
158    QualityInspection,
159    /// Cycle count
160    CycleCount,
161    /// BOM component
162    BomComponent,
163    /// Inventory movement
164    InventoryMovement,
165
166    // ===== GOV entities (DS-009) =====
167    /// COSO component
168    CosoComponent,
169    /// COSO principle
170    CosoPrinciple,
171    /// SOX assertion
172    SoxAssertion,
173    /// Audit engagement
174    AuditEngagement,
175    /// Professional judgment
176    ProfessionalJudgment,
177}
178
179impl GraphEntityType {
180    /// Get the entity type code.
181    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            // Tax
204            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            // Treasury
212            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            // ESG
221            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            // Project
235            Self::ProjectCostLine => "PL",
236            Self::ProjectRevenue => "PV",
237            Self::EarnedValueMetric => "EV",
238            Self::ChangeOrder => "CR",
239            Self::ProjectMilestone => "MS",
240            // S2C
241            Self::SupplierBid => "BD",
242            Self::BidEvaluation => "BE",
243            Self::ProcurementContract => "PC",
244            Self::SupplierQualification => "SQ",
245            // H2R
246            Self::PayrollRun => "PY",
247            Self::TimeEntry => "TE",
248            Self::ExpenseReport => "EX",
249            Self::BenefitEnrollment => "BN",
250            // MFG
251            Self::QualityInspection => "QI",
252            Self::CycleCount => "CY",
253            Self::BomComponent => "BC",
254            Self::InventoryMovement => "IM",
255            // GOV
256            Self::CosoComponent => "GC",
257            Self::CosoPrinciple => "GP",
258            Self::SoxAssertion => "SA",
259            Self::AuditEngagement => "AE",
260            Self::ProfessionalJudgment => "JD",
261        }
262    }
263
264    /// Numeric entity type code for registry.
265    pub fn numeric_code(&self) -> u16 {
266        match self {
267            // Existing types (100-series)
268            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            // S2C
287            Self::SourcingProject => 320,
288            Self::RfxEvent => 321,
289            Self::SupplierBid => 322,
290            Self::BidEvaluation => 323,
291            Self::ProcurementContract => 324,
292            Self::SupplierQualification => 325,
293            // H2R
294            Self::PayrollRun => 330,
295            Self::TimeEntry => 331,
296            Self::ExpenseReport => 332,
297            Self::BenefitEnrollment => 333,
298            // MFG
299            Self::ProductionOrder => 340,
300            Self::QualityInspection => 341,
301            Self::CycleCount => 342,
302            Self::BomComponent => 343,
303            // Tax
304            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            // Treasury
312            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            // ESG
321            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            // Project
335            Self::ProjectCostLine => 451,
336            Self::ProjectRevenue => 452,
337            Self::EarnedValueMetric => 453,
338            Self::ChangeOrder => 454,
339            Self::ProjectMilestone => 455,
340            // GOV
341            Self::CosoComponent => 500,
342            Self::CosoPrinciple => 501,
343            Self::SoxAssertion => 502,
344            Self::AuditEngagement => 360,
345            Self::ProfessionalJudgment => 365,
346        }
347    }
348
349    /// Entity type name (snake_case) for graph export.
350    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            // Tax
373            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            // Treasury
381            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            // ESG
390            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            // Project
404            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            // S2C
410            Self::SupplierBid => "supplier_bid",
411            Self::BidEvaluation => "bid_evaluation",
412            Self::ProcurementContract => "procurement_contract",
413            Self::SupplierQualification => "supplier_qualification",
414            // H2R
415            Self::PayrollRun => "payroll_run",
416            Self::TimeEntry => "time_entry",
417            Self::ExpenseReport => "expense_report",
418            Self::BenefitEnrollment => "benefit_enrollment",
419            // MFG
420            Self::QualityInspection => "quality_inspection",
421            Self::CycleCount => "cycle_count",
422            Self::BomComponent => "bom_component",
423            Self::InventoryMovement => "inventory_movement",
424            // GOV
425            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    /// Lookup entity type by numeric code.
434    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    /// Lookup entity type by snake_case name.
442    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    /// All registered entity types.
450    pub fn all_types() -> &'static [GraphEntityType] {
451        &[
452            // Original
453            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            // Tax
474            Self::TaxJurisdiction,
475            Self::TaxCode,
476            Self::TaxLine,
477            Self::TaxReturn,
478            Self::TaxProvision,
479            Self::WithholdingTaxRecord,
480            Self::UncertainTaxPosition,
481            // Treasury
482            Self::CashPosition,
483            Self::CashForecast,
484            Self::CashPool,
485            Self::CashPoolSweep,
486            Self::HedgingInstrument,
487            Self::HedgeRelationship,
488            Self::DebtInstrument,
489            Self::DebtCovenant,
490            // ESG
491            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            // Project
505            Self::ProjectCostLine,
506            Self::ProjectRevenue,
507            Self::EarnedValueMetric,
508            Self::ChangeOrder,
509            Self::ProjectMilestone,
510            // S2C
511            Self::SupplierBid,
512            Self::BidEvaluation,
513            Self::ProcurementContract,
514            Self::SupplierQualification,
515            // H2R
516            Self::PayrollRun,
517            Self::TimeEntry,
518            Self::ExpenseReport,
519            Self::BenefitEnrollment,
520            // MFG
521            Self::QualityInspection,
522            Self::CycleCount,
523            Self::BomComponent,
524            Self::InventoryMovement,
525            // GOV
526            Self::CosoComponent,
527            Self::CosoPrinciple,
528            Self::SoxAssertion,
529            Self::AuditEngagement,
530            Self::ProfessionalJudgment,
531        ]
532    }
533
534    /// Check if this is a tax entity.
535    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    /// Check if this is a treasury entity.
549    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    /// Check if this is an ESG entity.
564    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    /// Check if this is a project entity.
584    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    /// Check if this is an H2R (hire-to-retire) entity.
597    pub fn is_h2r(&self) -> bool {
598        matches!(
599            self,
600            Self::PayrollRun | Self::TimeEntry | Self::ExpenseReport | Self::BenefitEnrollment
601        )
602    }
603
604    /// Check if this is a manufacturing entity.
605    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    /// Check if this is a governance entity.
618    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    /// Check if this is a master data entity.
630    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    /// Check if this is a transactional entity.
648    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/// Type of relationship between entities.
664#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
665#[serde(rename_all = "snake_case")]
666pub enum RelationshipType {
667    // ===== Transactional relationships =====
668    /// Entity buys from another entity
669    BuysFrom,
670    /// Entity sells to another entity
671    SellsTo,
672    /// Entity pays to another entity
673    PaysTo,
674    /// Entity receives payment from another entity
675    ReceivesFrom,
676    /// Supplies goods to
677    SuppliesTo,
678    /// Sources goods from
679    SourcesFrom,
680
681    // ===== Organizational relationships =====
682    /// Employee reports to manager
683    ReportsTo,
684    /// Manager manages employee
685    Manages,
686    /// Entity belongs to parent entity
687    BelongsTo,
688    /// Entity owned by another entity
689    OwnedBy,
690    /// Works in department/cost center
691    WorksIn,
692    /// Responsible for
693    ResponsibleFor,
694
695    // ===== Network relationships =====
696    /// Referred by another entity
697    ReferredBy,
698    /// Partners with another entity
699    PartnersWith,
700    /// Affiliated with
701    AffiliatedWith,
702    /// Intercompany relationship
703    Intercompany,
704
705    // ===== Document relationships =====
706    /// Document references another document
707    References,
708    /// Document is referenced by another document
709    ReferencedBy,
710    /// Fulfills (e.g., delivery fulfills sales order)
711    Fulfills,
712    /// Fulfilled by
713    FulfilledBy,
714    /// Applies to (e.g., payment applies to invoice)
715    AppliesTo,
716    /// Applied by
717    AppliedBy,
718
719    // ===== Process relationships =====
720    /// Inventory links P2P to O2C
721    InventoryLink,
722    /// Material used in
723    UsedIn,
724    /// Material sourced via
725    SourcedVia,
726
727    // ===== Sourcing/procurement relationships =====
728    /// RFx awarded to vendor
729    AwardedTo,
730    /// Contract governs a purchase order
731    GovernsOrder,
732    /// Bid evaluated by evaluator
733    EvaluatedBy,
734    /// Vendor qualified as (status)
735    QualifiedAs,
736    /// Vendor scored by scorecard
737    ScoredBy,
738    /// Order sourced through contract
739    SourcedThrough,
740    /// Item belongs to catalog
741    CatalogItemOf,
742
743    // ===== Manufacturing relationships =====
744    /// Material produced by production order
745    ProducedBy,
746
747    // ===== Banking relationships =====
748    /// Payment reconciled with bank statement line
749    ReconciledWith,
750
751    // ===== P2P domain edges (DS-010) =====
752    /// PurchaseOrder → Vendor
753    PlacedWith,
754    /// VendorInvoice → PurchaseOrder
755    MatchesOrder,
756    /// Payment → VendorInvoice
757    PaysInvoice,
758
759    // ===== O2C domain edges =====
760    /// SalesOrder → Customer
761    PlacedBy,
762    /// CustomerInvoice → SalesOrder
763    BillsOrder,
764
765    // ===== S2C domain edges =====
766    /// RfxEvent → SourcingProject
767    RfxBelongsToProject,
768    /// SupplierBid → RfxEvent
769    RespondsTo,
770    /// ProcurementContract → BidEvaluation
771    AwardedFrom,
772
773    // ===== H2R domain edges =====
774    /// TimeEntry → Employee
775    RecordedBy,
776    /// PayrollRun → Employee
777    PayrollIncludes,
778    /// ExpenseReport → Employee
779    SubmittedBy,
780    /// BenefitEnrollment → Employee
781    EnrolledBy,
782
783    // ===== MFG domain edges =====
784    /// ProductionOrder → Material
785    Produces,
786    /// QualityInspection → ProductionOrder
787    Inspects,
788    /// BomComponent → Material
789    PartOf,
790
791    // ===== Tax domain edges =====
792    /// TaxLine → TaxReturn
793    TaxLineBelongsTo,
794    /// TaxProvision → TaxJurisdiction
795    ProvisionAppliesTo,
796    /// WithholdingTaxRecord → Vendor
797    WithheldFrom,
798
799    // ===== Treasury domain edges =====
800    /// CashPoolSweep → CashPool
801    SweepsTo,
802    /// HedgeRelationship → HedgingInstrument
803    HedgesInstrument,
804    /// DebtCovenant → DebtInstrument
805    GovernsInstrument,
806
807    // ===== ESG domain edges =====
808    /// EmissionRecord → Company
809    EmissionReportedBy,
810    /// SupplierEsgAssessment → Vendor
811    AssessesSupplier,
812
813    // ===== Project domain edges =====
814    /// ProjectCostLine → Project
815    CostChargedTo,
816    /// ProjectMilestone → Project
817    MilestoneOf,
818    /// ChangeOrder → Project
819    ModifiesProject,
820
821    // ===== GOV domain edges =====
822    /// CosoPrinciple → CosoComponent
823    PrincipleUnder,
824    /// SoxAssertion → GlAccount
825    AssertionCovers,
826    /// ProfessionalJudgment → AuditEngagement
827    JudgmentWithin,
828}
829
830impl RelationshipType {
831    /// Get the relationship type code.
832    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            // P2P
869            Self::PlacedWith => "PWI",
870            Self::MatchesOrder => "MO",
871            Self::PaysInvoice => "PI",
872            // O2C
873            Self::PlacedBy => "PLB",
874            Self::BillsOrder => "BO",
875            // S2C
876            Self::RfxBelongsToProject => "RBP",
877            Self::RespondsTo => "RTO",
878            Self::AwardedFrom => "AFR",
879            // H2R
880            Self::RecordedBy => "RCB",
881            Self::PayrollIncludes => "PYI",
882            Self::SubmittedBy => "SUB",
883            Self::EnrolledBy => "ENB",
884            // MFG
885            Self::Produces => "PRD",
886            Self::Inspects => "INS",
887            Self::PartOf => "POF",
888            // Tax
889            Self::TaxLineBelongsTo => "TLB",
890            Self::ProvisionAppliesTo => "PAT",
891            Self::WithheldFrom => "WHF",
892            // Treasury
893            Self::SweepsTo => "SWT",
894            Self::HedgesInstrument => "HDG",
895            Self::GovernsInstrument => "GVI",
896            // ESG
897            Self::EmissionReportedBy => "ERB",
898            Self::AssessesSupplier => "ASS",
899            // Project
900            Self::CostChargedTo => "CCT",
901            Self::MilestoneOf => "MLO",
902            Self::ModifiesProject => "MPJ",
903            // GOV
904            Self::PrincipleUnder => "PUN",
905            Self::AssertionCovers => "ACO",
906            Self::JudgmentWithin => "JWI",
907        }
908    }
909
910    /// Get the inverse relationship type.
911    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            // Symmetric relationships
930            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            // Sourcing/procurement (symmetric or self-inverse)
940            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            // New domain edges (self-inverse, as they are directed)
950            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    /// Check if this is a transactional relationship.
983    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    /// Check if this is an organizational relationship.
996    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    /// Check if this is a document relationship.
1009    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    /// Get the edge constraint for this domain-specific relationship type.
1022    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            // P2P
1034            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            // O2C
1038            Self::PlacedBy => Some(c(E::SalesOrder, E::Customer, ManyToOne)),
1039            Self::BillsOrder => Some(c(E::Invoice, E::SalesOrder, ManyToOne)),
1040            // S2C
1041            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            // H2R
1045            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            // MFG
1050            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            // Tax
1054            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            // Treasury
1058            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            // ESG
1064            Self::EmissionReportedBy => Some(c(E::EmissionRecord, E::Company, ManyToOne)),
1065            Self::AssessesSupplier => Some(c(E::SupplierEsgAssessment, E::Vendor, ManyToOne)),
1066            // Project
1067            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            // GOV
1071            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            // Existing relationship types don't have formal constraints
1075            _ => None,
1076        }
1077    }
1078
1079    /// All domain-specific edge constraints.
1080    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/// Edge cardinality constraint.
1117#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1118pub enum Cardinality {
1119    OneToOne,
1120    ManyToOne,
1121    ManyToMany,
1122}
1123
1124/// Constraint on an edge type: valid source/target entity types and cardinality.
1125#[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/// Unique identifier for an entity in the relationship graph.
1135///
1136/// This is separate from `entity_registry::EntityId` as it represents
1137/// the entity identifiers specifically used in graph/network analysis.
1138#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1139pub struct GraphEntityId {
1140    /// Entity type
1141    pub entity_type: GraphEntityType,
1142    /// Entity identifier (e.g., "V-001234")
1143    pub id: String,
1144}
1145
1146impl GraphEntityId {
1147    /// Create a new entity ID.
1148    pub fn new(entity_type: GraphEntityType, id: impl Into<String>) -> Self {
1149        Self {
1150            entity_type,
1151            id: id.into(),
1152        }
1153    }
1154
1155    /// Get the composite key for this entity.
1156    pub fn key(&self) -> String {
1157        format!("{}:{}", self.entity_type.code(), self.id)
1158    }
1159}
1160
1161/// Node in the entity graph.
1162#[derive(Debug, Clone, Serialize, Deserialize)]
1163pub struct EntityNode {
1164    /// Entity identifier
1165    pub entity_id: GraphEntityId,
1166    /// Display name
1167    pub name: String,
1168    /// Entity attributes (flexible key-value)
1169    pub attributes: HashMap<String, String>,
1170    /// Creation date
1171    pub created_date: NaiveDate,
1172    /// Is entity active
1173    pub is_active: bool,
1174    /// Company code (if applicable)
1175    pub company_code: Option<String>,
1176}
1177
1178impl EntityNode {
1179    /// Create a new entity node.
1180    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    /// Add an attribute.
1192    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    /// Set company code.
1198    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/// Edge in the entity graph representing a relationship.
1205#[derive(Debug, Clone, Serialize, Deserialize)]
1206pub struct RelationshipEdge {
1207    /// Source entity ID
1208    pub from_id: GraphEntityId,
1209    /// Target entity ID
1210    pub to_id: GraphEntityId,
1211    /// Relationship type
1212    pub relationship_type: RelationshipType,
1213    /// Relationship strength (0.0 to 1.0)
1214    pub strength: f64,
1215    /// Relationship start date
1216    pub start_date: NaiveDate,
1217    /// Relationship end date (if terminated)
1218    pub end_date: Option<NaiveDate>,
1219    /// Edge attributes
1220    pub attributes: HashMap<String, String>,
1221    /// Strength components (for analysis)
1222    pub strength_components: Option<StrengthComponents>,
1223}
1224
1225impl RelationshipEdge {
1226    /// Create a new relationship edge.
1227    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, // Default medium strength
1238            start_date,
1239            end_date: None,
1240            attributes: HashMap::new(),
1241            strength_components: None,
1242        }
1243    }
1244
1245    /// Set relationship strength.
1246    pub fn with_strength(mut self, strength: f64) -> Self {
1247        self.strength = strength.clamp(0.0, 1.0);
1248        self
1249    }
1250
1251    /// Set strength with components.
1252    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    /// Add an attribute.
1259    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    /// Check if relationship is active.
1265    pub fn is_active(&self) -> bool {
1266        self.end_date.is_none()
1267    }
1268
1269    /// Get the edge key (for deduplication).
1270    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/// Components of relationship strength calculation.
1281#[derive(Debug, Clone, Serialize, Deserialize)]
1282pub struct StrengthComponents {
1283    /// Transaction volume component (log scale, 0.0-1.0)
1284    pub transaction_volume: f64,
1285    /// Transaction count component (sqrt scale, 0.0-1.0)
1286    pub transaction_count: f64,
1287    /// Relationship duration component (0.0-1.0)
1288    pub duration: f64,
1289    /// Recency component (exp decay, 0.0-1.0)
1290    pub recency: f64,
1291    /// Mutual connections component (Jaccard, 0.0-1.0)
1292    pub mutual_connections: f64,
1293}
1294
1295impl StrengthComponents {
1296    /// Create new strength components.
1297    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    /// Calculate total strength with default weights.
1314    pub fn total(&self) -> f64 {
1315        self.total_weighted(RelationshipStrengthCalculator::default_weights())
1316    }
1317
1318    /// Calculate total strength with custom weights.
1319    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/// Weights for relationship strength calculation.
1331#[derive(Debug, Clone, Serialize, Deserialize)]
1332pub struct StrengthWeights {
1333    /// Weight for transaction volume (default: 0.30)
1334    pub transaction_volume_weight: f64,
1335    /// Weight for transaction count (default: 0.25)
1336    pub transaction_count_weight: f64,
1337    /// Weight for relationship duration (default: 0.20)
1338    pub duration_weight: f64,
1339    /// Weight for recency (default: 0.15)
1340    pub recency_weight: f64,
1341    /// Weight for mutual connections (default: 0.10)
1342    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    /// Validate that weights sum to 1.0.
1359    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/// Calculator for relationship strength.
1375#[derive(Debug, Clone, Serialize, Deserialize)]
1376pub struct RelationshipStrengthCalculator {
1377    /// Strength weights
1378    pub weights: StrengthWeights,
1379    /// Recency half-life in days (default: 90)
1380    pub recency_half_life_days: u32,
1381    /// Max transaction volume for normalization
1382    pub max_transaction_volume: Decimal,
1383    /// Max transaction count for normalization
1384    pub max_transaction_count: u32,
1385    /// Max relationship duration in days for normalization
1386    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, // 10 years
1397        }
1398    }
1399}
1400
1401impl RelationshipStrengthCalculator {
1402    /// Get default weights.
1403    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    /// Calculate relationship strength.
1409    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        // Transaction volume (log scale)
1419        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        // Transaction count (sqrt scale)
1436        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        // Duration (linear scale)
1445        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        // Recency (exponential decay)
1452        let recency_normalized = if self.recency_half_life_days > 0 {
1453            let decay_rate = 0.693 / self.recency_half_life_days as f64; // ln(2) / half_life
1454            (-decay_rate * days_since_last_transaction as f64).exp()
1455        } else {
1456            1.0
1457        };
1458
1459        // Mutual connections (Jaccard-like)
1460        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/// Relationship strength classification.
1477#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1478#[serde(rename_all = "snake_case")]
1479pub enum RelationshipStrength {
1480    /// Strong relationship (>= 0.7)
1481    Strong,
1482    /// Moderate relationship (>= 0.4)
1483    Moderate,
1484    /// Weak relationship (>= 0.1)
1485    Weak,
1486    /// Dormant relationship (< 0.1)
1487    Dormant,
1488}
1489
1490impl RelationshipStrength {
1491    /// Classify a strength value.
1492    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    /// Get the minimum threshold for this classification.
1505    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/// Indexes for efficient graph lookups.
1516#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1517pub struct GraphIndexes {
1518    /// Edges from each node
1519    pub outgoing_edges: HashMap<String, Vec<usize>>,
1520    /// Edges to each node
1521    pub incoming_edges: HashMap<String, Vec<usize>>,
1522    /// Edges by relationship type
1523    pub edges_by_type: HashMap<RelationshipType, Vec<usize>>,
1524    /// Nodes by entity type
1525    pub nodes_by_type: HashMap<GraphEntityType, Vec<String>>,
1526}
1527
1528/// Entity relationship graph.
1529#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1530pub struct EntityGraph {
1531    /// All nodes in the graph
1532    pub nodes: HashMap<String, EntityNode>,
1533    /// All edges in the graph
1534    pub edges: Vec<RelationshipEdge>,
1535    /// Graph indexes for efficient lookups
1536    #[serde(skip)]
1537    pub indexes: GraphIndexes,
1538    /// Graph metadata
1539    pub metadata: GraphMetadata,
1540}
1541
1542/// Metadata about the graph.
1543#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1544pub struct GraphMetadata {
1545    /// Company code (if single-company graph)
1546    pub company_code: Option<String>,
1547    /// Creation date
1548    pub created_date: Option<NaiveDate>,
1549    /// Total transaction volume
1550    #[serde(with = "rust_decimal::serde::str")]
1551    pub total_transaction_volume: Decimal,
1552    /// Date range covered
1553    pub date_range: Option<(NaiveDate, NaiveDate)>,
1554}
1555
1556impl EntityGraph {
1557    /// Create a new empty graph.
1558    pub fn new() -> Self {
1559        Self::default()
1560    }
1561
1562    /// Add a node to the graph.
1563    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    /// Add an edge to the graph.
1576    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    /// Get a node by entity ID.
1602    pub fn get_node(&self, entity_id: &GraphEntityId) -> Option<&EntityNode> {
1603        self.nodes.get(&entity_id.key())
1604    }
1605
1606    /// Get outgoing edges from a node.
1607    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    /// Get incoming edges to a node.
1616    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    /// Get edges by relationship type.
1625    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    /// Get all nodes of a specific type.
1634    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    /// Find neighbors of a node (nodes connected by edges).
1643    pub fn get_neighbors(&self, entity_id: &GraphEntityId) -> Vec<&EntityNode> {
1644        let mut neighbor_ids: HashSet<String> = HashSet::new();
1645
1646        // Outgoing edges
1647        for edge in self.get_outgoing_edges(entity_id) {
1648            neighbor_ids.insert(edge.to_id.key());
1649        }
1650
1651        // Incoming edges
1652        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    /// Calculate the degree of a node (total edges in + out).
1663    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    /// Rebuild indexes (call after deserialization).
1681    pub fn rebuild_indexes(&mut self) {
1682        self.indexes = GraphIndexes::default();
1683
1684        // Rebuild node type index
1685        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        // Rebuild edge indexes
1694        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    /// Get graph statistics.
1714    pub fn statistics(&self) -> GraphStatistics {
1715        let node_count = self.nodes.len();
1716        let edge_count = self.edges.len();
1717
1718        // Calculate average degree
1719        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        // Calculate average strength
1726        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        // Count nodes by type
1733        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        // Count edges by type
1741        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        // Count strength distribution
1749        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/// Statistics about the graph.
1770#[derive(Debug, Clone, Serialize, Deserialize)]
1771pub struct GraphStatistics {
1772    /// Total number of nodes
1773    pub node_count: usize,
1774    /// Total number of edges
1775    pub edge_count: usize,
1776    /// Average degree (edges per node)
1777    pub avg_degree: f64,
1778    /// Average edge strength
1779    pub avg_strength: f64,
1780    /// Node counts by type
1781    pub node_counts: HashMap<String, usize>,
1782    /// Edge counts by relationship type
1783    pub edge_counts: HashMap<String, usize>,
1784    /// Edge counts by strength classification
1785    pub strength_distribution: HashMap<String, usize>,
1786}
1787
1788/// Cross-process link connecting P2P and O2C via inventory.
1789#[derive(Debug, Clone, Serialize, Deserialize)]
1790pub struct CrossProcessLink {
1791    /// Material ID linking the processes
1792    pub material_id: String,
1793    /// Source process (e.g., P2P)
1794    pub source_process: String,
1795    /// Source document ID
1796    pub source_document_id: String,
1797    /// Target process (e.g., O2C)
1798    pub target_process: String,
1799    /// Target document ID
1800    pub target_document_id: String,
1801    /// Link type
1802    pub link_type: CrossProcessLinkType,
1803    /// Quantity involved
1804    #[serde(with = "rust_decimal::serde::str")]
1805    pub quantity: Decimal,
1806    /// Link date
1807    pub link_date: NaiveDate,
1808}
1809
1810/// Type of cross-process link.
1811#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1812#[serde(rename_all = "snake_case")]
1813pub enum CrossProcessLinkType {
1814    /// Inventory movement links GR to delivery
1815    InventoryMovement,
1816    /// Return flow from O2C back to P2P
1817    ReturnFlow,
1818    /// Payment reconciliation
1819    PaymentReconciliation,
1820    /// Intercompany bilateral matching
1821    IntercompanyBilateral,
1822}
1823
1824impl CrossProcessLink {
1825    /// Create a new cross-process link.
1826    #[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            // Source and target types should have valid numeric codes
2105            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        // P2P
2114        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        // O2C
2121        assert!(constraints
2122            .iter()
2123            .any(|c| c.relationship_type == RelationshipType::PlacedBy));
2124        // Tax
2125        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        // Treasury
2132        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        // ESG
2139        assert!(constraints
2140            .iter()
2141            .any(|c| c.relationship_type == RelationshipType::EmissionReportedBy));
2142        // Project
2143        assert!(constraints
2144            .iter()
2145            .any(|c| c.relationship_type == RelationshipType::CostChargedTo));
2146        // GOV
2147        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        // Pre-existing relationship types should return None for constraint
2177        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        // 20 original + 7 tax + 8 treasury + 13 ESG + 5 project
2197        // + 4 S2C + 4 H2R + 4 MFG + 5 GOV = 70
2198        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}