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}
54
55impl GraphEntityType {
56 pub fn code(&self) -> &'static str {
58 match self {
59 Self::Company => "CO",
60 Self::Vendor => "VN",
61 Self::Customer => "CU",
62 Self::Employee => "EM",
63 Self::Department => "DP",
64 Self::CostCenter => "CC",
65 Self::Project => "PJ",
66 Self::Contract => "CT",
67 Self::Asset => "AS",
68 Self::BankAccount => "BA",
69 Self::Material => "MT",
70 Self::GlAccount => "GL",
71 Self::PurchaseOrder => "PO",
72 Self::SalesOrder => "SO",
73 Self::Invoice => "IV",
74 Self::Payment => "PM",
75 }
76 }
77
78 pub fn is_master_data(&self) -> bool {
80 matches!(
81 self,
82 Self::Company
83 | Self::Vendor
84 | Self::Customer
85 | Self::Employee
86 | Self::Department
87 | Self::CostCenter
88 | Self::Material
89 | Self::GlAccount
90 )
91 }
92
93 pub fn is_transactional(&self) -> bool {
95 matches!(
96 self,
97 Self::PurchaseOrder | Self::SalesOrder | Self::Invoice | Self::Payment
98 )
99 }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum RelationshipType {
106 BuysFrom,
109 SellsTo,
111 PaysTo,
113 ReceivesFrom,
115 SuppliesTo,
117 SourcesFrom,
119
120 ReportsTo,
123 Manages,
125 BelongsTo,
127 OwnedBy,
129 WorksIn,
131 ResponsibleFor,
133
134 ReferredBy,
137 PartnersWith,
139 AffiliatedWith,
141 Intercompany,
143
144 References,
147 ReferencedBy,
149 Fulfills,
151 FulfilledBy,
153 AppliesTo,
155 AppliedBy,
157
158 InventoryLink,
161 UsedIn,
163 SourcedVia,
165}
166
167impl RelationshipType {
168 pub fn code(&self) -> &'static str {
170 match self {
171 Self::BuysFrom => "BF",
172 Self::SellsTo => "ST",
173 Self::PaysTo => "PT",
174 Self::ReceivesFrom => "RF",
175 Self::SuppliesTo => "SP",
176 Self::SourcesFrom => "SF",
177 Self::ReportsTo => "RT",
178 Self::Manages => "MG",
179 Self::BelongsTo => "BT",
180 Self::OwnedBy => "OB",
181 Self::WorksIn => "WI",
182 Self::ResponsibleFor => "RS",
183 Self::ReferredBy => "RB",
184 Self::PartnersWith => "PW",
185 Self::AffiliatedWith => "AW",
186 Self::Intercompany => "IC",
187 Self::References => "REF",
188 Self::ReferencedBy => "RBY",
189 Self::Fulfills => "FL",
190 Self::FulfilledBy => "FLB",
191 Self::AppliesTo => "AP",
192 Self::AppliedBy => "APB",
193 Self::InventoryLink => "INV",
194 Self::UsedIn => "UI",
195 Self::SourcedVia => "SV",
196 }
197 }
198
199 pub fn inverse(&self) -> Self {
201 match self {
202 Self::BuysFrom => Self::SellsTo,
203 Self::SellsTo => Self::BuysFrom,
204 Self::PaysTo => Self::ReceivesFrom,
205 Self::ReceivesFrom => Self::PaysTo,
206 Self::SuppliesTo => Self::SourcesFrom,
207 Self::SourcesFrom => Self::SuppliesTo,
208 Self::ReportsTo => Self::Manages,
209 Self::Manages => Self::ReportsTo,
210 Self::BelongsTo => Self::OwnedBy,
211 Self::OwnedBy => Self::BelongsTo,
212 Self::References => Self::ReferencedBy,
213 Self::ReferencedBy => Self::References,
214 Self::Fulfills => Self::FulfilledBy,
215 Self::FulfilledBy => Self::Fulfills,
216 Self::AppliesTo => Self::AppliedBy,
217 Self::AppliedBy => Self::AppliesTo,
218 Self::WorksIn => Self::WorksIn,
220 Self::ResponsibleFor => Self::ResponsibleFor,
221 Self::ReferredBy => Self::ReferredBy,
222 Self::PartnersWith => Self::PartnersWith,
223 Self::AffiliatedWith => Self::AffiliatedWith,
224 Self::Intercompany => Self::Intercompany,
225 Self::InventoryLink => Self::InventoryLink,
226 Self::UsedIn => Self::UsedIn,
227 Self::SourcedVia => Self::SourcedVia,
228 }
229 }
230
231 pub fn is_transactional(&self) -> bool {
233 matches!(
234 self,
235 Self::BuysFrom
236 | Self::SellsTo
237 | Self::PaysTo
238 | Self::ReceivesFrom
239 | Self::SuppliesTo
240 | Self::SourcesFrom
241 )
242 }
243
244 pub fn is_organizational(&self) -> bool {
246 matches!(
247 self,
248 Self::ReportsTo
249 | Self::Manages
250 | Self::BelongsTo
251 | Self::OwnedBy
252 | Self::WorksIn
253 | Self::ResponsibleFor
254 )
255 }
256
257 pub fn is_document(&self) -> bool {
259 matches!(
260 self,
261 Self::References
262 | Self::ReferencedBy
263 | Self::Fulfills
264 | Self::FulfilledBy
265 | Self::AppliesTo
266 | Self::AppliedBy
267 )
268 }
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
276pub struct GraphEntityId {
277 pub entity_type: GraphEntityType,
279 pub id: String,
281}
282
283impl GraphEntityId {
284 pub fn new(entity_type: GraphEntityType, id: impl Into<String>) -> Self {
286 Self {
287 entity_type,
288 id: id.into(),
289 }
290 }
291
292 pub fn key(&self) -> String {
294 format!("{}:{}", self.entity_type.code(), self.id)
295 }
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct EntityNode {
301 pub entity_id: GraphEntityId,
303 pub name: String,
305 pub attributes: HashMap<String, String>,
307 pub created_date: NaiveDate,
309 pub is_active: bool,
311 pub company_code: Option<String>,
313}
314
315impl EntityNode {
316 pub fn new(entity_id: GraphEntityId, name: impl Into<String>, created_date: NaiveDate) -> Self {
318 Self {
319 entity_id,
320 name: name.into(),
321 attributes: HashMap::new(),
322 created_date,
323 is_active: true,
324 company_code: None,
325 }
326 }
327
328 pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
330 self.attributes.insert(key.into(), value.into());
331 self
332 }
333
334 pub fn with_company(mut self, company_code: impl Into<String>) -> Self {
336 self.company_code = Some(company_code.into());
337 self
338 }
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct RelationshipEdge {
344 pub from_id: GraphEntityId,
346 pub to_id: GraphEntityId,
348 pub relationship_type: RelationshipType,
350 pub strength: f64,
352 pub start_date: NaiveDate,
354 pub end_date: Option<NaiveDate>,
356 pub attributes: HashMap<String, String>,
358 pub strength_components: Option<StrengthComponents>,
360}
361
362impl RelationshipEdge {
363 pub fn new(
365 from_id: GraphEntityId,
366 to_id: GraphEntityId,
367 relationship_type: RelationshipType,
368 start_date: NaiveDate,
369 ) -> Self {
370 Self {
371 from_id,
372 to_id,
373 relationship_type,
374 strength: 0.5, start_date,
376 end_date: None,
377 attributes: HashMap::new(),
378 strength_components: None,
379 }
380 }
381
382 pub fn with_strength(mut self, strength: f64) -> Self {
384 self.strength = strength.clamp(0.0, 1.0);
385 self
386 }
387
388 pub fn with_strength_components(mut self, components: StrengthComponents) -> Self {
390 self.strength = components.total();
391 self.strength_components = Some(components);
392 self
393 }
394
395 pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
397 self.attributes.insert(key.into(), value.into());
398 self
399 }
400
401 pub fn is_active(&self) -> bool {
403 self.end_date.is_none()
404 }
405
406 pub fn key(&self) -> String {
408 format!(
409 "{}->{}:{}",
410 self.from_id.key(),
411 self.to_id.key(),
412 self.relationship_type.code()
413 )
414 }
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct StrengthComponents {
420 pub transaction_volume: f64,
422 pub transaction_count: f64,
424 pub duration: f64,
426 pub recency: f64,
428 pub mutual_connections: f64,
430}
431
432impl StrengthComponents {
433 pub fn new(
435 transaction_volume: f64,
436 transaction_count: f64,
437 duration: f64,
438 recency: f64,
439 mutual_connections: f64,
440 ) -> Self {
441 Self {
442 transaction_volume: transaction_volume.clamp(0.0, 1.0),
443 transaction_count: transaction_count.clamp(0.0, 1.0),
444 duration: duration.clamp(0.0, 1.0),
445 recency: recency.clamp(0.0, 1.0),
446 mutual_connections: mutual_connections.clamp(0.0, 1.0),
447 }
448 }
449
450 pub fn total(&self) -> f64 {
452 self.total_weighted(RelationshipStrengthCalculator::default_weights())
453 }
454
455 pub fn total_weighted(&self, weights: &StrengthWeights) -> f64 {
457 let total = self.transaction_volume * weights.transaction_volume_weight
458 + self.transaction_count * weights.transaction_count_weight
459 + self.duration * weights.duration_weight
460 + self.recency * weights.recency_weight
461 + self.mutual_connections * weights.mutual_connections_weight;
462
463 total.clamp(0.0, 1.0)
464 }
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct StrengthWeights {
470 pub transaction_volume_weight: f64,
472 pub transaction_count_weight: f64,
474 pub duration_weight: f64,
476 pub recency_weight: f64,
478 pub mutual_connections_weight: f64,
480}
481
482impl Default for StrengthWeights {
483 fn default() -> Self {
484 Self {
485 transaction_volume_weight: 0.30,
486 transaction_count_weight: 0.25,
487 duration_weight: 0.20,
488 recency_weight: 0.15,
489 mutual_connections_weight: 0.10,
490 }
491 }
492}
493
494impl StrengthWeights {
495 pub fn validate(&self) -> Result<(), String> {
497 let sum = self.transaction_volume_weight
498 + self.transaction_count_weight
499 + self.duration_weight
500 + self.recency_weight
501 + self.mutual_connections_weight;
502
503 if (sum - 1.0).abs() > 0.01 {
504 Err(format!("Strength weights must sum to 1.0, got {}", sum))
505 } else {
506 Ok(())
507 }
508 }
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
513pub struct RelationshipStrengthCalculator {
514 pub weights: StrengthWeights,
516 pub recency_half_life_days: u32,
518 pub max_transaction_volume: Decimal,
520 pub max_transaction_count: u32,
522 pub max_duration_days: u32,
524}
525
526impl Default for RelationshipStrengthCalculator {
527 fn default() -> Self {
528 Self {
529 weights: StrengthWeights::default(),
530 recency_half_life_days: 90,
531 max_transaction_volume: Decimal::from(10_000_000),
532 max_transaction_count: 1000,
533 max_duration_days: 3650, }
535 }
536}
537
538impl RelationshipStrengthCalculator {
539 pub fn default_weights() -> &'static StrengthWeights {
541 static WEIGHTS: std::sync::OnceLock<StrengthWeights> = std::sync::OnceLock::new();
542 WEIGHTS.get_or_init(StrengthWeights::default)
543 }
544
545 pub fn calculate(
547 &self,
548 transaction_volume: Decimal,
549 transaction_count: u32,
550 relationship_days: u32,
551 days_since_last_transaction: u32,
552 mutual_connections: usize,
553 total_possible_connections: usize,
554 ) -> StrengthComponents {
555 let volume_normalized = if transaction_volume > Decimal::ZERO
557 && self.max_transaction_volume > Decimal::ZERO
558 {
559 let log_vol = (transaction_volume.to_string().parse::<f64>().unwrap_or(1.0) + 1.0).ln();
560 let log_max = (self
561 .max_transaction_volume
562 .to_string()
563 .parse::<f64>()
564 .unwrap_or(1.0)
565 + 1.0)
566 .ln();
567 (log_vol / log_max).min(1.0)
568 } else {
569 0.0
570 };
571
572 let count_normalized = if self.max_transaction_count > 0 {
574 let sqrt_count = (transaction_count as f64).sqrt();
575 let sqrt_max = (self.max_transaction_count as f64).sqrt();
576 (sqrt_count / sqrt_max).min(1.0)
577 } else {
578 0.0
579 };
580
581 let duration_normalized = if self.max_duration_days > 0 {
583 (relationship_days as f64 / self.max_duration_days as f64).min(1.0)
584 } else {
585 0.0
586 };
587
588 let recency_normalized = if self.recency_half_life_days > 0 {
590 let decay_rate = 0.693 / self.recency_half_life_days as f64; (-decay_rate * days_since_last_transaction as f64).exp()
592 } else {
593 1.0
594 };
595
596 let mutual_normalized = if total_possible_connections > 0 {
598 mutual_connections as f64 / total_possible_connections as f64
599 } else {
600 0.0
601 };
602
603 StrengthComponents::new(
604 volume_normalized,
605 count_normalized,
606 duration_normalized,
607 recency_normalized,
608 mutual_normalized,
609 )
610 }
611}
612
613#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
615#[serde(rename_all = "snake_case")]
616pub enum RelationshipStrength {
617 Strong,
619 Moderate,
621 Weak,
623 Dormant,
625}
626
627impl RelationshipStrength {
628 pub fn from_value(strength: f64) -> Self {
630 if strength >= 0.7 {
631 Self::Strong
632 } else if strength >= 0.4 {
633 Self::Moderate
634 } else if strength >= 0.1 {
635 Self::Weak
636 } else {
637 Self::Dormant
638 }
639 }
640
641 pub fn min_threshold(&self) -> f64 {
643 match self {
644 Self::Strong => 0.7,
645 Self::Moderate => 0.4,
646 Self::Weak => 0.1,
647 Self::Dormant => 0.0,
648 }
649 }
650}
651
652#[derive(Debug, Clone, Default, Serialize, Deserialize)]
654pub struct GraphIndexes {
655 pub outgoing_edges: HashMap<String, Vec<usize>>,
657 pub incoming_edges: HashMap<String, Vec<usize>>,
659 pub edges_by_type: HashMap<RelationshipType, Vec<usize>>,
661 pub nodes_by_type: HashMap<GraphEntityType, Vec<String>>,
663}
664
665#[derive(Debug, Clone, Default, Serialize, Deserialize)]
667pub struct EntityGraph {
668 pub nodes: HashMap<String, EntityNode>,
670 pub edges: Vec<RelationshipEdge>,
672 #[serde(skip)]
674 pub indexes: GraphIndexes,
675 pub metadata: GraphMetadata,
677}
678
679#[derive(Debug, Clone, Default, Serialize, Deserialize)]
681pub struct GraphMetadata {
682 pub company_code: Option<String>,
684 pub created_date: Option<NaiveDate>,
686 #[serde(with = "rust_decimal::serde::str")]
688 pub total_transaction_volume: Decimal,
689 pub date_range: Option<(NaiveDate, NaiveDate)>,
691}
692
693impl EntityGraph {
694 pub fn new() -> Self {
696 Self::default()
697 }
698
699 pub fn add_node(&mut self, node: EntityNode) {
701 let key = node.entity_id.key();
702 let entity_type = node.entity_id.entity_type;
703
704 self.nodes.insert(key.clone(), node);
705 self.indexes
706 .nodes_by_type
707 .entry(entity_type)
708 .or_default()
709 .push(key);
710 }
711
712 pub fn add_edge(&mut self, edge: RelationshipEdge) {
714 let edge_idx = self.edges.len();
715 let from_key = edge.from_id.key();
716 let to_key = edge.to_id.key();
717 let rel_type = edge.relationship_type;
718
719 self.indexes
720 .outgoing_edges
721 .entry(from_key)
722 .or_default()
723 .push(edge_idx);
724 self.indexes
725 .incoming_edges
726 .entry(to_key)
727 .or_default()
728 .push(edge_idx);
729 self.indexes
730 .edges_by_type
731 .entry(rel_type)
732 .or_default()
733 .push(edge_idx);
734
735 self.edges.push(edge);
736 }
737
738 pub fn get_node(&self, entity_id: &GraphEntityId) -> Option<&EntityNode> {
740 self.nodes.get(&entity_id.key())
741 }
742
743 pub fn get_outgoing_edges(&self, entity_id: &GraphEntityId) -> Vec<&RelationshipEdge> {
745 self.indexes
746 .outgoing_edges
747 .get(&entity_id.key())
748 .map(|indices| indices.iter().map(|&idx| &self.edges[idx]).collect())
749 .unwrap_or_default()
750 }
751
752 pub fn get_incoming_edges(&self, entity_id: &GraphEntityId) -> Vec<&RelationshipEdge> {
754 self.indexes
755 .incoming_edges
756 .get(&entity_id.key())
757 .map(|indices| indices.iter().map(|&idx| &self.edges[idx]).collect())
758 .unwrap_or_default()
759 }
760
761 pub fn get_edges_by_type(&self, rel_type: RelationshipType) -> Vec<&RelationshipEdge> {
763 self.indexes
764 .edges_by_type
765 .get(&rel_type)
766 .map(|indices| indices.iter().map(|&idx| &self.edges[idx]).collect())
767 .unwrap_or_default()
768 }
769
770 pub fn get_nodes_by_type(&self, entity_type: GraphEntityType) -> Vec<&EntityNode> {
772 self.indexes
773 .nodes_by_type
774 .get(&entity_type)
775 .map(|keys| keys.iter().filter_map(|k| self.nodes.get(k)).collect())
776 .unwrap_or_default()
777 }
778
779 pub fn get_neighbors(&self, entity_id: &GraphEntityId) -> Vec<&EntityNode> {
781 let mut neighbor_ids: HashSet<String> = HashSet::new();
782
783 for edge in self.get_outgoing_edges(entity_id) {
785 neighbor_ids.insert(edge.to_id.key());
786 }
787
788 for edge in self.get_incoming_edges(entity_id) {
790 neighbor_ids.insert(edge.from_id.key());
791 }
792
793 neighbor_ids
794 .iter()
795 .filter_map(|key| self.nodes.get(key))
796 .collect()
797 }
798
799 pub fn node_degree(&self, entity_id: &GraphEntityId) -> usize {
801 let key = entity_id.key();
802 let out_degree = self
803 .indexes
804 .outgoing_edges
805 .get(&key)
806 .map(|v| v.len())
807 .unwrap_or(0);
808 let in_degree = self
809 .indexes
810 .incoming_edges
811 .get(&key)
812 .map(|v| v.len())
813 .unwrap_or(0);
814 out_degree + in_degree
815 }
816
817 pub fn rebuild_indexes(&mut self) {
819 self.indexes = GraphIndexes::default();
820
821 for (key, node) in &self.nodes {
823 self.indexes
824 .nodes_by_type
825 .entry(node.entity_id.entity_type)
826 .or_default()
827 .push(key.clone());
828 }
829
830 for (idx, edge) in self.edges.iter().enumerate() {
832 self.indexes
833 .outgoing_edges
834 .entry(edge.from_id.key())
835 .or_default()
836 .push(idx);
837 self.indexes
838 .incoming_edges
839 .entry(edge.to_id.key())
840 .or_default()
841 .push(idx);
842 self.indexes
843 .edges_by_type
844 .entry(edge.relationship_type)
845 .or_default()
846 .push(idx);
847 }
848 }
849
850 pub fn statistics(&self) -> GraphStatistics {
852 let node_count = self.nodes.len();
853 let edge_count = self.edges.len();
854
855 let avg_degree = if node_count > 0 {
857 (2.0 * edge_count as f64) / node_count as f64
858 } else {
859 0.0
860 };
861
862 let avg_strength = if edge_count > 0 {
864 self.edges.iter().map(|e| e.strength).sum::<f64>() / edge_count as f64
865 } else {
866 0.0
867 };
868
869 let mut node_counts: HashMap<String, usize> = HashMap::new();
871 for node in self.nodes.values() {
872 *node_counts
873 .entry(format!("{:?}", node.entity_id.entity_type))
874 .or_insert(0) += 1;
875 }
876
877 let mut edge_counts: HashMap<String, usize> = HashMap::new();
879 for edge in &self.edges {
880 *edge_counts
881 .entry(format!("{:?}", edge.relationship_type))
882 .or_insert(0) += 1;
883 }
884
885 let mut strength_distribution: HashMap<String, usize> = HashMap::new();
887 for edge in &self.edges {
888 let classification = RelationshipStrength::from_value(edge.strength);
889 *strength_distribution
890 .entry(format!("{:?}", classification))
891 .or_insert(0) += 1;
892 }
893
894 GraphStatistics {
895 node_count,
896 edge_count,
897 avg_degree,
898 avg_strength,
899 node_counts,
900 edge_counts,
901 strength_distribution,
902 }
903 }
904}
905
906#[derive(Debug, Clone, Serialize, Deserialize)]
908pub struct GraphStatistics {
909 pub node_count: usize,
911 pub edge_count: usize,
913 pub avg_degree: f64,
915 pub avg_strength: f64,
917 pub node_counts: HashMap<String, usize>,
919 pub edge_counts: HashMap<String, usize>,
921 pub strength_distribution: HashMap<String, usize>,
923}
924
925#[derive(Debug, Clone, Serialize, Deserialize)]
927pub struct CrossProcessLink {
928 pub material_id: String,
930 pub source_process: String,
932 pub source_document_id: String,
934 pub target_process: String,
936 pub target_document_id: String,
938 pub link_type: CrossProcessLinkType,
940 #[serde(with = "rust_decimal::serde::str")]
942 pub quantity: Decimal,
943 pub link_date: NaiveDate,
945}
946
947#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
949#[serde(rename_all = "snake_case")]
950pub enum CrossProcessLinkType {
951 InventoryMovement,
953 ReturnFlow,
955 PaymentReconciliation,
957 IntercompanyBilateral,
959}
960
961impl CrossProcessLink {
962 #[allow(clippy::too_many_arguments)]
964 pub fn new(
965 material_id: impl Into<String>,
966 source_process: impl Into<String>,
967 source_document_id: impl Into<String>,
968 target_process: impl Into<String>,
969 target_document_id: impl Into<String>,
970 link_type: CrossProcessLinkType,
971 quantity: Decimal,
972 link_date: NaiveDate,
973 ) -> Self {
974 Self {
975 material_id: material_id.into(),
976 source_process: source_process.into(),
977 source_document_id: source_document_id.into(),
978 target_process: target_process.into(),
979 target_document_id: target_document_id.into(),
980 link_type,
981 quantity,
982 link_date,
983 }
984 }
985}
986
987#[cfg(test)]
988mod tests {
989 use super::*;
990
991 #[test]
992 fn test_entity_id() {
993 let id = GraphEntityId::new(GraphEntityType::Vendor, "V-001234");
994 assert_eq!(id.key(), "VN:V-001234");
995 }
996
997 #[test]
998 fn test_relationship_type_inverse() {
999 assert_eq!(
1000 RelationshipType::BuysFrom.inverse(),
1001 RelationshipType::SellsTo
1002 );
1003 assert_eq!(
1004 RelationshipType::SellsTo.inverse(),
1005 RelationshipType::BuysFrom
1006 );
1007 assert_eq!(
1008 RelationshipType::ReportsTo.inverse(),
1009 RelationshipType::Manages
1010 );
1011 }
1012
1013 #[test]
1014 fn test_strength_weights_validation() {
1015 let valid_weights = StrengthWeights::default();
1016 assert!(valid_weights.validate().is_ok());
1017
1018 let invalid_weights = StrengthWeights {
1019 transaction_volume_weight: 0.5,
1020 transaction_count_weight: 0.5,
1021 duration_weight: 0.5,
1022 recency_weight: 0.5,
1023 mutual_connections_weight: 0.5,
1024 };
1025 assert!(invalid_weights.validate().is_err());
1026 }
1027
1028 #[test]
1029 fn test_strength_calculator() {
1030 let calc = RelationshipStrengthCalculator::default();
1031 let components = calc.calculate(Decimal::from(100000), 50, 365, 30, 5, 20);
1032
1033 assert!(components.transaction_volume > 0.0);
1034 assert!(components.transaction_count > 0.0);
1035 assert!(components.duration > 0.0);
1036 assert!(components.recency > 0.0);
1037 assert!(components.mutual_connections > 0.0);
1038 assert!(components.total() <= 1.0);
1039 }
1040
1041 #[test]
1042 fn test_relationship_strength_classification() {
1043 assert_eq!(
1044 RelationshipStrength::from_value(0.8),
1045 RelationshipStrength::Strong
1046 );
1047 assert_eq!(
1048 RelationshipStrength::from_value(0.5),
1049 RelationshipStrength::Moderate
1050 );
1051 assert_eq!(
1052 RelationshipStrength::from_value(0.2),
1053 RelationshipStrength::Weak
1054 );
1055 assert_eq!(
1056 RelationshipStrength::from_value(0.05),
1057 RelationshipStrength::Dormant
1058 );
1059 }
1060
1061 #[test]
1062 fn test_entity_graph() {
1063 let mut graph = EntityGraph::new();
1064
1065 let vendor_id = GraphEntityId::new(GraphEntityType::Vendor, "V-001");
1066 let customer_id = GraphEntityId::new(GraphEntityType::Customer, "C-001");
1067
1068 graph.add_node(EntityNode::new(
1069 vendor_id.clone(),
1070 "Acme Supplies",
1071 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1072 ));
1073
1074 graph.add_node(EntityNode::new(
1075 customer_id.clone(),
1076 "Contoso Corp",
1077 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1078 ));
1079
1080 graph.add_edge(
1081 RelationshipEdge::new(
1082 vendor_id.clone(),
1083 customer_id.clone(),
1084 RelationshipType::SellsTo,
1085 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1086 )
1087 .with_strength(0.7),
1088 );
1089
1090 assert_eq!(graph.nodes.len(), 2);
1091 assert_eq!(graph.edges.len(), 1);
1092
1093 let neighbors = graph.get_neighbors(&vendor_id);
1094 assert_eq!(neighbors.len(), 1);
1095 assert_eq!(neighbors[0].entity_id.id, "C-001");
1096
1097 assert_eq!(graph.node_degree(&vendor_id), 1);
1098 assert_eq!(graph.node_degree(&customer_id), 1);
1099 }
1100
1101 #[test]
1102 fn test_graph_statistics() {
1103 let mut graph = EntityGraph::new();
1104
1105 for i in 0..10 {
1106 let id = GraphEntityId::new(GraphEntityType::Vendor, format!("V-{:03}", i));
1107 graph.add_node(EntityNode::new(
1108 id,
1109 format!("Vendor {}", i),
1110 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1111 ));
1112 }
1113
1114 for i in 0..5 {
1115 let from_id = GraphEntityId::new(GraphEntityType::Vendor, format!("V-{:03}", i));
1116 let to_id = GraphEntityId::new(GraphEntityType::Vendor, format!("V-{:03}", i + 5));
1117 graph.add_edge(
1118 RelationshipEdge::new(
1119 from_id,
1120 to_id,
1121 RelationshipType::PartnersWith,
1122 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1123 )
1124 .with_strength(0.6),
1125 );
1126 }
1127
1128 let stats = graph.statistics();
1129 assert_eq!(stats.node_count, 10);
1130 assert_eq!(stats.edge_count, 5);
1131 assert!((stats.avg_degree - 1.0).abs() < 0.01);
1132 assert!((stats.avg_strength - 0.6).abs() < 0.01);
1133 }
1134
1135 #[test]
1136 fn test_cross_process_link() {
1137 let link = CrossProcessLink::new(
1138 "MAT-001",
1139 "P2P",
1140 "GR-12345",
1141 "O2C",
1142 "DEL-67890",
1143 CrossProcessLinkType::InventoryMovement,
1144 Decimal::from(100),
1145 NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(),
1146 );
1147
1148 assert_eq!(link.material_id, "MAT-001");
1149 assert_eq!(link.link_type, CrossProcessLinkType::InventoryMovement);
1150 }
1151}