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