datasynth_generators/sourcing/
bid_generator.rs1use chrono::NaiveDate;
4use datasynth_core::models::sourcing::{BidLineItem, BidStatus, RfxEvent, SupplierBid};
5use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
6use rand::prelude::*;
7use rand_chacha::ChaCha8Rng;
8use rust_decimal::Decimal;
9
10pub struct BidGenerator {
12 rng: ChaCha8Rng,
13 uuid_factory: DeterministicUuidFactory,
14}
15
16impl BidGenerator {
17 pub fn new(seed: u64) -> Self {
19 Self {
20 rng: ChaCha8Rng::seed_from_u64(seed),
21 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::SupplierBid),
22 }
23 }
24
25 pub fn generate(
27 &mut self,
28 rfx: &RfxEvent,
29 responding_vendor_ids: &[String],
30 submission_date: NaiveDate,
31 ) -> Vec<SupplierBid> {
32 let mut bids = Vec::new();
33
34 for vendor_id in responding_vendor_ids {
35 let line_items: Vec<BidLineItem> = rfx
36 .line_items
37 .iter()
38 .map(|rfx_item| {
39 let target = rfx_item.target_price.unwrap_or(Decimal::from(100));
41 let target_f64: f64 = target.to_string().parse().unwrap_or(100.0);
42 let price_factor = self.rng.gen_range(0.70..=1.30);
43 let unit_price =
44 Decimal::from_f64_retain(target_f64 * price_factor).unwrap_or(target);
45 let quantity = rfx_item.quantity;
46 let total = unit_price * quantity;
47
48 BidLineItem {
49 item_number: rfx_item.item_number,
50 unit_price,
51 quantity,
52 total_amount: total,
53 lead_time_days: self.rng.gen_range(5..=60),
54 notes: None,
55 }
56 })
57 .collect();
58
59 let total_amount: Decimal = line_items.iter().map(|i| i.total_amount).sum();
60
61 let is_on_time = self.rng.gen_bool(0.92);
62 let is_compliant = self.rng.gen_bool(0.88);
63
64 bids.push(SupplierBid {
65 bid_id: self.uuid_factory.next().to_string(),
66 rfx_id: rfx.rfx_id.clone(),
67 vendor_id: vendor_id.clone(),
68 company_code: rfx.company_code.clone(),
69 status: BidStatus::Submitted,
70 submission_date,
71 line_items,
72 total_amount,
73 validity_days: self.rng.gen_range(30..=90),
74 payment_terms: ["NET30", "NET45", "NET60", "2/10 NET30"][self.rng.gen_range(0..4)]
75 .to_string(),
76 delivery_terms: Some("FCA".to_string()),
77 technical_summary: None,
78 is_on_time,
79 is_compliant,
80 });
81 }
82
83 bids
84 }
85}
86
87#[cfg(test)]
88#[allow(clippy::unwrap_used)]
89mod tests {
90 use super::*;
91 use datasynth_core::models::sourcing::{
92 RfxEvaluationCriterion, RfxLineItem, RfxStatus, RfxType, ScoringMethod,
93 };
94
95 fn test_rfx() -> RfxEvent {
96 RfxEvent {
97 rfx_id: "RFX-001".to_string(),
98 rfx_type: RfxType::Rfp,
99 company_code: "C001".to_string(),
100 title: "Test RFx".to_string(),
101 description: "Test description".to_string(),
102 status: RfxStatus::Awarded,
103 sourcing_project_id: "SP-001".to_string(),
104 category_id: "CAT-001".to_string(),
105 scoring_method: ScoringMethod::BestValue,
106 criteria: vec![
107 RfxEvaluationCriterion {
108 name: "Price".to_string(),
109 weight: 0.40,
110 description: "Cost".to_string(),
111 },
112 RfxEvaluationCriterion {
113 name: "Quality".to_string(),
114 weight: 0.35,
115 description: "Quality".to_string(),
116 },
117 RfxEvaluationCriterion {
118 name: "Delivery".to_string(),
119 weight: 0.25,
120 description: "Delivery".to_string(),
121 },
122 ],
123 line_items: vec![
124 RfxLineItem {
125 item_number: 1,
126 description: "Item A".to_string(),
127 material_id: None,
128 quantity: Decimal::from(100),
129 uom: "EA".to_string(),
130 target_price: Some(Decimal::from(50)),
131 },
132 RfxLineItem {
133 item_number: 2,
134 description: "Item B".to_string(),
135 material_id: None,
136 quantity: Decimal::from(200),
137 uom: "EA".to_string(),
138 target_price: Some(Decimal::from(25)),
139 },
140 ],
141 invited_vendors: vec!["V001".to_string(), "V002".to_string(), "V003".to_string()],
142 publish_date: NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
143 response_deadline: NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
144 bid_count: 3,
145 owner_id: "BUYER-01".to_string(),
146 awarded_vendor_id: None,
147 awarded_bid_id: None,
148 }
149 }
150
151 fn test_responding_vendors() -> Vec<String> {
152 vec!["V001".to_string(), "V002".to_string(), "V003".to_string()]
153 }
154
155 #[test]
156 fn test_basic_generation() {
157 let mut gen = BidGenerator::new(42);
158 let rfx = test_rfx();
159 let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
160 let bids = gen.generate(&rfx, &test_responding_vendors(), date);
161
162 assert_eq!(bids.len(), 3);
163 for bid in &bids {
164 assert!(!bid.bid_id.is_empty());
165 assert_eq!(bid.rfx_id, "RFX-001");
166 assert_eq!(bid.company_code, "C001");
167 assert_eq!(bid.submission_date, date);
168 assert_eq!(bid.line_items.len(), 2);
169 assert!(bid.total_amount > Decimal::ZERO);
170 assert_eq!(bid.status, BidStatus::Submitted);
171 }
172 }
173
174 #[test]
175 fn test_deterministic() {
176 let rfx = test_rfx();
177 let vendors = test_responding_vendors();
178 let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
179
180 let mut gen1 = BidGenerator::new(42);
181 let mut gen2 = BidGenerator::new(42);
182
183 let r1 = gen1.generate(&rfx, &vendors, date);
184 let r2 = gen2.generate(&rfx, &vendors, date);
185
186 assert_eq!(r1.len(), r2.len());
187 for (a, b) in r1.iter().zip(r2.iter()) {
188 assert_eq!(a.bid_id, b.bid_id);
189 assert_eq!(a.vendor_id, b.vendor_id);
190 assert_eq!(a.total_amount, b.total_amount);
191 assert_eq!(a.payment_terms, b.payment_terms);
192 }
193 }
194
195 #[test]
196 fn test_field_constraints() {
197 let mut gen = BidGenerator::new(99);
198 let rfx = test_rfx();
199 let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
200 let bids = gen.generate(&rfx, &test_responding_vendors(), date);
201
202 for bid in &bids {
203 assert!(bid.validity_days >= 30 && bid.validity_days <= 90);
205
206 assert!(["NET30", "NET45", "NET60", "2/10 NET30"].contains(&bid.payment_terms.as_str()));
208
209 for line in &bid.line_items {
211 assert!(line.unit_price > Decimal::ZERO);
212 assert!(line.total_amount > Decimal::ZERO);
213 assert!(line.lead_time_days >= 5 && line.lead_time_days <= 60);
214 }
215
216 let line_sum: Decimal = bid.line_items.iter().map(|l| l.total_amount).sum();
218 assert_eq!(bid.total_amount, line_sum);
219 }
220 }
221}