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).unwrap(),
128 last_transaction_date: NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
129 related_entities: HashSet::new(),
130 }
131 }
132}
133
134#[derive(Debug, Clone)]
136pub struct GoodsReceiptRef {
137 pub document_id: String,
139 pub material_id: String,
141 pub quantity: Decimal,
143 pub receipt_date: NaiveDate,
145 pub vendor_id: String,
147 pub company_code: String,
149}
150
151#[derive(Debug, Clone)]
153pub struct DeliveryRef {
154 pub document_id: String,
156 pub material_id: String,
158 pub quantity: Decimal,
160 pub delivery_date: NaiveDate,
162 pub customer_id: String,
164 pub company_code: String,
166}
167
168pub struct EntityGraphGenerator {
170 rng: ChaCha8Rng,
171 seed: u64,
172 config: EntityGraphConfig,
173 strength_calculator: RelationshipStrengthCalculator,
174}
175
176impl EntityGraphGenerator {
177 pub fn new(seed: u64) -> Self {
179 Self::with_config(seed, EntityGraphConfig::default())
180 }
181
182 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 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 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 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 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 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 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 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 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 graph.metadata.total_transaction_volume =
305 transaction_summaries.values().map(|s| s.total_volume).sum();
306
307 graph
308 }
309
310 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 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 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 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 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 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 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 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 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 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 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 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 pub fn reset(&mut self) {
481 self.rng = ChaCha8Rng::seed_from_u64(self.seed);
482 }
483}
484
485#[derive(Debug, Clone)]
487pub struct EntitySummary {
488 pub entity_id: String,
490 pub name: String,
492 pub first_activity_date: NaiveDate,
494 pub entity_type: GraphEntityType,
496 pub attributes: HashMap<String, String>,
498}
499
500impl EntitySummary {
501 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 assert_eq!(graph.nodes.len(), 4);
563 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, ..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}