1use chrono::NaiveDate;
10use rust_decimal::Decimal;
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum GraphEntityType {
21 Company,
23 Vendor,
25 Customer,
27 Employee,
29 Department,
31 CostCenter,
33 Project,
35 Contract,
37 Asset,
39 BankAccount,
41 Material,
43 GlAccount,
45 PurchaseOrder,
47 SalesOrder,
49 Invoice,
51 Payment,
53 SourcingProject,
55 RfxEvent,
57 ProductionOrder,
59 BankReconciliation,
61}
62
63impl GraphEntityType {
64 pub fn code(&self) -> &'static str {
66 match self {
67 Self::Company => "CO",
68 Self::Vendor => "VN",
69 Self::Customer => "CU",
70 Self::Employee => "EM",
71 Self::Department => "DP",
72 Self::CostCenter => "CC",
73 Self::Project => "PJ",
74 Self::Contract => "CT",
75 Self::Asset => "AS",
76 Self::BankAccount => "BA",
77 Self::Material => "MT",
78 Self::GlAccount => "GL",
79 Self::PurchaseOrder => "PO",
80 Self::SalesOrder => "SO",
81 Self::Invoice => "IV",
82 Self::Payment => "PM",
83 Self::SourcingProject => "SP",
84 Self::RfxEvent => "RX",
85 Self::ProductionOrder => "PR",
86 Self::BankReconciliation => "BR",
87 }
88 }
89
90 pub fn is_master_data(&self) -> bool {
92 matches!(
93 self,
94 Self::Company
95 | Self::Vendor
96 | Self::Customer
97 | Self::Employee
98 | Self::Department
99 | Self::CostCenter
100 | Self::Material
101 | Self::GlAccount
102 )
103 }
104
105 pub fn is_transactional(&self) -> bool {
107 matches!(
108 self,
109 Self::PurchaseOrder | Self::SalesOrder | Self::Invoice | Self::Payment
110 )
111 }
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum RelationshipType {
118 BuysFrom,
121 SellsTo,
123 PaysTo,
125 ReceivesFrom,
127 SuppliesTo,
129 SourcesFrom,
131
132 ReportsTo,
135 Manages,
137 BelongsTo,
139 OwnedBy,
141 WorksIn,
143 ResponsibleFor,
145
146 ReferredBy,
149 PartnersWith,
151 AffiliatedWith,
153 Intercompany,
155
156 References,
159 ReferencedBy,
161 Fulfills,
163 FulfilledBy,
165 AppliesTo,
167 AppliedBy,
169
170 InventoryLink,
173 UsedIn,
175 SourcedVia,
177
178 AwardedTo,
181 GovernsOrder,
183 EvaluatedBy,
185 QualifiedAs,
187 ScoredBy,
189 SourcedThrough,
191 CatalogItemOf,
193
194 ProducedBy,
197
198 ReconciledWith,
201}
202
203impl RelationshipType {
204 pub fn code(&self) -> &'static str {
206 match self {
207 Self::BuysFrom => "BF",
208 Self::SellsTo => "ST",
209 Self::PaysTo => "PT",
210 Self::ReceivesFrom => "RF",
211 Self::SuppliesTo => "SP",
212 Self::SourcesFrom => "SF",
213 Self::ReportsTo => "RT",
214 Self::Manages => "MG",
215 Self::BelongsTo => "BT",
216 Self::OwnedBy => "OB",
217 Self::WorksIn => "WI",
218 Self::ResponsibleFor => "RS",
219 Self::ReferredBy => "RB",
220 Self::PartnersWith => "PW",
221 Self::AffiliatedWith => "AW",
222 Self::Intercompany => "IC",
223 Self::References => "REF",
224 Self::ReferencedBy => "RBY",
225 Self::Fulfills => "FL",
226 Self::FulfilledBy => "FLB",
227 Self::AppliesTo => "AP",
228 Self::AppliedBy => "APB",
229 Self::InventoryLink => "INV",
230 Self::UsedIn => "UI",
231 Self::SourcedVia => "SV",
232 Self::AwardedTo => "AT",
233 Self::GovernsOrder => "GO",
234 Self::EvaluatedBy => "EB",
235 Self::QualifiedAs => "QA",
236 Self::ScoredBy => "SB",
237 Self::SourcedThrough => "STH",
238 Self::CatalogItemOf => "CIO",
239 Self::ProducedBy => "PB",
240 Self::ReconciledWith => "RW",
241 }
242 }
243
244 pub fn inverse(&self) -> Self {
246 match self {
247 Self::BuysFrom => Self::SellsTo,
248 Self::SellsTo => Self::BuysFrom,
249 Self::PaysTo => Self::ReceivesFrom,
250 Self::ReceivesFrom => Self::PaysTo,
251 Self::SuppliesTo => Self::SourcesFrom,
252 Self::SourcesFrom => Self::SuppliesTo,
253 Self::ReportsTo => Self::Manages,
254 Self::Manages => Self::ReportsTo,
255 Self::BelongsTo => Self::OwnedBy,
256 Self::OwnedBy => Self::BelongsTo,
257 Self::References => Self::ReferencedBy,
258 Self::ReferencedBy => Self::References,
259 Self::Fulfills => Self::FulfilledBy,
260 Self::FulfilledBy => Self::Fulfills,
261 Self::AppliesTo => Self::AppliedBy,
262 Self::AppliedBy => Self::AppliesTo,
263 Self::WorksIn => Self::WorksIn,
265 Self::ResponsibleFor => Self::ResponsibleFor,
266 Self::ReferredBy => Self::ReferredBy,
267 Self::PartnersWith => Self::PartnersWith,
268 Self::AffiliatedWith => Self::AffiliatedWith,
269 Self::Intercompany => Self::Intercompany,
270 Self::InventoryLink => Self::InventoryLink,
271 Self::UsedIn => Self::UsedIn,
272 Self::SourcedVia => Self::SourcedVia,
273 Self::AwardedTo => Self::AwardedTo,
275 Self::GovernsOrder => Self::GovernsOrder,
276 Self::EvaluatedBy => Self::EvaluatedBy,
277 Self::QualifiedAs => Self::QualifiedAs,
278 Self::ScoredBy => Self::ScoredBy,
279 Self::SourcedThrough => Self::SourcedThrough,
280 Self::CatalogItemOf => Self::CatalogItemOf,
281 Self::ProducedBy => Self::ProducedBy,
282 Self::ReconciledWith => Self::ReconciledWith,
283 }
284 }
285
286 pub fn is_transactional(&self) -> bool {
288 matches!(
289 self,
290 Self::BuysFrom
291 | Self::SellsTo
292 | Self::PaysTo
293 | Self::ReceivesFrom
294 | Self::SuppliesTo
295 | Self::SourcesFrom
296 )
297 }
298
299 pub fn is_organizational(&self) -> bool {
301 matches!(
302 self,
303 Self::ReportsTo
304 | Self::Manages
305 | Self::BelongsTo
306 | Self::OwnedBy
307 | Self::WorksIn
308 | Self::ResponsibleFor
309 )
310 }
311
312 pub fn is_document(&self) -> bool {
314 matches!(
315 self,
316 Self::References
317 | Self::ReferencedBy
318 | Self::Fulfills
319 | Self::FulfilledBy
320 | Self::AppliesTo
321 | Self::AppliedBy
322 )
323 }
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
331pub struct GraphEntityId {
332 pub entity_type: GraphEntityType,
334 pub id: String,
336}
337
338impl GraphEntityId {
339 pub fn new(entity_type: GraphEntityType, id: impl Into<String>) -> Self {
341 Self {
342 entity_type,
343 id: id.into(),
344 }
345 }
346
347 pub fn key(&self) -> String {
349 format!("{}:{}", self.entity_type.code(), self.id)
350 }
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct EntityNode {
356 pub entity_id: GraphEntityId,
358 pub name: String,
360 pub attributes: HashMap<String, String>,
362 pub created_date: NaiveDate,
364 pub is_active: bool,
366 pub company_code: Option<String>,
368}
369
370impl EntityNode {
371 pub fn new(entity_id: GraphEntityId, name: impl Into<String>, created_date: NaiveDate) -> Self {
373 Self {
374 entity_id,
375 name: name.into(),
376 attributes: HashMap::new(),
377 created_date,
378 is_active: true,
379 company_code: None,
380 }
381 }
382
383 pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
385 self.attributes.insert(key.into(), value.into());
386 self
387 }
388
389 pub fn with_company(mut self, company_code: impl Into<String>) -> Self {
391 self.company_code = Some(company_code.into());
392 self
393 }
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize)]
398pub struct RelationshipEdge {
399 pub from_id: GraphEntityId,
401 pub to_id: GraphEntityId,
403 pub relationship_type: RelationshipType,
405 pub strength: f64,
407 pub start_date: NaiveDate,
409 pub end_date: Option<NaiveDate>,
411 pub attributes: HashMap<String, String>,
413 pub strength_components: Option<StrengthComponents>,
415}
416
417impl RelationshipEdge {
418 pub fn new(
420 from_id: GraphEntityId,
421 to_id: GraphEntityId,
422 relationship_type: RelationshipType,
423 start_date: NaiveDate,
424 ) -> Self {
425 Self {
426 from_id,
427 to_id,
428 relationship_type,
429 strength: 0.5, start_date,
431 end_date: None,
432 attributes: HashMap::new(),
433 strength_components: None,
434 }
435 }
436
437 pub fn with_strength(mut self, strength: f64) -> Self {
439 self.strength = strength.clamp(0.0, 1.0);
440 self
441 }
442
443 pub fn with_strength_components(mut self, components: StrengthComponents) -> Self {
445 self.strength = components.total();
446 self.strength_components = Some(components);
447 self
448 }
449
450 pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
452 self.attributes.insert(key.into(), value.into());
453 self
454 }
455
456 pub fn is_active(&self) -> bool {
458 self.end_date.is_none()
459 }
460
461 pub fn key(&self) -> String {
463 format!(
464 "{}->{}:{}",
465 self.from_id.key(),
466 self.to_id.key(),
467 self.relationship_type.code()
468 )
469 }
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize)]
474pub struct StrengthComponents {
475 pub transaction_volume: f64,
477 pub transaction_count: f64,
479 pub duration: f64,
481 pub recency: f64,
483 pub mutual_connections: f64,
485}
486
487impl StrengthComponents {
488 pub fn new(
490 transaction_volume: f64,
491 transaction_count: f64,
492 duration: f64,
493 recency: f64,
494 mutual_connections: f64,
495 ) -> Self {
496 Self {
497 transaction_volume: transaction_volume.clamp(0.0, 1.0),
498 transaction_count: transaction_count.clamp(0.0, 1.0),
499 duration: duration.clamp(0.0, 1.0),
500 recency: recency.clamp(0.0, 1.0),
501 mutual_connections: mutual_connections.clamp(0.0, 1.0),
502 }
503 }
504
505 pub fn total(&self) -> f64 {
507 self.total_weighted(RelationshipStrengthCalculator::default_weights())
508 }
509
510 pub fn total_weighted(&self, weights: &StrengthWeights) -> f64 {
512 let total = self.transaction_volume * weights.transaction_volume_weight
513 + self.transaction_count * weights.transaction_count_weight
514 + self.duration * weights.duration_weight
515 + self.recency * weights.recency_weight
516 + self.mutual_connections * weights.mutual_connections_weight;
517
518 total.clamp(0.0, 1.0)
519 }
520}
521
522#[derive(Debug, Clone, Serialize, Deserialize)]
524pub struct StrengthWeights {
525 pub transaction_volume_weight: f64,
527 pub transaction_count_weight: f64,
529 pub duration_weight: f64,
531 pub recency_weight: f64,
533 pub mutual_connections_weight: f64,
535}
536
537impl Default for StrengthWeights {
538 fn default() -> Self {
539 Self {
540 transaction_volume_weight: 0.30,
541 transaction_count_weight: 0.25,
542 duration_weight: 0.20,
543 recency_weight: 0.15,
544 mutual_connections_weight: 0.10,
545 }
546 }
547}
548
549impl StrengthWeights {
550 pub fn validate(&self) -> Result<(), String> {
552 let sum = self.transaction_volume_weight
553 + self.transaction_count_weight
554 + self.duration_weight
555 + self.recency_weight
556 + self.mutual_connections_weight;
557
558 if (sum - 1.0).abs() > 0.01 {
559 Err(format!("Strength weights must sum to 1.0, got {}", sum))
560 } else {
561 Ok(())
562 }
563 }
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize)]
568pub struct RelationshipStrengthCalculator {
569 pub weights: StrengthWeights,
571 pub recency_half_life_days: u32,
573 pub max_transaction_volume: Decimal,
575 pub max_transaction_count: u32,
577 pub max_duration_days: u32,
579}
580
581impl Default for RelationshipStrengthCalculator {
582 fn default() -> Self {
583 Self {
584 weights: StrengthWeights::default(),
585 recency_half_life_days: 90,
586 max_transaction_volume: Decimal::from(10_000_000),
587 max_transaction_count: 1000,
588 max_duration_days: 3650, }
590 }
591}
592
593impl RelationshipStrengthCalculator {
594 pub fn default_weights() -> &'static StrengthWeights {
596 static WEIGHTS: std::sync::OnceLock<StrengthWeights> = std::sync::OnceLock::new();
597 WEIGHTS.get_or_init(StrengthWeights::default)
598 }
599
600 pub fn calculate(
602 &self,
603 transaction_volume: Decimal,
604 transaction_count: u32,
605 relationship_days: u32,
606 days_since_last_transaction: u32,
607 mutual_connections: usize,
608 total_possible_connections: usize,
609 ) -> StrengthComponents {
610 let volume_normalized = if transaction_volume > Decimal::ZERO
612 && self.max_transaction_volume > Decimal::ZERO
613 {
614 let log_vol = (transaction_volume.to_string().parse::<f64>().unwrap_or(1.0) + 1.0).ln();
615 let log_max = (self
616 .max_transaction_volume
617 .to_string()
618 .parse::<f64>()
619 .unwrap_or(1.0)
620 + 1.0)
621 .ln();
622 (log_vol / log_max).min(1.0)
623 } else {
624 0.0
625 };
626
627 let count_normalized = if self.max_transaction_count > 0 {
629 let sqrt_count = (transaction_count as f64).sqrt();
630 let sqrt_max = (self.max_transaction_count as f64).sqrt();
631 (sqrt_count / sqrt_max).min(1.0)
632 } else {
633 0.0
634 };
635
636 let duration_normalized = if self.max_duration_days > 0 {
638 (relationship_days as f64 / self.max_duration_days as f64).min(1.0)
639 } else {
640 0.0
641 };
642
643 let recency_normalized = if self.recency_half_life_days > 0 {
645 let decay_rate = 0.693 / self.recency_half_life_days as f64; (-decay_rate * days_since_last_transaction as f64).exp()
647 } else {
648 1.0
649 };
650
651 let mutual_normalized = if total_possible_connections > 0 {
653 mutual_connections as f64 / total_possible_connections as f64
654 } else {
655 0.0
656 };
657
658 StrengthComponents::new(
659 volume_normalized,
660 count_normalized,
661 duration_normalized,
662 recency_normalized,
663 mutual_normalized,
664 )
665 }
666}
667
668#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
670#[serde(rename_all = "snake_case")]
671pub enum RelationshipStrength {
672 Strong,
674 Moderate,
676 Weak,
678 Dormant,
680}
681
682impl RelationshipStrength {
683 pub fn from_value(strength: f64) -> Self {
685 if strength >= 0.7 {
686 Self::Strong
687 } else if strength >= 0.4 {
688 Self::Moderate
689 } else if strength >= 0.1 {
690 Self::Weak
691 } else {
692 Self::Dormant
693 }
694 }
695
696 pub fn min_threshold(&self) -> f64 {
698 match self {
699 Self::Strong => 0.7,
700 Self::Moderate => 0.4,
701 Self::Weak => 0.1,
702 Self::Dormant => 0.0,
703 }
704 }
705}
706
707#[derive(Debug, Clone, Default, Serialize, Deserialize)]
709pub struct GraphIndexes {
710 pub outgoing_edges: HashMap<String, Vec<usize>>,
712 pub incoming_edges: HashMap<String, Vec<usize>>,
714 pub edges_by_type: HashMap<RelationshipType, Vec<usize>>,
716 pub nodes_by_type: HashMap<GraphEntityType, Vec<String>>,
718}
719
720#[derive(Debug, Clone, Default, Serialize, Deserialize)]
722pub struct EntityGraph {
723 pub nodes: HashMap<String, EntityNode>,
725 pub edges: Vec<RelationshipEdge>,
727 #[serde(skip)]
729 pub indexes: GraphIndexes,
730 pub metadata: GraphMetadata,
732}
733
734#[derive(Debug, Clone, Default, Serialize, Deserialize)]
736pub struct GraphMetadata {
737 pub company_code: Option<String>,
739 pub created_date: Option<NaiveDate>,
741 #[serde(with = "rust_decimal::serde::str")]
743 pub total_transaction_volume: Decimal,
744 pub date_range: Option<(NaiveDate, NaiveDate)>,
746}
747
748impl EntityGraph {
749 pub fn new() -> Self {
751 Self::default()
752 }
753
754 pub fn add_node(&mut self, node: EntityNode) {
756 let key = node.entity_id.key();
757 let entity_type = node.entity_id.entity_type;
758
759 self.nodes.insert(key.clone(), node);
760 self.indexes
761 .nodes_by_type
762 .entry(entity_type)
763 .or_default()
764 .push(key);
765 }
766
767 pub fn add_edge(&mut self, edge: RelationshipEdge) {
769 let edge_idx = self.edges.len();
770 let from_key = edge.from_id.key();
771 let to_key = edge.to_id.key();
772 let rel_type = edge.relationship_type;
773
774 self.indexes
775 .outgoing_edges
776 .entry(from_key)
777 .or_default()
778 .push(edge_idx);
779 self.indexes
780 .incoming_edges
781 .entry(to_key)
782 .or_default()
783 .push(edge_idx);
784 self.indexes
785 .edges_by_type
786 .entry(rel_type)
787 .or_default()
788 .push(edge_idx);
789
790 self.edges.push(edge);
791 }
792
793 pub fn get_node(&self, entity_id: &GraphEntityId) -> Option<&EntityNode> {
795 self.nodes.get(&entity_id.key())
796 }
797
798 pub fn get_outgoing_edges(&self, entity_id: &GraphEntityId) -> Vec<&RelationshipEdge> {
800 self.indexes
801 .outgoing_edges
802 .get(&entity_id.key())
803 .map(|indices| indices.iter().map(|&idx| &self.edges[idx]).collect())
804 .unwrap_or_default()
805 }
806
807 pub fn get_incoming_edges(&self, entity_id: &GraphEntityId) -> Vec<&RelationshipEdge> {
809 self.indexes
810 .incoming_edges
811 .get(&entity_id.key())
812 .map(|indices| indices.iter().map(|&idx| &self.edges[idx]).collect())
813 .unwrap_or_default()
814 }
815
816 pub fn get_edges_by_type(&self, rel_type: RelationshipType) -> Vec<&RelationshipEdge> {
818 self.indexes
819 .edges_by_type
820 .get(&rel_type)
821 .map(|indices| indices.iter().map(|&idx| &self.edges[idx]).collect())
822 .unwrap_or_default()
823 }
824
825 pub fn get_nodes_by_type(&self, entity_type: GraphEntityType) -> Vec<&EntityNode> {
827 self.indexes
828 .nodes_by_type
829 .get(&entity_type)
830 .map(|keys| keys.iter().filter_map(|k| self.nodes.get(k)).collect())
831 .unwrap_or_default()
832 }
833
834 pub fn get_neighbors(&self, entity_id: &GraphEntityId) -> Vec<&EntityNode> {
836 let mut neighbor_ids: HashSet<String> = HashSet::new();
837
838 for edge in self.get_outgoing_edges(entity_id) {
840 neighbor_ids.insert(edge.to_id.key());
841 }
842
843 for edge in self.get_incoming_edges(entity_id) {
845 neighbor_ids.insert(edge.from_id.key());
846 }
847
848 neighbor_ids
849 .iter()
850 .filter_map(|key| self.nodes.get(key))
851 .collect()
852 }
853
854 pub fn node_degree(&self, entity_id: &GraphEntityId) -> usize {
856 let key = entity_id.key();
857 let out_degree = self
858 .indexes
859 .outgoing_edges
860 .get(&key)
861 .map(|v| v.len())
862 .unwrap_or(0);
863 let in_degree = self
864 .indexes
865 .incoming_edges
866 .get(&key)
867 .map(|v| v.len())
868 .unwrap_or(0);
869 out_degree + in_degree
870 }
871
872 pub fn rebuild_indexes(&mut self) {
874 self.indexes = GraphIndexes::default();
875
876 for (key, node) in &self.nodes {
878 self.indexes
879 .nodes_by_type
880 .entry(node.entity_id.entity_type)
881 .or_default()
882 .push(key.clone());
883 }
884
885 for (idx, edge) in self.edges.iter().enumerate() {
887 self.indexes
888 .outgoing_edges
889 .entry(edge.from_id.key())
890 .or_default()
891 .push(idx);
892 self.indexes
893 .incoming_edges
894 .entry(edge.to_id.key())
895 .or_default()
896 .push(idx);
897 self.indexes
898 .edges_by_type
899 .entry(edge.relationship_type)
900 .or_default()
901 .push(idx);
902 }
903 }
904
905 pub fn statistics(&self) -> GraphStatistics {
907 let node_count = self.nodes.len();
908 let edge_count = self.edges.len();
909
910 let avg_degree = if node_count > 0 {
912 (2.0 * edge_count as f64) / node_count as f64
913 } else {
914 0.0
915 };
916
917 let avg_strength = if edge_count > 0 {
919 self.edges.iter().map(|e| e.strength).sum::<f64>() / edge_count as f64
920 } else {
921 0.0
922 };
923
924 let mut node_counts: HashMap<String, usize> = HashMap::new();
926 for node in self.nodes.values() {
927 *node_counts
928 .entry(format!("{:?}", node.entity_id.entity_type))
929 .or_insert(0) += 1;
930 }
931
932 let mut edge_counts: HashMap<String, usize> = HashMap::new();
934 for edge in &self.edges {
935 *edge_counts
936 .entry(format!("{:?}", edge.relationship_type))
937 .or_insert(0) += 1;
938 }
939
940 let mut strength_distribution: HashMap<String, usize> = HashMap::new();
942 for edge in &self.edges {
943 let classification = RelationshipStrength::from_value(edge.strength);
944 *strength_distribution
945 .entry(format!("{:?}", classification))
946 .or_insert(0) += 1;
947 }
948
949 GraphStatistics {
950 node_count,
951 edge_count,
952 avg_degree,
953 avg_strength,
954 node_counts,
955 edge_counts,
956 strength_distribution,
957 }
958 }
959}
960
961#[derive(Debug, Clone, Serialize, Deserialize)]
963pub struct GraphStatistics {
964 pub node_count: usize,
966 pub edge_count: usize,
968 pub avg_degree: f64,
970 pub avg_strength: f64,
972 pub node_counts: HashMap<String, usize>,
974 pub edge_counts: HashMap<String, usize>,
976 pub strength_distribution: HashMap<String, usize>,
978}
979
980#[derive(Debug, Clone, Serialize, Deserialize)]
982pub struct CrossProcessLink {
983 pub material_id: String,
985 pub source_process: String,
987 pub source_document_id: String,
989 pub target_process: String,
991 pub target_document_id: String,
993 pub link_type: CrossProcessLinkType,
995 #[serde(with = "rust_decimal::serde::str")]
997 pub quantity: Decimal,
998 pub link_date: NaiveDate,
1000}
1001
1002#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1004#[serde(rename_all = "snake_case")]
1005pub enum CrossProcessLinkType {
1006 InventoryMovement,
1008 ReturnFlow,
1010 PaymentReconciliation,
1012 IntercompanyBilateral,
1014}
1015
1016impl CrossProcessLink {
1017 #[allow(clippy::too_many_arguments)]
1019 pub fn new(
1020 material_id: impl Into<String>,
1021 source_process: impl Into<String>,
1022 source_document_id: impl Into<String>,
1023 target_process: impl Into<String>,
1024 target_document_id: impl Into<String>,
1025 link_type: CrossProcessLinkType,
1026 quantity: Decimal,
1027 link_date: NaiveDate,
1028 ) -> Self {
1029 Self {
1030 material_id: material_id.into(),
1031 source_process: source_process.into(),
1032 source_document_id: source_document_id.into(),
1033 target_process: target_process.into(),
1034 target_document_id: target_document_id.into(),
1035 link_type,
1036 quantity,
1037 link_date,
1038 }
1039 }
1040}
1041
1042#[cfg(test)]
1043#[allow(clippy::unwrap_used)]
1044mod tests {
1045 use super::*;
1046
1047 #[test]
1048 fn test_entity_id() {
1049 let id = GraphEntityId::new(GraphEntityType::Vendor, "V-001234");
1050 assert_eq!(id.key(), "VN:V-001234");
1051 }
1052
1053 #[test]
1054 fn test_relationship_type_inverse() {
1055 assert_eq!(
1056 RelationshipType::BuysFrom.inverse(),
1057 RelationshipType::SellsTo
1058 );
1059 assert_eq!(
1060 RelationshipType::SellsTo.inverse(),
1061 RelationshipType::BuysFrom
1062 );
1063 assert_eq!(
1064 RelationshipType::ReportsTo.inverse(),
1065 RelationshipType::Manages
1066 );
1067 }
1068
1069 #[test]
1070 fn test_strength_weights_validation() {
1071 let valid_weights = StrengthWeights::default();
1072 assert!(valid_weights.validate().is_ok());
1073
1074 let invalid_weights = StrengthWeights {
1075 transaction_volume_weight: 0.5,
1076 transaction_count_weight: 0.5,
1077 duration_weight: 0.5,
1078 recency_weight: 0.5,
1079 mutual_connections_weight: 0.5,
1080 };
1081 assert!(invalid_weights.validate().is_err());
1082 }
1083
1084 #[test]
1085 fn test_strength_calculator() {
1086 let calc = RelationshipStrengthCalculator::default();
1087 let components = calc.calculate(Decimal::from(100000), 50, 365, 30, 5, 20);
1088
1089 assert!(components.transaction_volume > 0.0);
1090 assert!(components.transaction_count > 0.0);
1091 assert!(components.duration > 0.0);
1092 assert!(components.recency > 0.0);
1093 assert!(components.mutual_connections > 0.0);
1094 assert!(components.total() <= 1.0);
1095 }
1096
1097 #[test]
1098 fn test_relationship_strength_classification() {
1099 assert_eq!(
1100 RelationshipStrength::from_value(0.8),
1101 RelationshipStrength::Strong
1102 );
1103 assert_eq!(
1104 RelationshipStrength::from_value(0.5),
1105 RelationshipStrength::Moderate
1106 );
1107 assert_eq!(
1108 RelationshipStrength::from_value(0.2),
1109 RelationshipStrength::Weak
1110 );
1111 assert_eq!(
1112 RelationshipStrength::from_value(0.05),
1113 RelationshipStrength::Dormant
1114 );
1115 }
1116
1117 #[test]
1118 fn test_entity_graph() {
1119 let mut graph = EntityGraph::new();
1120
1121 let vendor_id = GraphEntityId::new(GraphEntityType::Vendor, "V-001");
1122 let customer_id = GraphEntityId::new(GraphEntityType::Customer, "C-001");
1123
1124 graph.add_node(EntityNode::new(
1125 vendor_id.clone(),
1126 "Acme Supplies",
1127 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1128 ));
1129
1130 graph.add_node(EntityNode::new(
1131 customer_id.clone(),
1132 "Contoso Corp",
1133 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1134 ));
1135
1136 graph.add_edge(
1137 RelationshipEdge::new(
1138 vendor_id.clone(),
1139 customer_id.clone(),
1140 RelationshipType::SellsTo,
1141 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1142 )
1143 .with_strength(0.7),
1144 );
1145
1146 assert_eq!(graph.nodes.len(), 2);
1147 assert_eq!(graph.edges.len(), 1);
1148
1149 let neighbors = graph.get_neighbors(&vendor_id);
1150 assert_eq!(neighbors.len(), 1);
1151 assert_eq!(neighbors[0].entity_id.id, "C-001");
1152
1153 assert_eq!(graph.node_degree(&vendor_id), 1);
1154 assert_eq!(graph.node_degree(&customer_id), 1);
1155 }
1156
1157 #[test]
1158 fn test_graph_statistics() {
1159 let mut graph = EntityGraph::new();
1160
1161 for i in 0..10 {
1162 let id = GraphEntityId::new(GraphEntityType::Vendor, format!("V-{:03}", i));
1163 graph.add_node(EntityNode::new(
1164 id,
1165 format!("Vendor {}", i),
1166 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1167 ));
1168 }
1169
1170 for i in 0..5 {
1171 let from_id = GraphEntityId::new(GraphEntityType::Vendor, format!("V-{:03}", i));
1172 let to_id = GraphEntityId::new(GraphEntityType::Vendor, format!("V-{:03}", i + 5));
1173 graph.add_edge(
1174 RelationshipEdge::new(
1175 from_id,
1176 to_id,
1177 RelationshipType::PartnersWith,
1178 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1179 )
1180 .with_strength(0.6),
1181 );
1182 }
1183
1184 let stats = graph.statistics();
1185 assert_eq!(stats.node_count, 10);
1186 assert_eq!(stats.edge_count, 5);
1187 assert!((stats.avg_degree - 1.0).abs() < 0.01);
1188 assert!((stats.avg_strength - 0.6).abs() < 0.01);
1189 }
1190
1191 #[test]
1192 fn test_cross_process_link() {
1193 let link = CrossProcessLink::new(
1194 "MAT-001",
1195 "P2P",
1196 "GR-12345",
1197 "O2C",
1198 "DEL-67890",
1199 CrossProcessLinkType::InventoryMovement,
1200 Decimal::from(100),
1201 NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(),
1202 );
1203
1204 assert_eq!(link.material_id, "MAT-001");
1205 assert_eq!(link.link_type, CrossProcessLinkType::InventoryMovement);
1206 }
1207}