1use datasynth_core::models::sourcing::{
4 AwardRecommendation, BidEvaluation, BidEvaluationEntry, RankedBid, RfxEvent, SupplierBid,
5};
6use datasynth_core::utils::seeded_rng;
7use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
8use rand::prelude::*;
9use rand_chacha::ChaCha8Rng;
10
11pub struct BidEvaluationGenerator {
13 rng: ChaCha8Rng,
14 uuid_factory: DeterministicUuidFactory,
15}
16
17impl BidEvaluationGenerator {
18 pub fn new(seed: u64) -> Self {
20 Self {
21 rng: seeded_rng(seed, 0),
22 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::RfxEvent),
23 }
24 }
25
26 pub fn evaluate(
28 &mut self,
29 rfx: &RfxEvent,
30 bids: &[SupplierBid],
31 evaluator_id: &str,
32 ) -> BidEvaluation {
33 let eligible_bids: Vec<&SupplierBid> = bids
35 .iter()
36 .filter(|b| b.is_compliant && b.is_on_time)
37 .collect();
38
39 let amounts: Vec<f64> = eligible_bids
41 .iter()
42 .map(|b| b.total_amount.to_string().parse::<f64>().unwrap_or(0.0))
43 .collect();
44 let min_amount = amounts.iter().cloned().fold(f64::MAX, f64::min);
45 let max_amount = amounts.iter().cloned().fold(0.0f64, f64::max);
46 let amount_range = (max_amount - min_amount).max(1.0);
47
48 let mut ranked_bids: Vec<RankedBid> = eligible_bids
49 .iter()
50 .map(|bid| {
51 let bid_amount: f64 = bid.total_amount.to_string().parse().unwrap_or(0.0);
52
53 let mut criterion_scores = Vec::new();
54 let mut total_score = 0.0;
55 let mut price_score_val = 0.0;
56 let mut quality_score_val = 0.0;
57
58 for criterion in &rfx.criteria {
59 let (raw_score, is_price) = if criterion.name == "Price" {
60 let score = 100.0 * (1.0 - (bid_amount - min_amount) / amount_range);
62 (score, true)
63 } else {
64 (self.rng.random_range(50.0..=100.0), false)
66 };
67
68 let weighted = raw_score * criterion.weight;
69 total_score += weighted;
70
71 if is_price {
72 price_score_val = raw_score;
73 } else {
74 quality_score_val += weighted;
75 }
76
77 criterion_scores.push(BidEvaluationEntry {
78 criterion_name: criterion.name.clone(),
79 raw_score,
80 weight: criterion.weight,
81 weighted_score: weighted,
82 });
83 }
84
85 RankedBid {
86 bid_id: bid.bid_id.clone(),
87 vendor_id: bid.vendor_id.clone(),
88 rank: 0, total_score,
90 price_score: price_score_val,
91 quality_score: quality_score_val,
92 total_amount: bid.total_amount,
93 criterion_scores,
94 }
95 })
96 .collect();
97
98 ranked_bids.sort_by(|a, b| {
100 b.total_score
101 .partial_cmp(&a.total_score)
102 .unwrap_or(std::cmp::Ordering::Equal)
103 });
104
105 for (i, bid) in ranked_bids.iter_mut().enumerate() {
107 bid.rank = (i + 1) as u32;
108 }
109
110 let (recommendation, rec_vendor, rec_bid) = if ranked_bids.is_empty() {
111 (AwardRecommendation::Reject, None, None)
112 } else {
113 (
114 AwardRecommendation::Award,
115 Some(ranked_bids[0].vendor_id.clone()),
116 Some(ranked_bids[0].bid_id.clone()),
117 )
118 };
119
120 BidEvaluation {
121 evaluation_id: self.uuid_factory.next().to_string(),
122 rfx_id: rfx.rfx_id.clone(),
123 company_code: rfx.company_code.clone(),
124 evaluator_id: evaluator_id.to_string(),
125 ranked_bids,
126 recommendation,
127 recommended_vendor_id: rec_vendor,
128 recommended_bid_id: rec_bid,
129 notes: None,
130 is_finalized: true,
131 }
132 }
133}
134
135#[cfg(test)]
136#[allow(clippy::unwrap_used)]
137mod tests {
138 use super::*;
139 use chrono::NaiveDate;
140 use datasynth_core::models::sourcing::{
141 BidLineItem, BidStatus, RfxEvaluationCriterion, RfxEvent, RfxLineItem, RfxStatus, RfxType,
142 ScoringMethod,
143 };
144 use rust_decimal::Decimal;
145
146 fn test_rfx() -> RfxEvent {
147 RfxEvent {
148 rfx_id: "RFX-001".to_string(),
149 rfx_type: RfxType::Rfp,
150 company_code: "C001".to_string(),
151 title: "Test RFx".to_string(),
152 description: "Test".to_string(),
153 status: RfxStatus::Awarded,
154 sourcing_project_id: "SP-001".to_string(),
155 category_id: "CAT-001".to_string(),
156 scoring_method: ScoringMethod::BestValue,
157 criteria: vec![
158 RfxEvaluationCriterion {
159 name: "Price".to_string(),
160 weight: 0.40,
161 description: "Cost".to_string(),
162 },
163 RfxEvaluationCriterion {
164 name: "Quality".to_string(),
165 weight: 0.35,
166 description: "Quality".to_string(),
167 },
168 RfxEvaluationCriterion {
169 name: "Delivery".to_string(),
170 weight: 0.25,
171 description: "Delivery".to_string(),
172 },
173 ],
174 line_items: vec![RfxLineItem {
175 item_number: 1,
176 description: "Item A".to_string(),
177 material_id: None,
178 quantity: Decimal::from(100),
179 uom: "EA".to_string(),
180 target_price: Some(Decimal::from(50)),
181 }],
182 invited_vendors: vec!["V001".to_string(), "V002".to_string()],
183 publish_date: NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
184 response_deadline: NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
185 bid_count: 2,
186 owner_id: "BUYER-01".to_string(),
187 awarded_vendor_id: None,
188 awarded_bid_id: None,
189 }
190 }
191
192 fn test_bids() -> Vec<SupplierBid> {
193 vec![
194 SupplierBid {
195 bid_id: "BID-001".to_string(),
196 rfx_id: "RFX-001".to_string(),
197 vendor_id: "V001".to_string(),
198 company_code: "C001".to_string(),
199 status: BidStatus::Submitted,
200 submission_date: NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
201 line_items: vec![BidLineItem {
202 item_number: 1,
203 unit_price: Decimal::from(45),
204 quantity: Decimal::from(100),
205 total_amount: Decimal::from(4500),
206 lead_time_days: 10,
207 notes: None,
208 }],
209 total_amount: Decimal::from(4500),
210 validity_days: 60,
211 payment_terms: "NET30".to_string(),
212 delivery_terms: Some("FCA".to_string()),
213 technical_summary: None,
214 is_on_time: true,
215 is_compliant: true,
216 },
217 SupplierBid {
218 bid_id: "BID-002".to_string(),
219 rfx_id: "RFX-001".to_string(),
220 vendor_id: "V002".to_string(),
221 company_code: "C001".to_string(),
222 status: BidStatus::Submitted,
223 submission_date: NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
224 line_items: vec![BidLineItem {
225 item_number: 1,
226 unit_price: Decimal::from(55),
227 quantity: Decimal::from(100),
228 total_amount: Decimal::from(5500),
229 lead_time_days: 7,
230 notes: None,
231 }],
232 total_amount: Decimal::from(5500),
233 validity_days: 60,
234 payment_terms: "NET45".to_string(),
235 delivery_terms: Some("FCA".to_string()),
236 technical_summary: None,
237 is_on_time: true,
238 is_compliant: true,
239 },
240 ]
241 }
242
243 #[test]
244 fn test_basic_evaluation() {
245 let mut gen = BidEvaluationGenerator::new(42);
246 let rfx = test_rfx();
247 let bids = test_bids();
248 let eval = gen.evaluate(&rfx, &bids, "EVAL-01");
249
250 assert!(!eval.evaluation_id.is_empty());
251 assert_eq!(eval.rfx_id, "RFX-001");
252 assert_eq!(eval.company_code, "C001");
253 assert_eq!(eval.evaluator_id, "EVAL-01");
254 assert!(eval.is_finalized);
255 assert_eq!(eval.ranked_bids.len(), 2);
256 assert!(eval.recommended_vendor_id.is_some());
257 assert!(eval.recommended_bid_id.is_some());
258 assert!(matches!(eval.recommendation, AwardRecommendation::Award));
259 }
260
261 #[test]
262 fn test_deterministic() {
263 let rfx = test_rfx();
264 let bids = test_bids();
265
266 let mut gen1 = BidEvaluationGenerator::new(42);
267 let mut gen2 = BidEvaluationGenerator::new(42);
268
269 let r1 = gen1.evaluate(&rfx, &bids, "EVAL-01");
270 let r2 = gen2.evaluate(&rfx, &bids, "EVAL-01");
271
272 assert_eq!(r1.evaluation_id, r2.evaluation_id);
273 assert_eq!(r1.ranked_bids.len(), r2.ranked_bids.len());
274 for (a, b) in r1.ranked_bids.iter().zip(r2.ranked_bids.iter()) {
275 assert_eq!(a.bid_id, b.bid_id);
276 assert_eq!(a.rank, b.rank);
277 assert_eq!(a.total_score, b.total_score);
278 }
279 assert_eq!(r1.recommended_vendor_id, r2.recommended_vendor_id);
280 }
281
282 #[test]
283 fn test_ranking_order() {
284 let mut gen = BidEvaluationGenerator::new(42);
285 let rfx = test_rfx();
286 let bids = test_bids();
287 let eval = gen.evaluate(&rfx, &bids, "EVAL-01");
288
289 for (i, ranked) in eval.ranked_bids.iter().enumerate() {
291 assert_eq!(ranked.rank, (i + 1) as u32);
292 }
293
294 for window in eval.ranked_bids.windows(2) {
296 assert!(window[0].total_score >= window[1].total_score);
297 }
298
299 assert_eq!(
301 eval.recommended_vendor_id.as_ref().unwrap(),
302 &eval.ranked_bids[0].vendor_id
303 );
304 }
305
306 #[test]
307 fn test_non_compliant_bids_excluded() {
308 let mut gen = BidEvaluationGenerator::new(42);
309 let rfx = test_rfx();
310 let mut bids = test_bids();
311 bids[1].is_compliant = false;
313
314 let eval = gen.evaluate(&rfx, &bids, "EVAL-01");
315
316 assert_eq!(eval.ranked_bids.len(), 1);
318 assert_eq!(eval.ranked_bids[0].vendor_id, "V001");
319 }
320}