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.gen_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.gen_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.gen_bool(0.92);
63 let is_compliant = self.rng.gen_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.gen_range(30..=90),
75 payment_terms: ["NET30", "NET45", "NET60", "2/10 NET30"][self.rng.gen_range(0..4)]
76 .to_string(),
77 delivery_terms: Some("FCA".to_string()),
78 technical_summary: None,
79 is_on_time,
80 is_compliant,
81 });
82 }
83
84 bids
85 }
86}
87
88#[cfg(test)]
89#[allow(clippy::unwrap_used)]
90mod tests {
91 use super::*;
92 use datasynth_core::models::sourcing::{
93 RfxEvaluationCriterion, RfxLineItem, RfxStatus, RfxType, ScoringMethod,
94 };
95
96 fn test_rfx() -> RfxEvent {
97 RfxEvent {
98 rfx_id: "RFX-001".to_string(),
99 rfx_type: RfxType::Rfp,
100 company_code: "C001".to_string(),
101 title: "Test RFx".to_string(),
102 description: "Test description".to_string(),
103 status: RfxStatus::Awarded,
104 sourcing_project_id: "SP-001".to_string(),
105 category_id: "CAT-001".to_string(),
106 scoring_method: ScoringMethod::BestValue,
107 criteria: vec![
108 RfxEvaluationCriterion {
109 name: "Price".to_string(),
110 weight: 0.40,
111 description: "Cost".to_string(),
112 },
113 RfxEvaluationCriterion {
114 name: "Quality".to_string(),
115 weight: 0.35,
116 description: "Quality".to_string(),
117 },
118 RfxEvaluationCriterion {
119 name: "Delivery".to_string(),
120 weight: 0.25,
121 description: "Delivery".to_string(),
122 },
123 ],
124 line_items: vec![
125 RfxLineItem {
126 item_number: 1,
127 description: "Item A".to_string(),
128 material_id: None,
129 quantity: Decimal::from(100),
130 uom: "EA".to_string(),
131 target_price: Some(Decimal::from(50)),
132 },
133 RfxLineItem {
134 item_number: 2,
135 description: "Item B".to_string(),
136 material_id: None,
137 quantity: Decimal::from(200),
138 uom: "EA".to_string(),
139 target_price: Some(Decimal::from(25)),
140 },
141 ],
142 invited_vendors: vec!["V001".to_string(), "V002".to_string(), "V003".to_string()],
143 publish_date: NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
144 response_deadline: NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
145 bid_count: 3,
146 owner_id: "BUYER-01".to_string(),
147 awarded_vendor_id: None,
148 awarded_bid_id: None,
149 }
150 }
151
152 fn test_responding_vendors() -> Vec<String> {
153 vec!["V001".to_string(), "V002".to_string(), "V003".to_string()]
154 }
155
156 #[test]
157 fn test_basic_generation() {
158 let mut gen = BidGenerator::new(42);
159 let rfx = test_rfx();
160 let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
161 let bids = gen.generate(&rfx, &test_responding_vendors(), date);
162
163 assert_eq!(bids.len(), 3);
164 for bid in &bids {
165 assert!(!bid.bid_id.is_empty());
166 assert_eq!(bid.rfx_id, "RFX-001");
167 assert_eq!(bid.company_code, "C001");
168 assert_eq!(bid.submission_date, date);
169 assert_eq!(bid.line_items.len(), 2);
170 assert!(bid.total_amount > Decimal::ZERO);
171 assert_eq!(bid.status, BidStatus::Submitted);
172 }
173 }
174
175 #[test]
176 fn test_deterministic() {
177 let rfx = test_rfx();
178 let vendors = test_responding_vendors();
179 let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
180
181 let mut gen1 = BidGenerator::new(42);
182 let mut gen2 = BidGenerator::new(42);
183
184 let r1 = gen1.generate(&rfx, &vendors, date);
185 let r2 = gen2.generate(&rfx, &vendors, date);
186
187 assert_eq!(r1.len(), r2.len());
188 for (a, b) in r1.iter().zip(r2.iter()) {
189 assert_eq!(a.bid_id, b.bid_id);
190 assert_eq!(a.vendor_id, b.vendor_id);
191 assert_eq!(a.total_amount, b.total_amount);
192 assert_eq!(a.payment_terms, b.payment_terms);
193 }
194 }
195
196 #[test]
197 fn test_field_constraints() {
198 let mut gen = BidGenerator::new(99);
199 let rfx = test_rfx();
200 let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
201 let bids = gen.generate(&rfx, &test_responding_vendors(), date);
202
203 for bid in &bids {
204 assert!(bid.validity_days >= 30 && bid.validity_days <= 90);
206
207 assert!(["NET30", "NET45", "NET60", "2/10 NET30"].contains(&bid.payment_terms.as_str()));
209
210 for line in &bid.line_items {
212 assert!(line.unit_price > Decimal::ZERO);
213 assert!(line.total_amount > Decimal::ZERO);
214 assert!(line.lead_time_days >= 5 && line.lead_time_days <= 60);
215 }
216
217 let line_sum: Decimal = bid.line_items.iter().map(|l| l.total_amount).sum();
219 assert_eq!(bid.total_amount, line_sum);
220 }
221 }
222}