Skip to main content

datasynth_generators/relationships/
entity_graph_generator.rs

1//! Entity graph generator for interconnectivity modeling.
2//!
3//! Provides generation of comprehensive entity relationship graphs including:
4//! - Transactional relationships from journal entries and document flows
5//! - Cross-process linkages (P2P ↔ O2C via inventory)
6//! - Relationship strength calculation
7//! - Network analysis support
8
9use chrono::NaiveDate;
10use datasynth_core::models::{
11    CrossProcessLink, CrossProcessLinkType, EntityGraph, EntityNode, GraphEntityId,
12    GraphEntityType, GraphMetadata, RelationshipEdge, RelationshipStrengthCalculator,
13    RelationshipType, VendorNetwork,
14};
15use rand::prelude::*;
16use rand_chacha::ChaCha8Rng;
17use rust_decimal::Decimal;
18use std::collections::{HashMap, HashSet};
19
20/// Configuration for entity graph generation.
21#[derive(Debug, Clone)]
22pub struct EntityGraphConfig {
23    /// Enable entity graph generation
24    pub enabled: bool,
25    /// Cross-process link configuration
26    pub cross_process: CrossProcessConfig,
27    /// Strength calculation settings
28    pub strength_config: StrengthConfig,
29    /// Include organizational relationships
30    pub include_organizational: bool,
31    /// Include document relationships
32    pub include_document: bool,
33}
34
35impl Default for EntityGraphConfig {
36    fn default() -> Self {
37        Self {
38            enabled: false,
39            cross_process: CrossProcessConfig::default(),
40            strength_config: StrengthConfig::default(),
41            include_organizational: true,
42            include_document: true,
43        }
44    }
45}
46
47/// Configuration for cross-process linkages.
48#[derive(Debug, Clone)]
49pub struct CrossProcessConfig {
50    /// Enable inventory links between P2P and O2C
51    pub enable_inventory_links: bool,
52    /// Enable return flow generation
53    pub enable_return_flows: bool,
54    /// Enable payment reconciliation links
55    pub enable_payment_links: bool,
56    /// Enable intercompany bilateral matching
57    pub enable_ic_bilateral: bool,
58    /// Percentage of GR/Deliveries to link via inventory (0.0 - 1.0)
59    pub inventory_link_rate: f64,
60    /// Percentage of payments to link for reconciliation (0.0 - 1.0)
61    pub payment_link_rate: f64,
62}
63
64impl Default for CrossProcessConfig {
65    fn default() -> Self {
66        Self {
67            enable_inventory_links: true,
68            enable_return_flows: true,
69            enable_payment_links: true,
70            enable_ic_bilateral: true,
71            inventory_link_rate: 0.30,
72            payment_link_rate: 0.80,
73        }
74    }
75}
76
77/// Configuration for relationship strength calculation.
78#[derive(Debug, Clone)]
79pub struct StrengthConfig {
80    /// Transaction volume weight
81    pub transaction_volume_weight: f64,
82    /// Transaction count weight
83    pub transaction_count_weight: f64,
84    /// Duration weight
85    pub duration_weight: f64,
86    /// Recency weight
87    pub recency_weight: f64,
88    /// Mutual connections weight
89    pub mutual_connections_weight: f64,
90    /// Recency half-life in days
91    pub recency_half_life_days: u32,
92}
93
94impl Default for StrengthConfig {
95    fn default() -> Self {
96        Self {
97            transaction_volume_weight: 0.30,
98            transaction_count_weight: 0.25,
99            duration_weight: 0.20,
100            recency_weight: 0.15,
101            mutual_connections_weight: 0.10,
102            recency_half_life_days: 90,
103        }
104    }
105}
106
107/// Summary of transaction history between two entities.
108#[derive(Debug, Clone)]
109pub struct TransactionSummary {
110    /// Total transaction volume
111    pub total_volume: Decimal,
112    /// Number of transactions
113    pub transaction_count: u32,
114    /// First transaction date
115    pub first_transaction_date: NaiveDate,
116    /// Last transaction date
117    pub last_transaction_date: NaiveDate,
118    /// Related entity IDs (for mutual connection calculation)
119    pub related_entities: HashSet<String>,
120}
121
122impl Default for TransactionSummary {
123    fn default() -> Self {
124        Self {
125            total_volume: Decimal::ZERO,
126            transaction_count: 0,
127            first_transaction_date: NaiveDate::from_ymd_opt(2020, 1, 1)
128                .expect("valid default date"),
129            last_transaction_date: NaiveDate::from_ymd_opt(2020, 1, 1).expect("valid default date"),
130            related_entities: HashSet::new(),
131        }
132    }
133}
134
135/// Goods receipt summary for cross-process linking.
136#[derive(Debug, Clone)]
137pub struct GoodsReceiptRef {
138    /// GR document ID
139    pub document_id: String,
140    /// Material ID
141    pub material_id: String,
142    /// Quantity received
143    pub quantity: Decimal,
144    /// Receipt date
145    pub receipt_date: NaiveDate,
146    /// Vendor ID
147    pub vendor_id: String,
148    /// Company code
149    pub company_code: String,
150}
151
152/// Delivery summary for cross-process linking.
153#[derive(Debug, Clone)]
154pub struct DeliveryRef {
155    /// Delivery document ID
156    pub document_id: String,
157    /// Material ID
158    pub material_id: String,
159    /// Quantity delivered
160    pub quantity: Decimal,
161    /// Delivery date
162    pub delivery_date: NaiveDate,
163    /// Customer ID
164    pub customer_id: String,
165    /// Company code
166    pub company_code: String,
167}
168
169/// Generator for entity relationship graphs.
170pub struct EntityGraphGenerator {
171    rng: ChaCha8Rng,
172    seed: u64,
173    config: EntityGraphConfig,
174    strength_calculator: RelationshipStrengthCalculator,
175}
176
177impl EntityGraphGenerator {
178    /// Create a new entity graph generator.
179    pub fn new(seed: u64) -> Self {
180        Self::with_config(seed, EntityGraphConfig::default())
181    }
182
183    /// Create a new entity graph generator with configuration.
184    pub fn with_config(seed: u64, config: EntityGraphConfig) -> Self {
185        let strength_calculator = RelationshipStrengthCalculator {
186            weights: datasynth_core::models::StrengthWeights {
187                transaction_volume_weight: config.strength_config.transaction_volume_weight,
188                transaction_count_weight: config.strength_config.transaction_count_weight,
189                duration_weight: config.strength_config.duration_weight,
190                recency_weight: config.strength_config.recency_weight,
191                mutual_connections_weight: config.strength_config.mutual_connections_weight,
192            },
193            recency_half_life_days: config.strength_config.recency_half_life_days,
194            ..Default::default()
195        };
196
197        Self {
198            rng: ChaCha8Rng::seed_from_u64(seed),
199            seed,
200            config,
201            strength_calculator,
202        }
203    }
204
205    /// Generate an entity graph from transaction data.
206    pub fn generate_entity_graph(
207        &mut self,
208        company_code: &str,
209        as_of_date: NaiveDate,
210        vendors: &[EntitySummary],
211        customers: &[EntitySummary],
212        transaction_summaries: &HashMap<(String, String), TransactionSummary>,
213    ) -> EntityGraph {
214        let mut graph = EntityGraph::new();
215        graph.metadata = GraphMetadata {
216            company_code: Some(company_code.to_string()),
217            created_date: Some(as_of_date),
218            total_transaction_volume: Decimal::ZERO,
219            date_range: None,
220        };
221
222        if !self.config.enabled {
223            return graph;
224        }
225
226        // Add company node
227        let company_id = GraphEntityId::new(GraphEntityType::Company, company_code);
228        graph.add_node(EntityNode::new(
229            company_id.clone(),
230            format!("Company {}", company_code),
231            as_of_date,
232        ));
233
234        // Add vendor nodes
235        for vendor in vendors {
236            let vendor_id = GraphEntityId::new(GraphEntityType::Vendor, &vendor.entity_id);
237            let node = EntityNode::new(vendor_id.clone(), &vendor.name, as_of_date)
238                .with_company(company_code);
239            graph.add_node(node);
240
241            // Add relationship: Company buys from Vendor
242            let edge = RelationshipEdge::new(
243                company_id.clone(),
244                vendor_id,
245                RelationshipType::BuysFrom,
246                vendor.first_activity_date,
247            );
248            graph.add_edge(edge);
249        }
250
251        // Add customer nodes
252        for customer in customers {
253            let customer_id = GraphEntityId::new(GraphEntityType::Customer, &customer.entity_id);
254            let node = EntityNode::new(customer_id.clone(), &customer.name, as_of_date)
255                .with_company(company_code);
256            graph.add_node(node);
257
258            // Add relationship: Company sells to Customer
259            let edge = RelationshipEdge::new(
260                company_id.clone(),
261                customer_id,
262                RelationshipType::SellsTo,
263                customer.first_activity_date,
264            );
265            graph.add_edge(edge);
266        }
267
268        // Add transactional relationships with strength
269        let total_connections = transaction_summaries.len().max(1);
270        for ((from_id, to_id), summary) in transaction_summaries {
271            let from_entity_id = self.infer_entity_id(from_id);
272            let to_entity_id = self.infer_entity_id(to_id);
273
274            // Calculate relationship strength
275            let days_since_last = (as_of_date - summary.last_transaction_date)
276                .num_days()
277                .max(0) as u32;
278            let relationship_days = (as_of_date - summary.first_transaction_date)
279                .num_days()
280                .max(1) as u32;
281
282            let components = self.strength_calculator.calculate(
283                summary.total_volume,
284                summary.transaction_count,
285                relationship_days,
286                days_since_last,
287                summary.related_entities.len(),
288                total_connections,
289            );
290
291            let rel_type = self.infer_relationship_type(&from_entity_id, &to_entity_id);
292
293            let edge = RelationshipEdge::new(
294                from_entity_id,
295                to_entity_id,
296                rel_type,
297                summary.first_transaction_date,
298            )
299            .with_strength_components(components);
300
301            graph.add_edge(edge);
302        }
303
304        // Calculate total transaction volume
305        graph.metadata.total_transaction_volume =
306            transaction_summaries.values().map(|s| s.total_volume).sum();
307
308        graph
309    }
310
311    /// Generate cross-process links between P2P and O2C.
312    pub fn generate_cross_process_links(
313        &mut self,
314        goods_receipts: &[GoodsReceiptRef],
315        deliveries: &[DeliveryRef],
316    ) -> Vec<CrossProcessLink> {
317        let mut links = Vec::new();
318
319        if !self.config.cross_process.enable_inventory_links {
320            return links;
321        }
322
323        // Group deliveries by material for matching
324        let deliveries_by_material: HashMap<String, Vec<&DeliveryRef>> =
325            deliveries.iter().fold(HashMap::new(), |mut acc, del| {
326                acc.entry(del.material_id.clone()).or_default().push(del);
327                acc
328            });
329
330        // Link GRs to Deliveries via shared material
331        for gr in goods_receipts {
332            if self.rng.gen::<f64>() > self.config.cross_process.inventory_link_rate {
333                continue;
334            }
335
336            if let Some(matching_deliveries) = deliveries_by_material.get(&gr.material_id) {
337                // Find a delivery that could have used this inventory
338                // (delivery date after receipt date)
339                let valid_deliveries: Vec<_> = matching_deliveries
340                    .iter()
341                    .filter(|d| {
342                        d.delivery_date >= gr.receipt_date && d.company_code == gr.company_code
343                    })
344                    .collect();
345
346                if !valid_deliveries.is_empty() {
347                    let delivery = valid_deliveries[self.rng.gen_range(0..valid_deliveries.len())];
348
349                    // Calculate linked quantity (minimum of available)
350                    let linked_qty = gr.quantity.min(delivery.quantity);
351
352                    links.push(CrossProcessLink::new(
353                        &gr.material_id,
354                        "P2P",
355                        &gr.document_id,
356                        "O2C",
357                        &delivery.document_id,
358                        CrossProcessLinkType::InventoryMovement,
359                        linked_qty,
360                        delivery.delivery_date,
361                    ));
362                }
363            }
364        }
365
366        links
367    }
368
369    /// Generate graph from vendor network.
370    pub fn generate_from_vendor_network(
371        &mut self,
372        vendor_network: &VendorNetwork,
373        as_of_date: NaiveDate,
374    ) -> EntityGraph {
375        let mut graph = EntityGraph::new();
376        graph.metadata = GraphMetadata {
377            company_code: Some(vendor_network.company_code.clone()),
378            created_date: Some(as_of_date),
379            total_transaction_volume: vendor_network.statistics.total_annual_spend,
380            date_range: None,
381        };
382
383        if !self.config.enabled {
384            return graph;
385        }
386
387        // Add company node
388        let company_id = GraphEntityId::new(GraphEntityType::Company, &vendor_network.company_code);
389        graph.add_node(EntityNode::new(
390            company_id.clone(),
391            format!("Company {}", vendor_network.company_code),
392            as_of_date,
393        ));
394
395        // Add all vendors from the network
396        for (vendor_id, relationship) in &vendor_network.relationships {
397            let entity_id = GraphEntityId::new(GraphEntityType::Vendor, vendor_id);
398            let node = EntityNode::new(entity_id.clone(), vendor_id, as_of_date)
399                .with_company(&vendor_network.company_code)
400                .with_attribute("tier", format!("{:?}", relationship.tier))
401                .with_attribute("cluster", format!("{:?}", relationship.cluster))
402                .with_attribute(
403                    "strategic_level",
404                    format!("{:?}", relationship.strategic_importance),
405                );
406            graph.add_node(node);
407
408            // Add relationship to company (for Tier 1) or parent vendor (for Tier 2/3)
409            if let Some(parent_id) = &relationship.parent_vendor {
410                let parent_entity_id = GraphEntityId::new(GraphEntityType::Vendor, parent_id);
411                let edge = RelationshipEdge::new(
412                    entity_id.clone(),
413                    parent_entity_id,
414                    RelationshipType::SuppliesTo,
415                    relationship.start_date,
416                )
417                .with_strength(relationship.relationship_score());
418                graph.add_edge(edge);
419            } else {
420                // Tier 1 supplies directly to company
421                let edge = RelationshipEdge::new(
422                    entity_id,
423                    company_id.clone(),
424                    RelationshipType::SuppliesTo,
425                    relationship.start_date,
426                )
427                .with_strength(relationship.relationship_score());
428                graph.add_edge(edge);
429            }
430        }
431
432        graph
433    }
434
435    /// Infer entity ID from string (simple heuristic).
436    fn infer_entity_id(&self, id: &str) -> GraphEntityId {
437        if id.starts_with("V-") || id.starts_with("VN-") {
438            GraphEntityId::new(GraphEntityType::Vendor, id)
439        } else if id.starts_with("C-") || id.starts_with("CU-") {
440            GraphEntityId::new(GraphEntityType::Customer, id)
441        } else if id.starts_with("E-") || id.starts_with("EM-") {
442            GraphEntityId::new(GraphEntityType::Employee, id)
443        } else if id.starts_with("MAT-") || id.starts_with("M-") {
444            GraphEntityId::new(GraphEntityType::Material, id)
445        } else if id.starts_with("PO-") {
446            GraphEntityId::new(GraphEntityType::PurchaseOrder, id)
447        } else if id.starts_with("SO-") {
448            GraphEntityId::new(GraphEntityType::SalesOrder, id)
449        } else if id.starts_with("INV-") || id.starts_with("IV-") {
450            GraphEntityId::new(GraphEntityType::Invoice, id)
451        } else if id.starts_with("PAY-") || id.starts_with("PM-") {
452            GraphEntityId::new(GraphEntityType::Payment, id)
453        } else {
454            GraphEntityId::new(GraphEntityType::Company, id)
455        }
456    }
457
458    /// Infer relationship type between two entities.
459    fn infer_relationship_type(
460        &self,
461        from: &GraphEntityId,
462        to: &GraphEntityId,
463    ) -> RelationshipType {
464        match (&from.entity_type, &to.entity_type) {
465            (GraphEntityType::Company, GraphEntityType::Vendor) => RelationshipType::BuysFrom,
466            (GraphEntityType::Company, GraphEntityType::Customer) => RelationshipType::SellsTo,
467            (GraphEntityType::Vendor, GraphEntityType::Company) => RelationshipType::SuppliesTo,
468            (GraphEntityType::Customer, GraphEntityType::Company) => RelationshipType::SourcesFrom,
469            (GraphEntityType::PurchaseOrder, GraphEntityType::Invoice) => {
470                RelationshipType::References
471            }
472            (GraphEntityType::Invoice, GraphEntityType::Payment) => RelationshipType::FulfilledBy,
473            (GraphEntityType::Payment, GraphEntityType::Invoice) => RelationshipType::AppliesTo,
474            (GraphEntityType::Employee, GraphEntityType::Employee) => RelationshipType::ReportsTo,
475            (GraphEntityType::Employee, GraphEntityType::Department) => RelationshipType::WorksIn,
476            _ => RelationshipType::References,
477        }
478    }
479
480    /// Reset the generator.
481    pub fn reset(&mut self) {
482        self.rng = ChaCha8Rng::seed_from_u64(self.seed);
483    }
484}
485
486/// Summary of an entity for graph generation.
487#[derive(Debug, Clone)]
488pub struct EntitySummary {
489    /// Entity ID
490    pub entity_id: String,
491    /// Entity name
492    pub name: String,
493    /// First activity date
494    pub first_activity_date: NaiveDate,
495    /// Entity type (for categorization)
496    pub entity_type: GraphEntityType,
497    /// Additional attributes
498    pub attributes: HashMap<String, String>,
499}
500
501impl EntitySummary {
502    /// Create a new entity summary.
503    pub fn new(
504        entity_id: impl Into<String>,
505        name: impl Into<String>,
506        entity_type: GraphEntityType,
507        first_activity_date: NaiveDate,
508    ) -> Self {
509        Self {
510            entity_id: entity_id.into(),
511            name: name.into(),
512            first_activity_date,
513            entity_type,
514            attributes: HashMap::new(),
515        }
516    }
517}
518
519#[cfg(test)]
520#[allow(clippy::unwrap_used)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn test_entity_graph_generation() {
526        let config = EntityGraphConfig {
527            enabled: true,
528            ..Default::default()
529        };
530
531        let mut gen = EntityGraphGenerator::with_config(42, config);
532
533        let vendors = vec![
534            EntitySummary::new(
535                "V-001",
536                "Acme Supplies",
537                GraphEntityType::Vendor,
538                NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
539            ),
540            EntitySummary::new(
541                "V-002",
542                "Global Parts",
543                GraphEntityType::Vendor,
544                NaiveDate::from_ymd_opt(2023, 3, 1).unwrap(),
545            ),
546        ];
547
548        let customers = vec![EntitySummary::new(
549            "C-001",
550            "Contoso Corp",
551            GraphEntityType::Customer,
552            NaiveDate::from_ymd_opt(2023, 2, 1).unwrap(),
553        )];
554
555        let graph = gen.generate_entity_graph(
556            "1000",
557            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
558            &vendors,
559            &customers,
560            &HashMap::new(),
561        );
562
563        // Should have company + 2 vendors + 1 customer = 4 nodes
564        assert_eq!(graph.nodes.len(), 4);
565        // Should have 3 edges (company buys from 2 vendors, sells to 1 customer)
566        assert_eq!(graph.edges.len(), 3);
567    }
568
569    #[test]
570    fn test_cross_process_link_generation() {
571        let config = EntityGraphConfig {
572            enabled: true,
573            cross_process: CrossProcessConfig {
574                enable_inventory_links: true,
575                inventory_link_rate: 1.0, // Always link for testing
576                ..Default::default()
577            },
578            ..Default::default()
579        };
580
581        let mut gen = EntityGraphGenerator::with_config(42, config);
582
583        let goods_receipts = vec![GoodsReceiptRef {
584            document_id: "GR-001".to_string(),
585            material_id: "MAT-100".to_string(),
586            quantity: Decimal::from(100),
587            receipt_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
588            vendor_id: "V-001".to_string(),
589            company_code: "1000".to_string(),
590        }];
591
592        let deliveries = vec![DeliveryRef {
593            document_id: "DEL-001".to_string(),
594            material_id: "MAT-100".to_string(),
595            quantity: Decimal::from(50),
596            delivery_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
597            customer_id: "C-001".to_string(),
598            company_code: "1000".to_string(),
599        }];
600
601        let links = gen.generate_cross_process_links(&goods_receipts, &deliveries);
602
603        assert_eq!(links.len(), 1);
604        assert_eq!(links[0].material_id, "MAT-100");
605        assert_eq!(links[0].source_document_id, "GR-001");
606        assert_eq!(links[0].target_document_id, "DEL-001");
607        assert_eq!(links[0].link_type, CrossProcessLinkType::InventoryMovement);
608    }
609
610    #[test]
611    fn test_disabled_graph_generation() {
612        let config = EntityGraphConfig {
613            enabled: false,
614            ..Default::default()
615        };
616
617        let mut gen = EntityGraphGenerator::with_config(42, config);
618
619        let graph = gen.generate_entity_graph(
620            "1000",
621            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
622            &[],
623            &[],
624            &HashMap::new(),
625        );
626
627        assert!(graph.nodes.is_empty());
628    }
629
630    #[test]
631    fn test_entity_id_inference() {
632        let gen = EntityGraphGenerator::new(42);
633
634        let vendor_id = gen.infer_entity_id("V-001");
635        assert_eq!(vendor_id.entity_type, GraphEntityType::Vendor);
636
637        let customer_id = gen.infer_entity_id("C-001");
638        assert_eq!(customer_id.entity_type, GraphEntityType::Customer);
639
640        let po_id = gen.infer_entity_id("PO-12345");
641        assert_eq!(po_id.entity_type, GraphEntityType::PurchaseOrder);
642    }
643
644    #[test]
645    fn test_relationship_type_inference() {
646        let gen = EntityGraphGenerator::new(42);
647
648        let company_id = GraphEntityId::new(GraphEntityType::Company, "1000");
649        let vendor_id = GraphEntityId::new(GraphEntityType::Vendor, "V-001");
650
651        let rel_type = gen.infer_relationship_type(&company_id, &vendor_id);
652        assert_eq!(rel_type, RelationshipType::BuysFrom);
653
654        let rel_type = gen.infer_relationship_type(&vendor_id, &company_id);
655        assert_eq!(rel_type, RelationshipType::SuppliesTo);
656    }
657}