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::uuid_factory::{DeterministicUuidFactory, GeneratorType};
11use rand::prelude::*;
12use rand_chacha::ChaCha8Rng;
13use rust_decimal::Decimal;
14
15pub struct SourcingProjectGenerator {
17 rng: ChaCha8Rng,
18 uuid_factory: DeterministicUuidFactory,
19 config: SourcingConfig,
20}
21
22impl SourcingProjectGenerator {
23 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 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 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 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 assert!(project.target_savings_pct >= 0.03 && project.target_savings_pct <= 0.15);
187
188 assert!(project.actual_savings_pct.is_some());
190
191 assert!(project.actual_end_date.is_some());
193
194 matches!(
196 project.project_type,
197 SourcingProjectType::NewSourcing
198 | SourcingProjectType::Renewal
199 | SourcingProjectType::Consolidation
200 );
201 }
202 }
203}