Skip to main content

datasynth_generators/sourcing/
sourcing_project_generator.rs

1//! Sourcing project generator.
2//!
3//! Creates sourcing projects triggered by spend analysis or contract expiry.
4
5use chrono::NaiveDate;
6use datasynth_config::schema::SourcingConfig;
7use datasynth_core::models::sourcing::{
8    SourcingProject, SourcingProjectStatus, SourcingProjectType,
9};
10use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
11use rand::prelude::*;
12use rand_chacha::ChaCha8Rng;
13use rust_decimal::Decimal;
14
15/// Generates sourcing projects.
16pub struct SourcingProjectGenerator {
17    rng: ChaCha8Rng,
18    uuid_factory: DeterministicUuidFactory,
19    config: SourcingConfig,
20}
21
22impl SourcingProjectGenerator {
23    /// Create a new sourcing project generator.
24    pub fn new(seed: u64) -> Self {
25        Self {
26            rng: ChaCha8Rng::seed_from_u64(seed),
27            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::SourcingProject),
28            config: SourcingConfig::default(),
29        }
30    }
31
32    /// Create with custom configuration.
33    pub fn with_config(seed: u64, config: SourcingConfig) -> Self {
34        Self {
35            rng: ChaCha8Rng::seed_from_u64(seed),
36            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::SourcingProject),
37            config,
38        }
39    }
40
41    /// Generate sourcing projects for a given period.
42    ///
43    /// # Arguments
44    /// * `company_code` - Company code
45    /// * `categories` - Available spend categories (id, name, annual_spend)
46    /// * `owner_ids` - Available buyer/sourcing manager IDs
47    /// * `period_start` - Period start date
48    /// * `period_months` - Number of months
49    pub fn generate(
50        &mut self,
51        company_code: &str,
52        categories: &[(String, String, Decimal)],
53        owner_ids: &[String],
54        period_start: NaiveDate,
55        period_months: u32,
56    ) -> Vec<SourcingProject> {
57        let mut projects = Vec::new();
58        let years = (period_months as f64 / 12.0).ceil() as u32;
59        let target_count = self.config.projects_per_year * years;
60
61        for _ in 0..target_count {
62            if categories.is_empty() || owner_ids.is_empty() {
63                break;
64            }
65
66            let (cat_id, cat_name, annual_spend) =
67                &categories[self.rng.gen_range(0..categories.len())];
68            let owner_id = &owner_ids[self.rng.gen_range(0..owner_ids.len())];
69
70            let project_type = if self.rng.gen_bool(0.4) {
71                SourcingProjectType::Renewal
72            } else if self.rng.gen_bool(0.15) {
73                SourcingProjectType::Consolidation
74            } else {
75                SourcingProjectType::NewSourcing
76            };
77
78            let days_offset = self.rng.gen_range(0..period_months * 30);
79            let start_date = period_start + chrono::Duration::days(days_offset as i64);
80            let duration_months = self.config.project_duration_months;
81            let target_end_date =
82                start_date + chrono::Duration::days((duration_months * 30) as i64);
83
84            let project_id = self.uuid_factory.next().to_string();
85            let target_savings = self.rng.gen_range(0.03..=0.15);
86
87            projects.push(SourcingProject {
88                project_id,
89                project_name: format!("{} - {} Sourcing", cat_name, company_code),
90                company_code: company_code.to_string(),
91                project_type,
92                status: SourcingProjectStatus::Completed,
93                category_id: cat_id.clone(),
94                estimated_annual_spend: *annual_spend,
95                target_savings_pct: target_savings,
96                owner_id: owner_id.clone(),
97                start_date,
98                target_end_date,
99                actual_end_date: Some(
100                    target_end_date + chrono::Duration::days(self.rng.gen_range(-10..=20) as i64),
101                ),
102                spend_analysis_id: None,
103                rfx_ids: Vec::new(),
104                contract_id: None,
105                actual_savings_pct: Some(target_savings * self.rng.gen_range(0.6..=1.2)),
106            });
107        }
108
109        projects
110    }
111}
112
113#[cfg(test)]
114#[allow(clippy::unwrap_used)]
115mod tests {
116    use super::*;
117
118    fn test_categories() -> Vec<(String, String, Decimal)> {
119        vec![
120            (
121                "CAT-001".to_string(),
122                "Office Supplies".to_string(),
123                Decimal::from(500_000),
124            ),
125            (
126                "CAT-002".to_string(),
127                "IT Equipment".to_string(),
128                Decimal::from(1_200_000),
129            ),
130        ]
131    }
132
133    fn test_owner_ids() -> Vec<String> {
134        vec!["BUYER-001".to_string(), "BUYER-002".to_string()]
135    }
136
137    #[test]
138    fn test_basic_generation() {
139        let mut gen = SourcingProjectGenerator::new(42);
140        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
141        let results = gen.generate("C001", &test_categories(), &test_owner_ids(), start, 12);
142
143        // Default is 10 projects per year
144        assert_eq!(results.len(), 10);
145        for project in &results {
146            assert_eq!(project.company_code, "C001");
147            assert!(!project.project_id.is_empty());
148            assert!(!project.project_name.is_empty());
149            assert!(!project.category_id.is_empty());
150            assert!(!project.owner_id.is_empty());
151            assert!(project.start_date >= start);
152            assert!(project.target_end_date > project.start_date);
153            assert!(project.estimated_annual_spend > Decimal::ZERO);
154        }
155    }
156
157    #[test]
158    fn test_deterministic() {
159        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
160        let cats = test_categories();
161        let owners = test_owner_ids();
162
163        let mut gen1 = SourcingProjectGenerator::new(42);
164        let mut gen2 = SourcingProjectGenerator::new(42);
165
166        let r1 = gen1.generate("C001", &cats, &owners, start, 12);
167        let r2 = gen2.generate("C001", &cats, &owners, start, 12);
168
169        assert_eq!(r1.len(), r2.len());
170        for (a, b) in r1.iter().zip(r2.iter()) {
171            assert_eq!(a.project_id, b.project_id);
172            assert_eq!(a.category_id, b.category_id);
173            assert_eq!(a.start_date, b.start_date);
174            assert_eq!(a.target_savings_pct, b.target_savings_pct);
175        }
176    }
177
178    #[test]
179    fn test_field_constraints() {
180        let mut gen = SourcingProjectGenerator::new(99);
181        let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
182        let results = gen.generate("C001", &test_categories(), &test_owner_ids(), start, 12);
183
184        for project in &results {
185            // Target savings should be in 3-15% range
186            assert!(project.target_savings_pct >= 0.03 && project.target_savings_pct <= 0.15);
187
188            // Actual savings should be present (status is Completed)
189            assert!(project.actual_savings_pct.is_some());
190
191            // Actual end date should be present for completed projects
192            assert!(project.actual_end_date.is_some());
193
194            // Project type should be one of the valid variants
195            matches!(
196                project.project_type,
197                SourcingProjectType::NewSourcing
198                    | SourcingProjectType::Renewal
199                    | SourcingProjectType::Consolidation
200            );
201        }
202    }
203}