Skip to main content

datasynth_generators/sourcing/
bid_generator.rs

1//! Supplier bid generator.
2
3use 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
11/// Generates supplier bids in response to RFx events.
12pub struct BidGenerator {
13    rng: ChaCha8Rng,
14    uuid_factory: DeterministicUuidFactory,
15}
16
17impl BidGenerator {
18    /// Create a new bid generator.
19    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    /// Generate bids for an RFx event from responding vendors.
27    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                    // Vendor offers price within ±30% of target
41                    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            // Validity days should be in range
205            assert!(bid.validity_days >= 30 && bid.validity_days <= 90);
206
207            // Payment terms should be one of the valid options
208            assert!(["NET30", "NET45", "NET60", "2/10 NET30"].contains(&bid.payment_terms.as_str()));
209
210            // Line items should match RFx line items
211            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            // Total amount should equal sum of line totals
218            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}