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