datasynth_generators/sourcing/
rfx_generator.rs1use chrono::NaiveDate;
4use datasynth_config::schema::RfxConfig;
5use datasynth_core::models::sourcing::{
6 RfxEvaluationCriterion, RfxEvent, RfxLineItem, RfxStatus, RfxType, ScoringMethod,
7};
8use datasynth_core::utils::seeded_rng;
9use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
10use rand::prelude::*;
11use rand_chacha::ChaCha8Rng;
12use rust_decimal::Decimal;
13
14pub struct RfxGenerator {
16 rng: ChaCha8Rng,
17 uuid_factory: DeterministicUuidFactory,
18 config: RfxConfig,
19}
20
21impl RfxGenerator {
22 pub fn new(seed: u64) -> Self {
24 Self {
25 rng: seeded_rng(seed, 0),
26 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::RfxEvent),
27 config: RfxConfig::default(),
28 }
29 }
30
31 pub fn with_config(seed: u64, config: RfxConfig) -> Self {
33 Self {
34 rng: seeded_rng(seed, 0),
35 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::RfxEvent),
36 config,
37 }
38 }
39
40 pub fn generate(
42 &mut self,
43 company_code: &str,
44 sourcing_project_id: &str,
45 category_id: &str,
46 qualified_vendor_ids: &[String],
47 owner_id: &str,
48 publish_date: NaiveDate,
49 estimated_spend: f64,
50 ) -> RfxEvent {
51 let rfx_type = if estimated_spend > self.config.rfi_threshold {
52 if self.rng.random_bool(0.3) {
53 RfxType::Rfi
54 } else {
55 RfxType::Rfp
56 }
57 } else {
58 RfxType::Rfq
59 };
60
61 let invited_count = self
62 .rng
63 .random_range(self.config.min_invited_vendors..=self.config.max_invited_vendors)
64 .min(qualified_vendor_ids.len() as u32) as usize;
65
66 let invited_vendors: Vec<String> = qualified_vendor_ids
67 .choose_multiple(&mut self.rng, invited_count)
68 .cloned()
69 .collect();
70
71 let response_deadline =
72 publish_date + chrono::Duration::days(self.rng.random_range(14..=45));
73
74 let bid_count = (invited_vendors.len() as f64 * self.config.response_rate).round() as u32;
75
76 let criteria = vec![
77 RfxEvaluationCriterion {
78 name: "Price".to_string(),
79 weight: self.config.default_price_weight,
80 description: "Total cost of ownership".to_string(),
81 },
82 RfxEvaluationCriterion {
83 name: "Quality".to_string(),
84 weight: self.config.default_quality_weight,
85 description: "Quality management and track record".to_string(),
86 },
87 RfxEvaluationCriterion {
88 name: "Delivery".to_string(),
89 weight: self.config.default_delivery_weight,
90 description: "Lead time and reliability".to_string(),
91 },
92 ];
93
94 let line_count = self.rng.random_range(1u16..=5);
95 let line_items: Vec<RfxLineItem> = (1..=line_count)
96 .map(|i| RfxLineItem {
97 item_number: i,
98 description: format!("Item {}", i),
99 material_id: None,
100 quantity: Decimal::from(self.rng.random_range(10..=1000)),
101 uom: "EA".to_string(),
102 target_price: Some(Decimal::from(self.rng.random_range(10..=5000))),
103 })
104 .collect();
105
106 let scoring_method = match rfx_type {
107 RfxType::Rfq => ScoringMethod::LowestPrice,
108 RfxType::Rfp => ScoringMethod::BestValue,
109 RfxType::Rfi => ScoringMethod::QualityBased,
110 };
111
112 RfxEvent {
113 rfx_id: self.uuid_factory.next().to_string(),
114 rfx_type,
115 company_code: company_code.to_string(),
116 title: format!("RFx for {}", category_id),
117 description: format!(
118 "Sourcing event for category {} under project {}",
119 category_id, sourcing_project_id
120 ),
121 status: RfxStatus::Awarded,
122 sourcing_project_id: sourcing_project_id.to_string(),
123 category_id: category_id.to_string(),
124 scoring_method,
125 criteria,
126 line_items,
127 invited_vendors,
128 publish_date,
129 response_deadline,
130 bid_count,
131 owner_id: owner_id.to_string(),
132 awarded_vendor_id: None,
133 awarded_bid_id: None,
134 }
135 }
136}
137
138#[cfg(test)]
139#[allow(clippy::unwrap_used)]
140mod tests {
141 use super::*;
142
143 fn test_vendor_ids() -> Vec<String> {
144 (1..=6).map(|i| format!("V{:04}", i)).collect()
145 }
146
147 #[test]
148 fn test_basic_generation() {
149 let mut gen = RfxGenerator::new(42);
150 let date = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
151 let rfx = gen.generate(
152 "C001",
153 "SP-001",
154 "CAT-001",
155 &test_vendor_ids(),
156 "BUYER-01",
157 date,
158 200_000.0,
159 );
160
161 assert!(!rfx.rfx_id.is_empty());
162 assert_eq!(rfx.company_code, "C001");
163 assert_eq!(rfx.sourcing_project_id, "SP-001");
164 assert_eq!(rfx.category_id, "CAT-001");
165 assert_eq!(rfx.owner_id, "BUYER-01");
166 assert_eq!(rfx.status, RfxStatus::Awarded);
167 assert!(!rfx.invited_vendors.is_empty());
168 assert!(!rfx.criteria.is_empty());
169 assert!(!rfx.line_items.is_empty());
170 assert!(rfx.response_deadline > date);
171 }
172
173 #[test]
174 fn test_deterministic() {
175 let date = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
176 let vendors = test_vendor_ids();
177
178 let mut gen1 = RfxGenerator::new(42);
179 let mut gen2 = RfxGenerator::new(42);
180
181 let r1 = gen1.generate(
182 "C001", "SP-001", "CAT-001", &vendors, "BUYER-01", date, 200_000.0,
183 );
184 let r2 = gen2.generate(
185 "C001", "SP-001", "CAT-001", &vendors, "BUYER-01", date, 200_000.0,
186 );
187
188 assert_eq!(r1.rfx_id, r2.rfx_id);
189 assert_eq!(r1.rfx_type, r2.rfx_type);
190 assert_eq!(r1.invited_vendors, r2.invited_vendors);
191 assert_eq!(r1.line_items.len(), r2.line_items.len());
192 assert_eq!(r1.bid_count, r2.bid_count);
193 }
194
195 #[test]
196 fn test_field_constraints() {
197 let mut gen = RfxGenerator::new(99);
198 let date = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
199
200 let rfx_high = gen.generate(
202 "C001",
203 "SP-001",
204 "CAT-001",
205 &test_vendor_ids(),
206 "BUYER-01",
207 date,
208 500_000.0,
209 );
210 assert!(matches!(rfx_high.rfx_type, RfxType::Rfi | RfxType::Rfp));
211
212 let rfx_low = gen.generate(
214 "C001",
215 "SP-002",
216 "CAT-002",
217 &test_vendor_ids(),
218 "BUYER-01",
219 date,
220 50_000.0,
221 );
222 assert_eq!(rfx_low.rfx_type, RfxType::Rfq);
223
224 assert_eq!(rfx_high.criteria.len(), 3);
226 let weight_sum: f64 = rfx_high.criteria.iter().map(|c| c.weight).sum();
227 assert!((weight_sum - 1.0).abs() < 0.01);
228
229 for (i, item) in rfx_high.line_items.iter().enumerate() {
231 assert_eq!(item.item_number, (i + 1) as u16);
232 assert!(item.quantity > Decimal::ZERO);
233 }
234 }
235}