1use 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#[derive(Debug, Clone)]
22pub struct EntityGraphConfig {
23 pub enabled: bool,
25 pub cross_process: CrossProcessConfig,
27 pub strength_config: StrengthConfig,
29 pub include_organizational: bool,
31 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#[derive(Debug, Clone)]
49pub struct CrossProcessConfig {
50 pub enable_inventory_links: bool,
52 pub enable_return_flows: bool,
54 pub enable_payment_links: bool,
56 pub enable_ic_bilateral: bool,
58 pub inventory_link_rate: f64,
60 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#[derive(Debug, Clone)]
79pub struct StrengthConfig {
80 pub transaction_volume_weight: f64,
82 pub transaction_count_weight: f64,
84 pub duration_weight: f64,
86 pub recency_weight: f64,
88 pub mutual_connections_weight: f64,
90 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#[derive(Debug, Clone)]
109pub struct TransactionSummary {
110 pub total_volume: Decimal,
112 pub transaction_count: u32,
114 pub first_transaction_date: NaiveDate,
116 pub last_transaction_date: NaiveDate,
118 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#[derive(Debug, Clone)]
137pub struct GoodsReceiptRef {
138 pub document_id: String,
140 pub material_id: String,
142 pub quantity: Decimal,
144 pub receipt_date: NaiveDate,
146 pub vendor_id: String,
148 pub company_code: String,
150}
151
152#[derive(Debug, Clone)]
154pub struct DeliveryRef {
155 pub document_id: String,
157 pub material_id: String,
159 pub quantity: Decimal,
161 pub delivery_date: NaiveDate,
163 pub customer_id: String,
165 pub company_code: String,
167}
168
169pub struct EntityGraphGenerator {
171 rng: ChaCha8Rng,
172 seed: u64,
173 config: EntityGraphConfig,
174 strength_calculator: RelationshipStrengthCalculator,
175}
176
177impl EntityGraphGenerator {
178 pub fn new(seed: u64) -> Self {
180 Self::with_config(seed, EntityGraphConfig::default())
181 }
182
183 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 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 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 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 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 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 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 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 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 graph.metadata.total_transaction_volume =
306 transaction_summaries.values().map(|s| s.total_volume).sum();
307
308 graph
309 }
310
311 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 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 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 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 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 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 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 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 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 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 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 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 pub fn reset(&mut self) {
482 self.rng = ChaCha8Rng::seed_from_u64(self.seed);
483 }
484}
485
486#[derive(Debug, Clone)]
488pub struct EntitySummary {
489 pub entity_id: String,
491 pub name: String,
493 pub first_activity_date: NaiveDate,
495 pub entity_type: GraphEntityType,
497 pub attributes: HashMap<String, String>,
499}
500
501impl EntitySummary {
502 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 assert_eq!(graph.nodes.len(), 4);
565 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, ..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}