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
63impl GraphEntityType {
64    /// Get the entity type code.
65    pub fn code(&self) -> &'static str {
66        match self {
67            Self::Company => "CO",
68            Self::Vendor => "VN",
69            Self::Customer => "CU",
70            Self::Employee => "EM",
71            Self::Department => "DP",
72            Self::CostCenter => "CC",
73            Self::Project => "PJ",
74            Self::Contract => "CT",
75            Self::Asset => "AS",
76            Self::BankAccount => "BA",
77            Self::Material => "MT",
78            Self::GlAccount => "GL",
79            Self::PurchaseOrder => "PO",
80            Self::SalesOrder => "SO",
81            Self::Invoice => "IV",
82            Self::Payment => "PM",
83            Self::SourcingProject => "SP",
84            Self::RfxEvent => "RX",
85            Self::ProductionOrder => "PR",
86            Self::BankReconciliation => "BR",
87        }
88    }
89
90    /// Check if this is a master data entity.
91    pub fn is_master_data(&self) -> bool {
92        matches!(
93            self,
94            Self::Company
95                | Self::Vendor
96                | Self::Customer
97                | Self::Employee
98                | Self::Department
99                | Self::CostCenter
100                | Self::Material
101                | Self::GlAccount
102        )
103    }
104
105    /// Check if this is a transactional entity.
106    pub fn is_transactional(&self) -> bool {
107        matches!(
108            self,
109            Self::PurchaseOrder | Self::SalesOrder | Self::Invoice | Self::Payment
110        )
111    }
112}
113
114/// Type of relationship between entities.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum RelationshipType {
118    // ===== Transactional relationships =====
119    /// Entity buys from another entity
120    BuysFrom,
121    /// Entity sells to another entity
122    SellsTo,
123    /// Entity pays to another entity
124    PaysTo,
125    /// Entity receives payment from another entity
126    ReceivesFrom,
127    /// Supplies goods to
128    SuppliesTo,
129    /// Sources goods from
130    SourcesFrom,
131
132    // ===== Organizational relationships =====
133    /// Employee reports to manager
134    ReportsTo,
135    /// Manager manages employee
136    Manages,
137    /// Entity belongs to parent entity
138    BelongsTo,
139    /// Entity owned by another entity
140    OwnedBy,
141    /// Works in department/cost center
142    WorksIn,
143    /// Responsible for
144    ResponsibleFor,
145
146    // ===== Network relationships =====
147    /// Referred by another entity
148    ReferredBy,
149    /// Partners with another entity
150    PartnersWith,
151    /// Affiliated with
152    AffiliatedWith,
153    /// Intercompany relationship
154    Intercompany,
155
156    // ===== Document relationships =====
157    /// Document references another document
158    References,
159    /// Document is referenced by another document
160    ReferencedBy,
161    /// Fulfills (e.g., delivery fulfills sales order)
162    Fulfills,
163    /// Fulfilled by
164    FulfilledBy,
165    /// Applies to (e.g., payment applies to invoice)
166    AppliesTo,
167    /// Applied by
168    AppliedBy,
169
170    // ===== Process relationships =====
171    /// Inventory links P2P to O2C
172    InventoryLink,
173    /// Material used in
174    UsedIn,
175    /// Material sourced via
176    SourcedVia,
177
178    // ===== Sourcing/procurement relationships =====
179    /// RFx awarded to vendor
180    AwardedTo,
181    /// Contract governs a purchase order
182    GovernsOrder,
183    /// Bid evaluated by evaluator
184    EvaluatedBy,
185    /// Vendor qualified as (status)
186    QualifiedAs,
187    /// Vendor scored by scorecard
188    ScoredBy,
189    /// Order sourced through contract
190    SourcedThrough,
191    /// Item belongs to catalog
192    CatalogItemOf,
193
194    // ===== Manufacturing relationships =====
195    /// Material produced by production order
196    ProducedBy,
197
198    // ===== Banking relationships =====
199    /// Payment reconciled with bank statement line
200    ReconciledWith,
201}
202
203impl RelationshipType {
204    /// Get the relationship type code.
205    pub fn code(&self) -> &'static str {
206        match self {
207            Self::BuysFrom => "BF",
208            Self::SellsTo => "ST",
209            Self::PaysTo => "PT",
210            Self::ReceivesFrom => "RF",
211            Self::SuppliesTo => "SP",
212            Self::SourcesFrom => "SF",
213            Self::ReportsTo => "RT",
214            Self::Manages => "MG",
215            Self::BelongsTo => "BT",
216            Self::OwnedBy => "OB",
217            Self::WorksIn => "WI",
218            Self::ResponsibleFor => "RS",
219            Self::ReferredBy => "RB",
220            Self::PartnersWith => "PW",
221            Self::AffiliatedWith => "AW",
222            Self::Intercompany => "IC",
223            Self::References => "REF",
224            Self::ReferencedBy => "RBY",
225            Self::Fulfills => "FL",
226            Self::FulfilledBy => "FLB",
227            Self::AppliesTo => "AP",
228            Self::AppliedBy => "APB",
229            Self::InventoryLink => "INV",
230            Self::UsedIn => "UI",
231            Self::SourcedVia => "SV",
232            Self::AwardedTo => "AT",
233            Self::GovernsOrder => "GO",
234            Self::EvaluatedBy => "EB",
235            Self::QualifiedAs => "QA",
236            Self::ScoredBy => "SB",
237            Self::SourcedThrough => "STH",
238            Self::CatalogItemOf => "CIO",
239            Self::ProducedBy => "PB",
240            Self::ReconciledWith => "RW",
241        }
242    }
243
244    /// Get the inverse relationship type.
245    pub fn inverse(&self) -> Self {
246        match self {
247            Self::BuysFrom => Self::SellsTo,
248            Self::SellsTo => Self::BuysFrom,
249            Self::PaysTo => Self::ReceivesFrom,
250            Self::ReceivesFrom => Self::PaysTo,
251            Self::SuppliesTo => Self::SourcesFrom,
252            Self::SourcesFrom => Self::SuppliesTo,
253            Self::ReportsTo => Self::Manages,
254            Self::Manages => Self::ReportsTo,
255            Self::BelongsTo => Self::OwnedBy,
256            Self::OwnedBy => Self::BelongsTo,
257            Self::References => Self::ReferencedBy,
258            Self::ReferencedBy => Self::References,
259            Self::Fulfills => Self::FulfilledBy,
260            Self::FulfilledBy => Self::Fulfills,
261            Self::AppliesTo => Self::AppliedBy,
262            Self::AppliedBy => Self::AppliesTo,
263            // Symmetric relationships
264            Self::WorksIn => Self::WorksIn,
265            Self::ResponsibleFor => Self::ResponsibleFor,
266            Self::ReferredBy => Self::ReferredBy,
267            Self::PartnersWith => Self::PartnersWith,
268            Self::AffiliatedWith => Self::AffiliatedWith,
269            Self::Intercompany => Self::Intercompany,
270            Self::InventoryLink => Self::InventoryLink,
271            Self::UsedIn => Self::UsedIn,
272            Self::SourcedVia => Self::SourcedVia,
273            // Sourcing/procurement (symmetric or self-inverse)
274            Self::AwardedTo => Self::AwardedTo,
275            Self::GovernsOrder => Self::GovernsOrder,
276            Self::EvaluatedBy => Self::EvaluatedBy,
277            Self::QualifiedAs => Self::QualifiedAs,
278            Self::ScoredBy => Self::ScoredBy,
279            Self::SourcedThrough => Self::SourcedThrough,
280            Self::CatalogItemOf => Self::CatalogItemOf,
281            Self::ProducedBy => Self::ProducedBy,
282            Self::ReconciledWith => Self::ReconciledWith,
283        }
284    }
285
286    /// Check if this is a transactional relationship.
287    pub fn is_transactional(&self) -> bool {
288        matches!(
289            self,
290            Self::BuysFrom
291                | Self::SellsTo
292                | Self::PaysTo
293                | Self::ReceivesFrom
294                | Self::SuppliesTo
295                | Self::SourcesFrom
296        )
297    }
298
299    /// Check if this is an organizational relationship.
300    pub fn is_organizational(&self) -> bool {
301        matches!(
302            self,
303            Self::ReportsTo
304                | Self::Manages
305                | Self::BelongsTo
306                | Self::OwnedBy
307                | Self::WorksIn
308                | Self::ResponsibleFor
309        )
310    }
311
312    /// Check if this is a document relationship.
313    pub fn is_document(&self) -> bool {
314        matches!(
315            self,
316            Self::References
317                | Self::ReferencedBy
318                | Self::Fulfills
319                | Self::FulfilledBy
320                | Self::AppliesTo
321                | Self::AppliedBy
322        )
323    }
324}
325
326/// Unique identifier for an entity in the relationship graph.
327///
328/// This is separate from `entity_registry::EntityId` as it represents
329/// the entity identifiers specifically used in graph/network analysis.
330#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
331pub struct GraphEntityId {
332    /// Entity type
333    pub entity_type: GraphEntityType,
334    /// Entity identifier (e.g., "V-001234")
335    pub id: String,
336}
337
338impl GraphEntityId {
339    /// Create a new entity ID.
340    pub fn new(entity_type: GraphEntityType, id: impl Into<String>) -> Self {
341        Self {
342            entity_type,
343            id: id.into(),
344        }
345    }
346
347    /// Get the composite key for this entity.
348    pub fn key(&self) -> String {
349        format!("{}:{}", self.entity_type.code(), self.id)
350    }
351}
352
353/// Node in the entity graph.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct EntityNode {
356    /// Entity identifier
357    pub entity_id: GraphEntityId,
358    /// Display name
359    pub name: String,
360    /// Entity attributes (flexible key-value)
361    pub attributes: HashMap<String, String>,
362    /// Creation date
363    pub created_date: NaiveDate,
364    /// Is entity active
365    pub is_active: bool,
366    /// Company code (if applicable)
367    pub company_code: Option<String>,
368}
369
370impl EntityNode {
371    /// Create a new entity node.
372    pub fn new(entity_id: GraphEntityId, name: impl Into<String>, created_date: NaiveDate) -> Self {
373        Self {
374            entity_id,
375            name: name.into(),
376            attributes: HashMap::new(),
377            created_date,
378            is_active: true,
379            company_code: None,
380        }
381    }
382
383    /// Add an attribute.
384    pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
385        self.attributes.insert(key.into(), value.into());
386        self
387    }
388
389    /// Set company code.
390    pub fn with_company(mut self, company_code: impl Into<String>) -> Self {
391        self.company_code = Some(company_code.into());
392        self
393    }
394}
395
396/// Edge in the entity graph representing a relationship.
397#[derive(Debug, Clone, Serialize, Deserialize)]
398pub struct RelationshipEdge {
399    /// Source entity ID
400    pub from_id: GraphEntityId,
401    /// Target entity ID
402    pub to_id: GraphEntityId,
403    /// Relationship type
404    pub relationship_type: RelationshipType,
405    /// Relationship strength (0.0 to 1.0)
406    pub strength: f64,
407    /// Relationship start date
408    pub start_date: NaiveDate,
409    /// Relationship end date (if terminated)
410    pub end_date: Option<NaiveDate>,
411    /// Edge attributes
412    pub attributes: HashMap<String, String>,
413    /// Strength components (for analysis)
414    pub strength_components: Option<StrengthComponents>,
415}
416
417impl RelationshipEdge {
418    /// Create a new relationship edge.
419    pub fn new(
420        from_id: GraphEntityId,
421        to_id: GraphEntityId,
422        relationship_type: RelationshipType,
423        start_date: NaiveDate,
424    ) -> Self {
425        Self {
426            from_id,
427            to_id,
428            relationship_type,
429            strength: 0.5, // Default medium strength
430            start_date,
431            end_date: None,
432            attributes: HashMap::new(),
433            strength_components: None,
434        }
435    }
436
437    /// Set relationship strength.
438    pub fn with_strength(mut self, strength: f64) -> Self {
439        self.strength = strength.clamp(0.0, 1.0);
440        self
441    }
442
443    /// Set strength with components.
444    pub fn with_strength_components(mut self, components: StrengthComponents) -> Self {
445        self.strength = components.total();
446        self.strength_components = Some(components);
447        self
448    }
449
450    /// Add an attribute.
451    pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
452        self.attributes.insert(key.into(), value.into());
453        self
454    }
455
456    /// Check if relationship is active.
457    pub fn is_active(&self) -> bool {
458        self.end_date.is_none()
459    }
460
461    /// Get the edge key (for deduplication).
462    pub fn key(&self) -> String {
463        format!(
464            "{}->{}:{}",
465            self.from_id.key(),
466            self.to_id.key(),
467            self.relationship_type.code()
468        )
469    }
470}
471
472/// Components of relationship strength calculation.
473#[derive(Debug, Clone, Serialize, Deserialize)]
474pub struct StrengthComponents {
475    /// Transaction volume component (log scale, 0.0-1.0)
476    pub transaction_volume: f64,
477    /// Transaction count component (sqrt scale, 0.0-1.0)
478    pub transaction_count: f64,
479    /// Relationship duration component (0.0-1.0)
480    pub duration: f64,
481    /// Recency component (exp decay, 0.0-1.0)
482    pub recency: f64,
483    /// Mutual connections component (Jaccard, 0.0-1.0)
484    pub mutual_connections: f64,
485}
486
487impl StrengthComponents {
488    /// Create new strength components.
489    pub fn new(
490        transaction_volume: f64,
491        transaction_count: f64,
492        duration: f64,
493        recency: f64,
494        mutual_connections: f64,
495    ) -> Self {
496        Self {
497            transaction_volume: transaction_volume.clamp(0.0, 1.0),
498            transaction_count: transaction_count.clamp(0.0, 1.0),
499            duration: duration.clamp(0.0, 1.0),
500            recency: recency.clamp(0.0, 1.0),
501            mutual_connections: mutual_connections.clamp(0.0, 1.0),
502        }
503    }
504
505    /// Calculate total strength with default weights.
506    pub fn total(&self) -> f64 {
507        self.total_weighted(RelationshipStrengthCalculator::default_weights())
508    }
509
510    /// Calculate total strength with custom weights.
511    pub fn total_weighted(&self, weights: &StrengthWeights) -> f64 {
512        let total = self.transaction_volume * weights.transaction_volume_weight
513            + self.transaction_count * weights.transaction_count_weight
514            + self.duration * weights.duration_weight
515            + self.recency * weights.recency_weight
516            + self.mutual_connections * weights.mutual_connections_weight;
517
518        total.clamp(0.0, 1.0)
519    }
520}
521
522/// Weights for relationship strength calculation.
523#[derive(Debug, Clone, Serialize, Deserialize)]
524pub struct StrengthWeights {
525    /// Weight for transaction volume (default: 0.30)
526    pub transaction_volume_weight: f64,
527    /// Weight for transaction count (default: 0.25)
528    pub transaction_count_weight: f64,
529    /// Weight for relationship duration (default: 0.20)
530    pub duration_weight: f64,
531    /// Weight for recency (default: 0.15)
532    pub recency_weight: f64,
533    /// Weight for mutual connections (default: 0.10)
534    pub mutual_connections_weight: f64,
535}
536
537impl Default for StrengthWeights {
538    fn default() -> Self {
539        Self {
540            transaction_volume_weight: 0.30,
541            transaction_count_weight: 0.25,
542            duration_weight: 0.20,
543            recency_weight: 0.15,
544            mutual_connections_weight: 0.10,
545        }
546    }
547}
548
549impl StrengthWeights {
550    /// Validate that weights sum to 1.0.
551    pub fn validate(&self) -> Result<(), String> {
552        let sum = self.transaction_volume_weight
553            + self.transaction_count_weight
554            + self.duration_weight
555            + self.recency_weight
556            + self.mutual_connections_weight;
557
558        if (sum - 1.0).abs() > 0.01 {
559            Err(format!("Strength weights must sum to 1.0, got {}", sum))
560        } else {
561            Ok(())
562        }
563    }
564}
565
566/// Calculator for relationship strength.
567#[derive(Debug, Clone, Serialize, Deserialize)]
568pub struct RelationshipStrengthCalculator {
569    /// Strength weights
570    pub weights: StrengthWeights,
571    /// Recency half-life in days (default: 90)
572    pub recency_half_life_days: u32,
573    /// Max transaction volume for normalization
574    pub max_transaction_volume: Decimal,
575    /// Max transaction count for normalization
576    pub max_transaction_count: u32,
577    /// Max relationship duration in days for normalization
578    pub max_duration_days: u32,
579}
580
581impl Default for RelationshipStrengthCalculator {
582    fn default() -> Self {
583        Self {
584            weights: StrengthWeights::default(),
585            recency_half_life_days: 90,
586            max_transaction_volume: Decimal::from(10_000_000),
587            max_transaction_count: 1000,
588            max_duration_days: 3650, // 10 years
589        }
590    }
591}
592
593impl RelationshipStrengthCalculator {
594    /// Get default weights.
595    pub fn default_weights() -> &'static StrengthWeights {
596        static WEIGHTS: std::sync::OnceLock<StrengthWeights> = std::sync::OnceLock::new();
597        WEIGHTS.get_or_init(StrengthWeights::default)
598    }
599
600    /// Calculate relationship strength.
601    pub fn calculate(
602        &self,
603        transaction_volume: Decimal,
604        transaction_count: u32,
605        relationship_days: u32,
606        days_since_last_transaction: u32,
607        mutual_connections: usize,
608        total_possible_connections: usize,
609    ) -> StrengthComponents {
610        // Transaction volume (log scale)
611        let volume_normalized = if transaction_volume > Decimal::ZERO
612            && self.max_transaction_volume > Decimal::ZERO
613        {
614            let log_vol = (transaction_volume.to_string().parse::<f64>().unwrap_or(1.0) + 1.0).ln();
615            let log_max = (self
616                .max_transaction_volume
617                .to_string()
618                .parse::<f64>()
619                .unwrap_or(1.0)
620                + 1.0)
621                .ln();
622            (log_vol / log_max).min(1.0)
623        } else {
624            0.0
625        };
626
627        // Transaction count (sqrt scale)
628        let count_normalized = if self.max_transaction_count > 0 {
629            let sqrt_count = (transaction_count as f64).sqrt();
630            let sqrt_max = (self.max_transaction_count as f64).sqrt();
631            (sqrt_count / sqrt_max).min(1.0)
632        } else {
633            0.0
634        };
635
636        // Duration (linear scale)
637        let duration_normalized = if self.max_duration_days > 0 {
638            (relationship_days as f64 / self.max_duration_days as f64).min(1.0)
639        } else {
640            0.0
641        };
642
643        // Recency (exponential decay)
644        let recency_normalized = if self.recency_half_life_days > 0 {
645            let decay_rate = 0.693 / self.recency_half_life_days as f64; // ln(2) / half_life
646            (-decay_rate * days_since_last_transaction as f64).exp()
647        } else {
648            1.0
649        };
650
651        // Mutual connections (Jaccard-like)
652        let mutual_normalized = if total_possible_connections > 0 {
653            mutual_connections as f64 / total_possible_connections as f64
654        } else {
655            0.0
656        };
657
658        StrengthComponents::new(
659            volume_normalized,
660            count_normalized,
661            duration_normalized,
662            recency_normalized,
663            mutual_normalized,
664        )
665    }
666}
667
668/// Relationship strength classification.
669#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
670#[serde(rename_all = "snake_case")]
671pub enum RelationshipStrength {
672    /// Strong relationship (>= 0.7)
673    Strong,
674    /// Moderate relationship (>= 0.4)
675    Moderate,
676    /// Weak relationship (>= 0.1)
677    Weak,
678    /// Dormant relationship (< 0.1)
679    Dormant,
680}
681
682impl RelationshipStrength {
683    /// Classify a strength value.
684    pub fn from_value(strength: f64) -> Self {
685        if strength >= 0.7 {
686            Self::Strong
687        } else if strength >= 0.4 {
688            Self::Moderate
689        } else if strength >= 0.1 {
690            Self::Weak
691        } else {
692            Self::Dormant
693        }
694    }
695
696    /// Get the minimum threshold for this classification.
697    pub fn min_threshold(&self) -> f64 {
698        match self {
699            Self::Strong => 0.7,
700            Self::Moderate => 0.4,
701            Self::Weak => 0.1,
702            Self::Dormant => 0.0,
703        }
704    }
705}
706
707/// Indexes for efficient graph lookups.
708#[derive(Debug, Clone, Default, Serialize, Deserialize)]
709pub struct GraphIndexes {
710    /// Edges from each node
711    pub outgoing_edges: HashMap<String, Vec<usize>>,
712    /// Edges to each node
713    pub incoming_edges: HashMap<String, Vec<usize>>,
714    /// Edges by relationship type
715    pub edges_by_type: HashMap<RelationshipType, Vec<usize>>,
716    /// Nodes by entity type
717    pub nodes_by_type: HashMap<GraphEntityType, Vec<String>>,
718}
719
720/// Entity relationship graph.
721#[derive(Debug, Clone, Default, Serialize, Deserialize)]
722pub struct EntityGraph {
723    /// All nodes in the graph
724    pub nodes: HashMap<String, EntityNode>,
725    /// All edges in the graph
726    pub edges: Vec<RelationshipEdge>,
727    /// Graph indexes for efficient lookups
728    #[serde(skip)]
729    pub indexes: GraphIndexes,
730    /// Graph metadata
731    pub metadata: GraphMetadata,
732}
733
734/// Metadata about the graph.
735#[derive(Debug, Clone, Default, Serialize, Deserialize)]
736pub struct GraphMetadata {
737    /// Company code (if single-company graph)
738    pub company_code: Option<String>,
739    /// Creation date
740    pub created_date: Option<NaiveDate>,
741    /// Total transaction volume
742    #[serde(with = "rust_decimal::serde::str")]
743    pub total_transaction_volume: Decimal,
744    /// Date range covered
745    pub date_range: Option<(NaiveDate, NaiveDate)>,
746}
747
748impl EntityGraph {
749    /// Create a new empty graph.
750    pub fn new() -> Self {
751        Self::default()
752    }
753
754    /// Add a node to the graph.
755    pub fn add_node(&mut self, node: EntityNode) {
756        let key = node.entity_id.key();
757        let entity_type = node.entity_id.entity_type;
758
759        self.nodes.insert(key.clone(), node);
760        self.indexes
761            .nodes_by_type
762            .entry(entity_type)
763            .or_default()
764            .push(key);
765    }
766
767    /// Add an edge to the graph.
768    pub fn add_edge(&mut self, edge: RelationshipEdge) {
769        let edge_idx = self.edges.len();
770        let from_key = edge.from_id.key();
771        let to_key = edge.to_id.key();
772        let rel_type = edge.relationship_type;
773
774        self.indexes
775            .outgoing_edges
776            .entry(from_key)
777            .or_default()
778            .push(edge_idx);
779        self.indexes
780            .incoming_edges
781            .entry(to_key)
782            .or_default()
783            .push(edge_idx);
784        self.indexes
785            .edges_by_type
786            .entry(rel_type)
787            .or_default()
788            .push(edge_idx);
789
790        self.edges.push(edge);
791    }
792
793    /// Get a node by entity ID.
794    pub fn get_node(&self, entity_id: &GraphEntityId) -> Option<&EntityNode> {
795        self.nodes.get(&entity_id.key())
796    }
797
798    /// Get outgoing edges from a node.
799    pub fn get_outgoing_edges(&self, entity_id: &GraphEntityId) -> Vec<&RelationshipEdge> {
800        self.indexes
801            .outgoing_edges
802            .get(&entity_id.key())
803            .map(|indices| indices.iter().map(|&idx| &self.edges[idx]).collect())
804            .unwrap_or_default()
805    }
806
807    /// Get incoming edges to a node.
808    pub fn get_incoming_edges(&self, entity_id: &GraphEntityId) -> Vec<&RelationshipEdge> {
809        self.indexes
810            .incoming_edges
811            .get(&entity_id.key())
812            .map(|indices| indices.iter().map(|&idx| &self.edges[idx]).collect())
813            .unwrap_or_default()
814    }
815
816    /// Get edges by relationship type.
817    pub fn get_edges_by_type(&self, rel_type: RelationshipType) -> Vec<&RelationshipEdge> {
818        self.indexes
819            .edges_by_type
820            .get(&rel_type)
821            .map(|indices| indices.iter().map(|&idx| &self.edges[idx]).collect())
822            .unwrap_or_default()
823    }
824
825    /// Get all nodes of a specific type.
826    pub fn get_nodes_by_type(&self, entity_type: GraphEntityType) -> Vec<&EntityNode> {
827        self.indexes
828            .nodes_by_type
829            .get(&entity_type)
830            .map(|keys| keys.iter().filter_map(|k| self.nodes.get(k)).collect())
831            .unwrap_or_default()
832    }
833
834    /// Find neighbors of a node (nodes connected by edges).
835    pub fn get_neighbors(&self, entity_id: &GraphEntityId) -> Vec<&EntityNode> {
836        let mut neighbor_ids: HashSet<String> = HashSet::new();
837
838        // Outgoing edges
839        for edge in self.get_outgoing_edges(entity_id) {
840            neighbor_ids.insert(edge.to_id.key());
841        }
842
843        // Incoming edges
844        for edge in self.get_incoming_edges(entity_id) {
845            neighbor_ids.insert(edge.from_id.key());
846        }
847
848        neighbor_ids
849            .iter()
850            .filter_map(|key| self.nodes.get(key))
851            .collect()
852    }
853
854    /// Calculate the degree of a node (total edges in + out).
855    pub fn node_degree(&self, entity_id: &GraphEntityId) -> usize {
856        let key = entity_id.key();
857        let out_degree = self
858            .indexes
859            .outgoing_edges
860            .get(&key)
861            .map(|v| v.len())
862            .unwrap_or(0);
863        let in_degree = self
864            .indexes
865            .incoming_edges
866            .get(&key)
867            .map(|v| v.len())
868            .unwrap_or(0);
869        out_degree + in_degree
870    }
871
872    /// Rebuild indexes (call after deserialization).
873    pub fn rebuild_indexes(&mut self) {
874        self.indexes = GraphIndexes::default();
875
876        // Rebuild node type index
877        for (key, node) in &self.nodes {
878            self.indexes
879                .nodes_by_type
880                .entry(node.entity_id.entity_type)
881                .or_default()
882                .push(key.clone());
883        }
884
885        // Rebuild edge indexes
886        for (idx, edge) in self.edges.iter().enumerate() {
887            self.indexes
888                .outgoing_edges
889                .entry(edge.from_id.key())
890                .or_default()
891                .push(idx);
892            self.indexes
893                .incoming_edges
894                .entry(edge.to_id.key())
895                .or_default()
896                .push(idx);
897            self.indexes
898                .edges_by_type
899                .entry(edge.relationship_type)
900                .or_default()
901                .push(idx);
902        }
903    }
904
905    /// Get graph statistics.
906    pub fn statistics(&self) -> GraphStatistics {
907        let node_count = self.nodes.len();
908        let edge_count = self.edges.len();
909
910        // Calculate average degree
911        let avg_degree = if node_count > 0 {
912            (2.0 * edge_count as f64) / node_count as f64
913        } else {
914            0.0
915        };
916
917        // Calculate average strength
918        let avg_strength = if edge_count > 0 {
919            self.edges.iter().map(|e| e.strength).sum::<f64>() / edge_count as f64
920        } else {
921            0.0
922        };
923
924        // Count nodes by type
925        let mut node_counts: HashMap<String, usize> = HashMap::new();
926        for node in self.nodes.values() {
927            *node_counts
928                .entry(format!("{:?}", node.entity_id.entity_type))
929                .or_insert(0) += 1;
930        }
931
932        // Count edges by type
933        let mut edge_counts: HashMap<String, usize> = HashMap::new();
934        for edge in &self.edges {
935            *edge_counts
936                .entry(format!("{:?}", edge.relationship_type))
937                .or_insert(0) += 1;
938        }
939
940        // Count strength distribution
941        let mut strength_distribution: HashMap<String, usize> = HashMap::new();
942        for edge in &self.edges {
943            let classification = RelationshipStrength::from_value(edge.strength);
944            *strength_distribution
945                .entry(format!("{:?}", classification))
946                .or_insert(0) += 1;
947        }
948
949        GraphStatistics {
950            node_count,
951            edge_count,
952            avg_degree,
953            avg_strength,
954            node_counts,
955            edge_counts,
956            strength_distribution,
957        }
958    }
959}
960
961/// Statistics about the graph.
962#[derive(Debug, Clone, Serialize, Deserialize)]
963pub struct GraphStatistics {
964    /// Total number of nodes
965    pub node_count: usize,
966    /// Total number of edges
967    pub edge_count: usize,
968    /// Average degree (edges per node)
969    pub avg_degree: f64,
970    /// Average edge strength
971    pub avg_strength: f64,
972    /// Node counts by type
973    pub node_counts: HashMap<String, usize>,
974    /// Edge counts by relationship type
975    pub edge_counts: HashMap<String, usize>,
976    /// Edge counts by strength classification
977    pub strength_distribution: HashMap<String, usize>,
978}
979
980/// Cross-process link connecting P2P and O2C via inventory.
981#[derive(Debug, Clone, Serialize, Deserialize)]
982pub struct CrossProcessLink {
983    /// Material ID linking the processes
984    pub material_id: String,
985    /// Source process (e.g., P2P)
986    pub source_process: String,
987    /// Source document ID
988    pub source_document_id: String,
989    /// Target process (e.g., O2C)
990    pub target_process: String,
991    /// Target document ID
992    pub target_document_id: String,
993    /// Link type
994    pub link_type: CrossProcessLinkType,
995    /// Quantity involved
996    #[serde(with = "rust_decimal::serde::str")]
997    pub quantity: Decimal,
998    /// Link date
999    pub link_date: NaiveDate,
1000}
1001
1002/// Type of cross-process link.
1003#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1004#[serde(rename_all = "snake_case")]
1005pub enum CrossProcessLinkType {
1006    /// Inventory movement links GR to delivery
1007    InventoryMovement,
1008    /// Return flow from O2C back to P2P
1009    ReturnFlow,
1010    /// Payment reconciliation
1011    PaymentReconciliation,
1012    /// Intercompany bilateral matching
1013    IntercompanyBilateral,
1014}
1015
1016impl CrossProcessLink {
1017    /// Create a new cross-process link.
1018    #[allow(clippy::too_many_arguments)]
1019    pub fn new(
1020        material_id: impl Into<String>,
1021        source_process: impl Into<String>,
1022        source_document_id: impl Into<String>,
1023        target_process: impl Into<String>,
1024        target_document_id: impl Into<String>,
1025        link_type: CrossProcessLinkType,
1026        quantity: Decimal,
1027        link_date: NaiveDate,
1028    ) -> Self {
1029        Self {
1030            material_id: material_id.into(),
1031            source_process: source_process.into(),
1032            source_document_id: source_document_id.into(),
1033            target_process: target_process.into(),
1034            target_document_id: target_document_id.into(),
1035            link_type,
1036            quantity,
1037            link_date,
1038        }
1039    }
1040}
1041
1042#[cfg(test)]
1043#[allow(clippy::unwrap_used)]
1044mod tests {
1045    use super::*;
1046
1047    #[test]
1048    fn test_entity_id() {
1049        let id = GraphEntityId::new(GraphEntityType::Vendor, "V-001234");
1050        assert_eq!(id.key(), "VN:V-001234");
1051    }
1052
1053    #[test]
1054    fn test_relationship_type_inverse() {
1055        assert_eq!(
1056            RelationshipType::BuysFrom.inverse(),
1057            RelationshipType::SellsTo
1058        );
1059        assert_eq!(
1060            RelationshipType::SellsTo.inverse(),
1061            RelationshipType::BuysFrom
1062        );
1063        assert_eq!(
1064            RelationshipType::ReportsTo.inverse(),
1065            RelationshipType::Manages
1066        );
1067    }
1068
1069    #[test]
1070    fn test_strength_weights_validation() {
1071        let valid_weights = StrengthWeights::default();
1072        assert!(valid_weights.validate().is_ok());
1073
1074        let invalid_weights = StrengthWeights {
1075            transaction_volume_weight: 0.5,
1076            transaction_count_weight: 0.5,
1077            duration_weight: 0.5,
1078            recency_weight: 0.5,
1079            mutual_connections_weight: 0.5,
1080        };
1081        assert!(invalid_weights.validate().is_err());
1082    }
1083
1084    #[test]
1085    fn test_strength_calculator() {
1086        let calc = RelationshipStrengthCalculator::default();
1087        let components = calc.calculate(Decimal::from(100000), 50, 365, 30, 5, 20);
1088
1089        assert!(components.transaction_volume > 0.0);
1090        assert!(components.transaction_count > 0.0);
1091        assert!(components.duration > 0.0);
1092        assert!(components.recency > 0.0);
1093        assert!(components.mutual_connections > 0.0);
1094        assert!(components.total() <= 1.0);
1095    }
1096
1097    #[test]
1098    fn test_relationship_strength_classification() {
1099        assert_eq!(
1100            RelationshipStrength::from_value(0.8),
1101            RelationshipStrength::Strong
1102        );
1103        assert_eq!(
1104            RelationshipStrength::from_value(0.5),
1105            RelationshipStrength::Moderate
1106        );
1107        assert_eq!(
1108            RelationshipStrength::from_value(0.2),
1109            RelationshipStrength::Weak
1110        );
1111        assert_eq!(
1112            RelationshipStrength::from_value(0.05),
1113            RelationshipStrength::Dormant
1114        );
1115    }
1116
1117    #[test]
1118    fn test_entity_graph() {
1119        let mut graph = EntityGraph::new();
1120
1121        let vendor_id = GraphEntityId::new(GraphEntityType::Vendor, "V-001");
1122        let customer_id = GraphEntityId::new(GraphEntityType::Customer, "C-001");
1123
1124        graph.add_node(EntityNode::new(
1125            vendor_id.clone(),
1126            "Acme Supplies",
1127            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1128        ));
1129
1130        graph.add_node(EntityNode::new(
1131            customer_id.clone(),
1132            "Contoso Corp",
1133            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1134        ));
1135
1136        graph.add_edge(
1137            RelationshipEdge::new(
1138                vendor_id.clone(),
1139                customer_id.clone(),
1140                RelationshipType::SellsTo,
1141                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1142            )
1143            .with_strength(0.7),
1144        );
1145
1146        assert_eq!(graph.nodes.len(), 2);
1147        assert_eq!(graph.edges.len(), 1);
1148
1149        let neighbors = graph.get_neighbors(&vendor_id);
1150        assert_eq!(neighbors.len(), 1);
1151        assert_eq!(neighbors[0].entity_id.id, "C-001");
1152
1153        assert_eq!(graph.node_degree(&vendor_id), 1);
1154        assert_eq!(graph.node_degree(&customer_id), 1);
1155    }
1156
1157    #[test]
1158    fn test_graph_statistics() {
1159        let mut graph = EntityGraph::new();
1160
1161        for i in 0..10 {
1162            let id = GraphEntityId::new(GraphEntityType::Vendor, format!("V-{:03}", i));
1163            graph.add_node(EntityNode::new(
1164                id,
1165                format!("Vendor {}", i),
1166                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1167            ));
1168        }
1169
1170        for i in 0..5 {
1171            let from_id = GraphEntityId::new(GraphEntityType::Vendor, format!("V-{:03}", i));
1172            let to_id = GraphEntityId::new(GraphEntityType::Vendor, format!("V-{:03}", i + 5));
1173            graph.add_edge(
1174                RelationshipEdge::new(
1175                    from_id,
1176                    to_id,
1177                    RelationshipType::PartnersWith,
1178                    NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1179                )
1180                .with_strength(0.6),
1181            );
1182        }
1183
1184        let stats = graph.statistics();
1185        assert_eq!(stats.node_count, 10);
1186        assert_eq!(stats.edge_count, 5);
1187        assert!((stats.avg_degree - 1.0).abs() < 0.01);
1188        assert!((stats.avg_strength - 0.6).abs() < 0.01);
1189    }
1190
1191    #[test]
1192    fn test_cross_process_link() {
1193        let link = CrossProcessLink::new(
1194            "MAT-001",
1195            "P2P",
1196            "GR-12345",
1197            "O2C",
1198            "DEL-67890",
1199            CrossProcessLinkType::InventoryMovement,
1200            Decimal::from(100),
1201            NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(),
1202        );
1203
1204        assert_eq!(link.material_id, "MAT-001");
1205        assert_eq!(link.link_type, CrossProcessLinkType::InventoryMovement);
1206    }
1207}