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