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)]
136mod tests {
137 use super::*;
138 use chrono::NaiveDate;
139 use datasynth_core::models::sourcing::{
140 BidLineItem, BidStatus, RfxEvaluationCriterion, RfxEvent, RfxLineItem, RfxStatus, RfxType,
141 ScoringMethod,
142 };
143 use rust_decimal::Decimal;
144
145 fn test_rfx() -> RfxEvent {
146 RfxEvent {
147 rfx_id: "RFX-001".to_string(),
148 rfx_type: RfxType::Rfp,
149 company_code: "C001".to_string(),
150 title: "Test RFx".to_string(),
151 description: "Test".to_string(),
152 status: RfxStatus::Awarded,
153 sourcing_project_id: "SP-001".to_string(),
154 category_id: "CAT-001".to_string(),
155 scoring_method: ScoringMethod::BestValue,
156 criteria: vec![
157 RfxEvaluationCriterion {
158 name: "Price".to_string(),
159 weight: 0.40,
160 description: "Cost".to_string(),
161 },
162 RfxEvaluationCriterion {
163 name: "Quality".to_string(),
164 weight: 0.35,
165 description: "Quality".to_string(),
166 },
167 RfxEvaluationCriterion {
168 name: "Delivery".to_string(),
169 weight: 0.25,
170 description: "Delivery".to_string(),
171 },
172 ],
173 line_items: vec![RfxLineItem {
174 item_number: 1,
175 description: "Item A".to_string(),
176 material_id: None,
177 quantity: Decimal::from(100),
178 uom: "EA".to_string(),
179 target_price: Some(Decimal::from(50)),
180 }],
181 invited_vendors: vec!["V001".to_string(), "V002".to_string()],
182 publish_date: NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
183 response_deadline: NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
184 bid_count: 2,
185 owner_id: "BUYER-01".to_string(),
186 awarded_vendor_id: None,
187 awarded_bid_id: None,
188 }
189 }
190
191 fn test_bids() -> Vec<SupplierBid> {
192 vec![
193 SupplierBid {
194 bid_id: "BID-001".to_string(),
195 rfx_id: "RFX-001".to_string(),
196 vendor_id: "V001".to_string(),
197 company_code: "C001".to_string(),
198 status: BidStatus::Submitted,
199 submission_date: NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
200 line_items: vec![BidLineItem {
201 item_number: 1,
202 unit_price: Decimal::from(45),
203 quantity: Decimal::from(100),
204 total_amount: Decimal::from(4500),
205 lead_time_days: 10,
206 notes: None,
207 }],
208 total_amount: Decimal::from(4500),
209 validity_days: 60,
210 payment_terms: "NET30".to_string(),
211 delivery_terms: Some("FCA".to_string()),
212 technical_summary: None,
213 is_on_time: true,
214 is_compliant: true,
215 },
216 SupplierBid {
217 bid_id: "BID-002".to_string(),
218 rfx_id: "RFX-001".to_string(),
219 vendor_id: "V002".to_string(),
220 company_code: "C001".to_string(),
221 status: BidStatus::Submitted,
222 submission_date: NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
223 line_items: vec![BidLineItem {
224 item_number: 1,
225 unit_price: Decimal::from(55),
226 quantity: Decimal::from(100),
227 total_amount: Decimal::from(5500),
228 lead_time_days: 7,
229 notes: None,
230 }],
231 total_amount: Decimal::from(5500),
232 validity_days: 60,
233 payment_terms: "NET45".to_string(),
234 delivery_terms: Some("FCA".to_string()),
235 technical_summary: None,
236 is_on_time: true,
237 is_compliant: true,
238 },
239 ]
240 }
241
242 #[test]
243 fn test_basic_evaluation() {
244 let mut gen = BidEvaluationGenerator::new(42);
245 let rfx = test_rfx();
246 let bids = test_bids();
247 let eval = gen.evaluate(&rfx, &bids, "EVAL-01");
248
249 assert!(!eval.evaluation_id.is_empty());
250 assert_eq!(eval.rfx_id, "RFX-001");
251 assert_eq!(eval.company_code, "C001");
252 assert_eq!(eval.evaluator_id, "EVAL-01");
253 assert!(eval.is_finalized);
254 assert_eq!(eval.ranked_bids.len(), 2);
255 assert!(eval.recommended_vendor_id.is_some());
256 assert!(eval.recommended_bid_id.is_some());
257 assert!(matches!(eval.recommendation, AwardRecommendation::Award));
258 }
259
260 #[test]
261 fn test_deterministic() {
262 let rfx = test_rfx();
263 let bids = test_bids();
264
265 let mut gen1 = BidEvaluationGenerator::new(42);
266 let mut gen2 = BidEvaluationGenerator::new(42);
267
268 let r1 = gen1.evaluate(&rfx, &bids, "EVAL-01");
269 let r2 = gen2.evaluate(&rfx, &bids, "EVAL-01");
270
271 assert_eq!(r1.evaluation_id, r2.evaluation_id);
272 assert_eq!(r1.ranked_bids.len(), r2.ranked_bids.len());
273 for (a, b) in r1.ranked_bids.iter().zip(r2.ranked_bids.iter()) {
274 assert_eq!(a.bid_id, b.bid_id);
275 assert_eq!(a.rank, b.rank);
276 assert_eq!(a.total_score, b.total_score);
277 }
278 assert_eq!(r1.recommended_vendor_id, r2.recommended_vendor_id);
279 }
280
281 #[test]
282 fn test_ranking_order() {
283 let mut gen = BidEvaluationGenerator::new(42);
284 let rfx = test_rfx();
285 let bids = test_bids();
286 let eval = gen.evaluate(&rfx, &bids, "EVAL-01");
287
288 for (i, ranked) in eval.ranked_bids.iter().enumerate() {
290 assert_eq!(ranked.rank, (i + 1) as u32);
291 }
292
293 for window in eval.ranked_bids.windows(2) {
295 assert!(window[0].total_score >= window[1].total_score);
296 }
297
298 assert_eq!(
300 eval.recommended_vendor_id.as_ref().unwrap(),
301 &eval.ranked_bids[0].vendor_id
302 );
303 }
304
305 #[test]
306 fn test_non_compliant_bids_excluded() {
307 let mut gen = BidEvaluationGenerator::new(42);
308 let rfx = test_rfx();
309 let mut bids = test_bids();
310 bids[1].is_compliant = false;
312
313 let eval = gen.evaluate(&rfx, &bids, "EVAL-01");
314
315 assert_eq!(eval.ranked_bids.len(), 1);
317 assert_eq!(eval.ranked_bids[0].vendor_id, "V001");
318 }
319}