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