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