Skip to main content

datasynth_generators/sourcing/
bid_evaluation_generator.rs

1//! Bid evaluation and award recommendation generator.
2
3use 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
11/// Generates bid evaluations and award recommendations.
12pub struct BidEvaluationGenerator {
13    rng: ChaCha8Rng,
14    uuid_factory: DeterministicUuidFactory,
15}
16
17impl BidEvaluationGenerator {
18    /// Create a new bid evaluation generator.
19    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    /// Evaluate bids for an RFx event and produce rankings.
27    pub fn evaluate(
28        &mut self,
29        rfx: &RfxEvent,
30        bids: &[SupplierBid],
31        evaluator_id: &str,
32    ) -> BidEvaluation {
33        // Only consider compliant, on-time bids
34        let eligible_bids: Vec<&SupplierBid> = bids
35            .iter()
36            .filter(|b| b.is_compliant && b.is_on_time)
37            .collect();
38
39        // Find min/max total amounts for price normalization
40        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                        // Price: lower is better
61                        let score = 100.0 * (1.0 - (bid_amount - min_amount) / amount_range);
62                        (score, true)
63                    } else {
64                        // Other criteria: random score
65                        (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, // Will be set after sorting
89                    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        // Sort by total score (descending)
99        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        // Assign ranks
106        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        // Ranks should be sequential starting at 1
289        for (i, ranked) in eval.ranked_bids.iter().enumerate() {
290            assert_eq!(ranked.rank, (i + 1) as u32);
291        }
292
293        // Scores should be in descending order
294        for window in eval.ranked_bids.windows(2) {
295            assert!(window[0].total_score >= window[1].total_score);
296        }
297
298        // Recommended vendor should be rank 1
299        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        // Make second bid non-compliant
311        bids[1].is_compliant = false;
312
313        let eval = gen.evaluate(&rfx, &bids, "EVAL-01");
314
315        // Only 1 eligible bid
316        assert_eq!(eval.ranked_bids.len(), 1);
317        assert_eq!(eval.ranked_bids[0].vendor_id, "V001");
318    }
319}