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