datasynth_generators/sourcing/
sourcing_project_generator.rs1use 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
16pub struct SourcingProjectGenerator {
18 rng: ChaCha8Rng,
19 uuid_factory: DeterministicUuidFactory,
20 config: SourcingConfig,
21}
22
23impl SourcingProjectGenerator {
24 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 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 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 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 assert!(project.target_savings_pct >= 0.03 && project.target_savings_pct <= 0.15);
194
195 assert!(project.actual_savings_pct.is_some());
197
198 assert!(project.actual_end_date.is_some());
200
201 matches!(
203 project.project_type,
204 SourcingProjectType::NewSourcing
205 | SourcingProjectType::Renewal
206 | SourcingProjectType::Consolidation
207 );
208 }
209 }
210}