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)]
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        // Ranks should be sequential starting at 1
290        for (i, ranked) in eval.ranked_bids.iter().enumerate() {
291            assert_eq!(ranked.rank, (i + 1) as u32);
292        }
293
294        // Scores should be in descending order
295        for window in eval.ranked_bids.windows(2) {
296            assert!(window[0].total_score >= window[1].total_score);
297        }
298
299        // Recommended vendor should be rank 1
300        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        // Make second bid non-compliant
312        bids[1].is_compliant = false;
313
314        let eval = gen.evaluate(&rfx, &bids, "EVAL-01");
315
316        // Only 1 eligible bid
317        assert_eq!(eval.ranked_bids.len(), 1);
318        assert_eq!(eval.ranked_bids[0].vendor_id, "V001");
319    }
320}