1use chrono::NaiveDate;
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
17#[serde(rename_all = "snake_case")]
18pub enum VendorRelationshipType {
19 #[default]
21 DirectSupplier,
22 ServiceProvider,
24 Contractor,
26 Distributor,
28 Manufacturer,
30 RawMaterialSupplier,
32 OemPartner,
34 Affiliate,
36 JointVenturePartner,
38 Subcontractor,
40}
41
42impl VendorRelationshipType {
43 pub fn code(&self) -> &'static str {
45 match self {
46 Self::DirectSupplier => "DS",
47 Self::ServiceProvider => "SP",
48 Self::Contractor => "CT",
49 Self::Distributor => "DI",
50 Self::Manufacturer => "MF",
51 Self::RawMaterialSupplier => "RM",
52 Self::OemPartner => "OE",
53 Self::Affiliate => "AF",
54 Self::JointVenturePartner => "JV",
55 Self::Subcontractor => "SC",
56 }
57 }
58
59 pub fn is_strategic(&self) -> bool {
61 matches!(
62 self,
63 Self::OemPartner | Self::JointVenturePartner | Self::Affiliate | Self::Manufacturer
64 )
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
70#[serde(rename_all = "snake_case")]
71pub enum SupplyChainTier {
72 #[default]
74 Tier1,
75 Tier2,
77 Tier3,
79}
80
81impl SupplyChainTier {
82 pub fn tier_number(&self) -> u8 {
84 match self {
85 Self::Tier1 => 1,
86 Self::Tier2 => 2,
87 Self::Tier3 => 3,
88 }
89 }
90
91 pub fn visibility(&self) -> f64 {
93 match self {
94 Self::Tier1 => 1.0,
95 Self::Tier2 => 0.5,
96 Self::Tier3 => 0.2,
97 }
98 }
99
100 pub fn child_tier(&self) -> Option<Self> {
102 match self {
103 Self::Tier1 => Some(Self::Tier2),
104 Self::Tier2 => Some(Self::Tier3),
105 Self::Tier3 => None,
106 }
107 }
108
109 pub fn parent_tier(&self) -> Option<Self> {
111 match self {
112 Self::Tier1 => None,
113 Self::Tier2 => Some(Self::Tier1),
114 Self::Tier3 => Some(Self::Tier2),
115 }
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
121#[serde(rename_all = "snake_case")]
122pub enum StrategicLevel {
123 Critical,
125 Important,
127 #[default]
129 Standard,
130 Transactional,
132}
133
134impl StrategicLevel {
135 pub fn importance_score(&self) -> f64 {
137 match self {
138 Self::Critical => 1.0,
139 Self::Important => 0.75,
140 Self::Standard => 0.5,
141 Self::Transactional => 0.25,
142 }
143 }
144
145 pub fn oversight_level(&self) -> &'static str {
147 match self {
148 Self::Critical => "executive",
149 Self::Important => "senior_management",
150 Self::Standard => "procurement_team",
151 Self::Transactional => "automated",
152 }
153 }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
158#[serde(rename_all = "snake_case")]
159pub enum SpendTier {
160 Platinum,
162 Gold,
164 #[default]
166 Silver,
167 Bronze,
169}
170
171impl SpendTier {
172 pub fn min_spend_percentile(&self) -> f64 {
174 match self {
175 Self::Platinum => 0.95,
176 Self::Gold => 0.80,
177 Self::Silver => 0.50,
178 Self::Bronze => 0.0,
179 }
180 }
181
182 pub fn discount_multiplier(&self) -> f64 {
184 match self {
185 Self::Platinum => 1.15,
186 Self::Gold => 1.10,
187 Self::Silver => 1.05,
188 Self::Bronze => 1.0,
189 }
190 }
191
192 pub fn payment_priority(&self) -> u8 {
194 match self {
195 Self::Platinum => 1,
196 Self::Gold => 2,
197 Self::Silver => 3,
198 Self::Bronze => 4,
199 }
200 }
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
207#[serde(rename_all = "snake_case")]
208pub enum VendorCluster {
209 ReliableStrategic,
211 #[default]
213 StandardOperational,
214 Transactional,
216 Problematic,
218}
219
220impl VendorCluster {
221 pub fn typical_distribution(&self) -> f64 {
223 match self {
224 Self::ReliableStrategic => 0.20,
225 Self::StandardOperational => 0.50,
226 Self::Transactional => 0.25,
227 Self::Problematic => 0.05,
228 }
229 }
230
231 pub fn on_time_delivery_probability(&self) -> f64 {
233 match self {
234 Self::ReliableStrategic => 0.98,
235 Self::StandardOperational => 0.92,
236 Self::Transactional => 0.85,
237 Self::Problematic => 0.70,
238 }
239 }
240
241 pub fn quality_issue_probability(&self) -> f64 {
243 match self {
244 Self::ReliableStrategic => 0.01,
245 Self::StandardOperational => 0.03,
246 Self::Transactional => 0.07,
247 Self::Problematic => 0.15,
248 }
249 }
250
251 pub fn invoice_accuracy_probability(&self) -> f64 {
253 match self {
254 Self::ReliableStrategic => 0.99,
255 Self::StandardOperational => 0.95,
256 Self::Transactional => 0.90,
257 Self::Problematic => 0.80,
258 }
259 }
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
264#[serde(rename_all = "snake_case")]
265pub enum DeclineReason {
266 QualityIssues,
268 PriceIssues,
270 DeliveryIssues,
272 FinancialConcerns,
274 StrategicShift,
276 ComplianceIssues,
278 Other(String),
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
284#[serde(rename_all = "snake_case")]
285pub enum TerminationReason {
286 ContractExpired,
288 Breach,
290 Bankruptcy,
292 MutualAgreement,
294 ComplianceViolation,
296 PerformanceIssues,
298 Consolidation,
300 Acquisition,
302 Other(String),
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(rename_all = "snake_case")]
309pub enum VendorLifecycleStage {
310 Onboarding {
312 started: NaiveDate,
313 expected_completion: NaiveDate,
314 },
315 RampUp {
317 started: NaiveDate,
318 target_volume_percent: u8,
319 },
320 SteadyState { since: NaiveDate },
322 Decline {
324 started: NaiveDate,
325 reason: DeclineReason,
326 },
327 Terminated {
329 date: NaiveDate,
330 reason: TerminationReason,
331 },
332}
333
334impl VendorLifecycleStage {
335 pub fn is_active(&self) -> bool {
337 !matches!(self, Self::Terminated { .. })
338 }
339
340 pub fn is_good_standing(&self) -> bool {
342 matches!(
343 self,
344 Self::Onboarding { .. } | Self::RampUp { .. } | Self::SteadyState { .. }
345 )
346 }
347
348 pub fn stage_name(&self) -> &'static str {
350 match self {
351 Self::Onboarding { .. } => "onboarding",
352 Self::RampUp { .. } => "ramp_up",
353 Self::SteadyState { .. } => "steady_state",
354 Self::Decline { .. } => "decline",
355 Self::Terminated { .. } => "terminated",
356 }
357 }
358}
359
360impl Default for VendorLifecycleStage {
361 fn default() -> Self {
362 Self::SteadyState {
363 since: NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
364 }
365 }
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct PaymentHistory {
371 pub total_invoices: u32,
373 pub on_time_payments: u32,
375 pub early_payments: u32,
377 pub late_payments: u32,
379 #[serde(with = "rust_decimal::serde::str")]
381 pub total_amount: Decimal,
382 pub average_days_to_pay: f64,
384 pub last_payment_date: Option<NaiveDate>,
386 #[serde(with = "rust_decimal::serde::str")]
388 pub total_discounts: Decimal,
389}
390
391impl Default for PaymentHistory {
392 fn default() -> Self {
393 Self {
394 total_invoices: 0,
395 on_time_payments: 0,
396 early_payments: 0,
397 late_payments: 0,
398 total_amount: Decimal::ZERO,
399 average_days_to_pay: 30.0,
400 last_payment_date: None,
401 total_discounts: Decimal::ZERO,
402 }
403 }
404}
405
406impl PaymentHistory {
407 pub fn on_time_rate(&self) -> f64 {
409 if self.total_invoices == 0 {
410 1.0
411 } else {
412 self.on_time_payments as f64 / self.total_invoices as f64
413 }
414 }
415
416 pub fn early_payment_rate(&self) -> f64 {
418 if self.total_invoices == 0 {
419 0.0
420 } else {
421 self.early_payments as f64 / self.total_invoices as f64
422 }
423 }
424
425 pub fn record_payment(
427 &mut self,
428 amount: Decimal,
429 payment_date: NaiveDate,
430 due_date: NaiveDate,
431 discount_taken: Decimal,
432 ) {
433 self.total_invoices += 1;
434 self.total_amount += amount;
435 self.last_payment_date = Some(payment_date);
436
437 if discount_taken > Decimal::ZERO {
438 self.early_payments += 1;
439 self.total_discounts += discount_taken;
440 } else if payment_date <= due_date {
441 self.on_time_payments += 1;
442 } else {
443 self.late_payments += 1;
444 }
445
446 let days = (payment_date - due_date).num_days() as f64;
448 let n = self.total_invoices as f64;
449 self.average_days_to_pay = ((self.average_days_to_pay * (n - 1.0)) + days) / n;
450 }
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct VendorQualityScore {
456 pub delivery_score: f64,
458 pub quality_score: f64,
460 pub invoice_accuracy_score: f64,
462 pub responsiveness_score: f64,
464 pub last_evaluation: NaiveDate,
466 pub evaluation_count: u32,
468}
469
470impl Default for VendorQualityScore {
471 fn default() -> Self {
472 Self {
473 delivery_score: 0.9,
474 quality_score: 0.9,
475 invoice_accuracy_score: 0.95,
476 responsiveness_score: 0.85,
477 last_evaluation: NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
478 evaluation_count: 0,
479 }
480 }
481}
482
483impl VendorQualityScore {
484 pub fn overall_score(&self) -> f64 {
486 const DELIVERY_WEIGHT: f64 = 0.30;
487 const QUALITY_WEIGHT: f64 = 0.35;
488 const INVOICE_WEIGHT: f64 = 0.20;
489 const RESPONSIVENESS_WEIGHT: f64 = 0.15;
490
491 self.delivery_score * DELIVERY_WEIGHT
492 + self.quality_score * QUALITY_WEIGHT
493 + self.invoice_accuracy_score * INVOICE_WEIGHT
494 + self.responsiveness_score * RESPONSIVENESS_WEIGHT
495 }
496
497 pub fn grade(&self) -> &'static str {
499 let score = self.overall_score();
500 if score >= 0.95 {
501 "A+"
502 } else if score >= 0.90 {
503 "A"
504 } else if score >= 0.85 {
505 "B+"
506 } else if score >= 0.80 {
507 "B"
508 } else if score >= 0.70 {
509 "C"
510 } else if score >= 0.60 {
511 "D"
512 } else {
513 "F"
514 }
515 }
516
517 pub fn update(
519 &mut self,
520 delivery: f64,
521 quality: f64,
522 invoice_accuracy: f64,
523 responsiveness: f64,
524 eval_date: NaiveDate,
525 ) {
526 const ALPHA: f64 = 0.3;
528
529 self.delivery_score = ALPHA * delivery + (1.0 - ALPHA) * self.delivery_score;
530 self.quality_score = ALPHA * quality + (1.0 - ALPHA) * self.quality_score;
531 self.invoice_accuracy_score =
532 ALPHA * invoice_accuracy + (1.0 - ALPHA) * self.invoice_accuracy_score;
533 self.responsiveness_score =
534 ALPHA * responsiveness + (1.0 - ALPHA) * self.responsiveness_score;
535 self.last_evaluation = eval_date;
536 self.evaluation_count += 1;
537 }
538}
539
540#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
542#[serde(rename_all = "snake_case")]
543pub enum Substitutability {
544 #[default]
546 Easy,
547 Moderate,
549 Difficult,
551}
552
553impl Substitutability {
554 pub fn typical_distribution(&self) -> f64 {
556 match self {
557 Self::Easy => 0.60,
558 Self::Moderate => 0.30,
559 Self::Difficult => 0.10,
560 }
561 }
562
563 pub fn replacement_time_months(&self) -> u8 {
565 match self {
566 Self::Easy => 1,
567 Self::Moderate => 3,
568 Self::Difficult => 6,
569 }
570 }
571
572 pub fn risk_factor(&self) -> f64 {
574 match self {
575 Self::Easy => 1.0,
576 Self::Moderate => 1.5,
577 Self::Difficult => 2.5,
578 }
579 }
580}
581
582#[derive(Debug, Clone, Serialize, Deserialize)]
584pub struct VendorDependency {
585 pub vendor_id: String,
587 pub is_single_source: bool,
589 pub substitutability: Substitutability,
591 pub concentration_percent: f64,
593 pub spend_category: String,
595 pub alternatives: Vec<String>,
597 pub last_review_date: Option<NaiveDate>,
599}
600
601impl VendorDependency {
602 pub fn new(vendor_id: impl Into<String>, spend_category: impl Into<String>) -> Self {
604 Self {
605 vendor_id: vendor_id.into(),
606 is_single_source: false,
607 substitutability: Substitutability::default(),
608 concentration_percent: 0.0,
609 spend_category: spend_category.into(),
610 alternatives: Vec::new(),
611 last_review_date: None,
612 }
613 }
614
615 pub fn risk_score(&self) -> f64 {
617 let single_source_factor = if self.is_single_source { 2.0 } else { 1.0 };
618 let concentration_factor = self.concentration_percent;
619 let substitutability_factor = self.substitutability.risk_factor();
620
621 single_source_factor * concentration_factor * substitutability_factor
623 }
624
625 pub fn is_high_risk(&self) -> bool {
627 self.is_single_source
628 && matches!(self.substitutability, Substitutability::Difficult)
629 && self.concentration_percent > 0.15
630 }
631}
632
633#[derive(Debug, Clone, Serialize, Deserialize)]
635pub struct VendorRelationship {
636 pub vendor_id: String,
638 pub relationship_type: VendorRelationshipType,
640 pub tier: SupplyChainTier,
642 pub strategic_importance: StrategicLevel,
644 pub spend_tier: SpendTier,
646 pub cluster: VendorCluster,
648 pub start_date: NaiveDate,
650 pub end_date: Option<NaiveDate>,
652 pub lifecycle_stage: VendorLifecycleStage,
654 pub payment_history: PaymentHistory,
656 pub quality_score: VendorQualityScore,
658 pub parent_vendor: Option<String>,
660 pub child_vendors: Vec<String>,
662 pub dependency: Option<VendorDependency>,
664 #[serde(with = "rust_decimal::serde::str")]
666 pub annual_spend: Decimal,
667 pub contract_id: Option<String>,
669 pub primary_contact: Option<String>,
671 pub notes: Option<String>,
673}
674
675impl VendorRelationship {
676 pub fn new(
678 vendor_id: impl Into<String>,
679 relationship_type: VendorRelationshipType,
680 tier: SupplyChainTier,
681 start_date: NaiveDate,
682 ) -> Self {
683 Self {
684 vendor_id: vendor_id.into(),
685 relationship_type,
686 tier,
687 strategic_importance: StrategicLevel::default(),
688 spend_tier: SpendTier::default(),
689 cluster: VendorCluster::default(),
690 start_date,
691 end_date: None,
692 lifecycle_stage: VendorLifecycleStage::Onboarding {
693 started: start_date,
694 expected_completion: start_date + chrono::Duration::days(90),
695 },
696 payment_history: PaymentHistory::default(),
697 quality_score: VendorQualityScore::default(),
698 parent_vendor: None,
699 child_vendors: Vec::new(),
700 dependency: None,
701 annual_spend: Decimal::ZERO,
702 contract_id: None,
703 primary_contact: None,
704 notes: None,
705 }
706 }
707
708 pub fn with_strategic_importance(mut self, level: StrategicLevel) -> Self {
710 self.strategic_importance = level;
711 self
712 }
713
714 pub fn with_spend_tier(mut self, tier: SpendTier) -> Self {
716 self.spend_tier = tier;
717 self
718 }
719
720 pub fn with_cluster(mut self, cluster: VendorCluster) -> Self {
722 self.cluster = cluster;
723 self
724 }
725
726 pub fn with_parent(mut self, parent_id: impl Into<String>) -> Self {
728 self.parent_vendor = Some(parent_id.into());
729 self
730 }
731
732 pub fn add_child(&mut self, child_id: impl Into<String>) {
734 self.child_vendors.push(child_id.into());
735 }
736
737 pub fn with_annual_spend(mut self, spend: Decimal) -> Self {
739 self.annual_spend = spend;
740 self
741 }
742
743 pub fn is_active(&self) -> bool {
745 self.end_date.is_none() && self.lifecycle_stage.is_active()
746 }
747
748 pub fn relationship_age_days(&self, as_of: NaiveDate) -> i64 {
750 (as_of - self.start_date).num_days()
751 }
752
753 pub fn relationship_score(&self) -> f64 {
755 let quality = self.quality_score.overall_score();
756 let payment = self.payment_history.on_time_rate();
757 let strategic = self.strategic_importance.importance_score();
758 let cluster_bonus = match self.cluster {
759 VendorCluster::ReliableStrategic => 0.1,
760 VendorCluster::StandardOperational => 0.0,
761 VendorCluster::Transactional => -0.05,
762 VendorCluster::Problematic => -0.15,
763 };
764
765 (quality * 0.4 + payment * 0.3 + strategic * 0.3 + cluster_bonus).clamp(0.0, 1.0)
767 }
768}
769
770#[derive(Debug, Clone, Default, Serialize, Deserialize)]
772pub struct VendorNetwork {
773 pub company_code: String,
775 pub relationships: HashMap<String, VendorRelationship>,
777 pub tier1_vendors: Vec<String>,
779 pub tier2_vendors: Vec<String>,
781 pub tier3_vendors: Vec<String>,
783 pub created_date: Option<NaiveDate>,
785 pub statistics: NetworkStatistics,
787}
788
789#[derive(Debug, Clone, Default, Serialize, Deserialize)]
791pub struct NetworkStatistics {
792 pub total_vendors: usize,
794 pub active_vendors: usize,
796 #[serde(with = "rust_decimal::serde::str")]
798 pub total_annual_spend: Decimal,
799 pub avg_relationship_age_days: f64,
801 pub top5_concentration: f64,
803 pub single_source_count: usize,
805 pub cluster_distribution: HashMap<String, f64>,
807}
808
809impl VendorNetwork {
810 pub fn new(company_code: impl Into<String>) -> Self {
812 Self {
813 company_code: company_code.into(),
814 relationships: HashMap::new(),
815 tier1_vendors: Vec::new(),
816 tier2_vendors: Vec::new(),
817 tier3_vendors: Vec::new(),
818 created_date: None,
819 statistics: NetworkStatistics::default(),
820 }
821 }
822
823 pub fn add_relationship(&mut self, relationship: VendorRelationship) {
825 let vendor_id = relationship.vendor_id.clone();
826 match relationship.tier {
827 SupplyChainTier::Tier1 => self.tier1_vendors.push(vendor_id.clone()),
828 SupplyChainTier::Tier2 => self.tier2_vendors.push(vendor_id.clone()),
829 SupplyChainTier::Tier3 => self.tier3_vendors.push(vendor_id.clone()),
830 }
831 self.relationships.insert(vendor_id, relationship);
832 }
833
834 pub fn get_relationship(&self, vendor_id: &str) -> Option<&VendorRelationship> {
836 self.relationships.get(vendor_id)
837 }
838
839 pub fn get_relationship_mut(&mut self, vendor_id: &str) -> Option<&mut VendorRelationship> {
841 self.relationships.get_mut(vendor_id)
842 }
843
844 pub fn vendors_in_tier(&self, tier: SupplyChainTier) -> Vec<&VendorRelationship> {
846 let ids = match tier {
847 SupplyChainTier::Tier1 => &self.tier1_vendors,
848 SupplyChainTier::Tier2 => &self.tier2_vendors,
849 SupplyChainTier::Tier3 => &self.tier3_vendors,
850 };
851 ids.iter()
852 .filter_map(|id| self.relationships.get(id))
853 .collect()
854 }
855
856 pub fn get_children(&self, vendor_id: &str) -> Vec<&VendorRelationship> {
858 self.relationships
859 .get(vendor_id)
860 .map(|rel| {
861 rel.child_vendors
862 .iter()
863 .filter_map(|id| self.relationships.get(id))
864 .collect()
865 })
866 .unwrap_or_default()
867 }
868
869 pub fn get_parent(&self, vendor_id: &str) -> Option<&VendorRelationship> {
871 self.relationships
872 .get(vendor_id)
873 .and_then(|rel| rel.parent_vendor.as_ref())
874 .and_then(|parent_id| self.relationships.get(parent_id))
875 }
876
877 pub fn calculate_statistics(&mut self, as_of: NaiveDate) {
879 let active_count = self
880 .relationships
881 .values()
882 .filter(|r| r.is_active())
883 .count();
884
885 let total_spend: Decimal = self.relationships.values().map(|r| r.annual_spend).sum();
886
887 let avg_age = if self.relationships.is_empty() {
888 0.0
889 } else {
890 self.relationships
891 .values()
892 .map(|r| r.relationship_age_days(as_of) as f64)
893 .sum::<f64>()
894 / self.relationships.len() as f64
895 };
896
897 let mut spends: Vec<Decimal> = self
899 .relationships
900 .values()
901 .map(|r| r.annual_spend)
902 .collect();
903 spends.sort_by(|a, b| b.cmp(a));
904 let top5_spend: Decimal = spends.iter().take(5).copied().sum();
905 let top5_conc = if total_spend > Decimal::ZERO {
906 (top5_spend / total_spend)
907 .to_string()
908 .parse::<f64>()
909 .unwrap_or(0.0)
910 } else {
911 0.0
912 };
913
914 let single_source = self
916 .relationships
917 .values()
918 .filter(|r| {
919 r.dependency
920 .as_ref()
921 .map(|d| d.is_single_source)
922 .unwrap_or(false)
923 })
924 .count();
925
926 let mut cluster_counts: HashMap<String, usize> = HashMap::new();
928 for rel in self.relationships.values() {
929 *cluster_counts
930 .entry(format!("{:?}", rel.cluster))
931 .or_insert(0) += 1;
932 }
933 let cluster_distribution: HashMap<String, f64> = cluster_counts
934 .into_iter()
935 .map(|(k, v)| (k, v as f64 / self.relationships.len().max(1) as f64))
936 .collect();
937
938 self.statistics = NetworkStatistics {
939 total_vendors: self.relationships.len(),
940 active_vendors: active_count,
941 total_annual_spend: total_spend,
942 avg_relationship_age_days: avg_age,
943 top5_concentration: top5_conc,
944 single_source_count: single_source,
945 cluster_distribution,
946 };
947 }
948
949 pub fn check_concentration_limits(&self, max_single_vendor: f64, max_top5: f64) -> Vec<String> {
951 let mut violations = Vec::new();
952
953 let total_spend: Decimal = self.relationships.values().map(|r| r.annual_spend).sum();
955 if total_spend > Decimal::ZERO {
956 for rel in self.relationships.values() {
957 let conc = (rel.annual_spend / total_spend)
958 .to_string()
959 .parse::<f64>()
960 .unwrap_or(0.0);
961 if conc > max_single_vendor {
962 violations.push(format!(
963 "Vendor {} concentration {:.1}% exceeds limit {:.1}%",
964 rel.vendor_id,
965 conc * 100.0,
966 max_single_vendor * 100.0
967 ));
968 }
969 }
970 }
971
972 if self.statistics.top5_concentration > max_top5 {
974 violations.push(format!(
975 "Top 5 vendor concentration {:.1}% exceeds limit {:.1}%",
976 self.statistics.top5_concentration * 100.0,
977 max_top5 * 100.0
978 ));
979 }
980
981 violations
982 }
983}
984
985#[cfg(test)]
986mod tests {
987 use super::*;
988
989 #[test]
990 fn test_supply_chain_tier() {
991 assert_eq!(SupplyChainTier::Tier1.tier_number(), 1);
992 assert_eq!(SupplyChainTier::Tier2.visibility(), 0.5);
993 assert_eq!(
994 SupplyChainTier::Tier1.child_tier(),
995 Some(SupplyChainTier::Tier2)
996 );
997 assert_eq!(SupplyChainTier::Tier3.child_tier(), None);
998 }
999
1000 #[test]
1001 fn test_vendor_cluster_distribution() {
1002 let total: f64 = [
1003 VendorCluster::ReliableStrategic,
1004 VendorCluster::StandardOperational,
1005 VendorCluster::Transactional,
1006 VendorCluster::Problematic,
1007 ]
1008 .iter()
1009 .map(|c| c.typical_distribution())
1010 .sum();
1011
1012 assert!((total - 1.0).abs() < 0.01);
1013 }
1014
1015 #[test]
1016 fn test_vendor_quality_score() {
1017 let mut score = VendorQualityScore::default();
1018 assert!(score.overall_score() > 0.8);
1019
1020 score.update(
1021 0.95,
1022 0.90,
1023 0.98,
1024 0.85,
1025 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1026 );
1027 assert_eq!(score.evaluation_count, 1);
1028 assert_eq!(score.grade(), "A");
1029 }
1030
1031 #[test]
1032 fn test_payment_history() {
1033 let mut history = PaymentHistory::default();
1034 history.record_payment(
1035 Decimal::from(1000),
1036 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1037 NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
1038 Decimal::ZERO,
1039 );
1040
1041 assert_eq!(history.total_invoices, 1);
1042 assert_eq!(history.on_time_payments, 1);
1043 assert!((history.on_time_rate() - 1.0).abs() < 0.001);
1044 }
1045
1046 #[test]
1047 fn test_vendor_relationship() {
1048 let rel = VendorRelationship::new(
1049 "V-001",
1050 VendorRelationshipType::DirectSupplier,
1051 SupplyChainTier::Tier1,
1052 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1053 )
1054 .with_strategic_importance(StrategicLevel::Critical)
1055 .with_spend_tier(SpendTier::Platinum)
1056 .with_cluster(VendorCluster::ReliableStrategic)
1057 .with_annual_spend(Decimal::from(1000000));
1058
1059 assert!(rel.is_active());
1060 assert_eq!(rel.strategic_importance, StrategicLevel::Critical);
1061 assert!(rel.relationship_score() > 0.5);
1062 }
1063
1064 #[test]
1065 fn test_vendor_network() {
1066 let mut network = VendorNetwork::new("1000");
1067
1068 let rel1 = VendorRelationship::new(
1069 "V-001",
1070 VendorRelationshipType::DirectSupplier,
1071 SupplyChainTier::Tier1,
1072 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1073 )
1074 .with_annual_spend(Decimal::from(500000));
1075
1076 let rel2 = VendorRelationship::new(
1077 "V-002",
1078 VendorRelationshipType::RawMaterialSupplier,
1079 SupplyChainTier::Tier2,
1080 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1081 )
1082 .with_parent("V-001")
1083 .with_annual_spend(Decimal::from(200000));
1084
1085 network.add_relationship(rel1);
1086 network.add_relationship(rel2);
1087
1088 assert_eq!(network.tier1_vendors.len(), 1);
1089 assert_eq!(network.tier2_vendors.len(), 1);
1090 assert!(network.get_relationship("V-001").is_some());
1091
1092 network.calculate_statistics(NaiveDate::from_ymd_opt(2024, 6, 1).unwrap());
1093 assert_eq!(network.statistics.total_vendors, 2);
1094 assert_eq!(network.statistics.active_vendors, 2);
1095 }
1096
1097 #[test]
1098 fn test_vendor_dependency() {
1099 let mut dep = VendorDependency::new("V-001", "Raw Materials");
1100 dep.is_single_source = true;
1101 dep.substitutability = Substitutability::Difficult;
1102 dep.concentration_percent = 0.25; assert!(dep.is_high_risk());
1105 assert!(dep.risk_score() > 1.0);
1107 }
1108
1109 #[test]
1110 fn test_lifecycle_stage() {
1111 let stage = VendorLifecycleStage::SteadyState {
1112 since: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1113 };
1114 assert!(stage.is_active());
1115 assert!(stage.is_good_standing());
1116
1117 let terminated = VendorLifecycleStage::Terminated {
1118 date: NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(),
1119 reason: TerminationReason::ContractExpired,
1120 };
1121 assert!(!terminated.is_active());
1122 }
1123}