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.random_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.random_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.random_bool(0.92);
63            let is_compliant = self.rng.random_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.random_range(30..=90),
75                payment_terms: ["NET30", "NET45", "NET60", "2/10 NET30"]
76                    [self.rng.random_range(0..4)]
77                .to_string(),
78                delivery_terms: Some("FCA".to_string()),
79                technical_summary: None,
80                is_on_time,
81                is_compliant,
82            });
83        }
84
85        bids
86    }
87}
88
89#[cfg(test)]
90#[allow(clippy::unwrap_used)]
91mod tests {
92    use super::*;
93    use datasynth_core::models::sourcing::{
94        RfxEvaluationCriterion, RfxLineItem, RfxStatus, RfxType, ScoringMethod,
95    };
96
97    fn test_rfx() -> RfxEvent {
98        RfxEvent {
99            rfx_id: "RFX-001".to_string(),
100            rfx_type: RfxType::Rfp,
101            company_code: "C001".to_string(),
102            title: "Test RFx".to_string(),
103            description: "Test description".to_string(),
104            status: RfxStatus::Awarded,
105            sourcing_project_id: "SP-001".to_string(),
106            category_id: "CAT-001".to_string(),
107            scoring_method: ScoringMethod::BestValue,
108            criteria: vec![
109                RfxEvaluationCriterion {
110                    name: "Price".to_string(),
111                    weight: 0.40,
112                    description: "Cost".to_string(),
113                },
114                RfxEvaluationCriterion {
115                    name: "Quality".to_string(),
116                    weight: 0.35,
117                    description: "Quality".to_string(),
118                },
119                RfxEvaluationCriterion {
120                    name: "Delivery".to_string(),
121                    weight: 0.25,
122                    description: "Delivery".to_string(),
123                },
124            ],
125            line_items: vec![
126                RfxLineItem {
127                    item_number: 1,
128                    description: "Item A".to_string(),
129                    material_id: None,
130                    quantity: Decimal::from(100),
131                    uom: "EA".to_string(),
132                    target_price: Some(Decimal::from(50)),
133                },
134                RfxLineItem {
135                    item_number: 2,
136                    description: "Item B".to_string(),
137                    material_id: None,
138                    quantity: Decimal::from(200),
139                    uom: "EA".to_string(),
140                    target_price: Some(Decimal::from(25)),
141                },
142            ],
143            invited_vendors: vec!["V001".to_string(), "V002".to_string(), "V003".to_string()],
144            publish_date: NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
145            response_deadline: NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
146            bid_count: 3,
147            owner_id: "BUYER-01".to_string(),
148            awarded_vendor_id: None,
149            awarded_bid_id: None,
150        }
151    }
152
153    fn test_responding_vendors() -> Vec<String> {
154        vec!["V001".to_string(), "V002".to_string(), "V003".to_string()]
155    }
156
157    #[test]
158    fn test_basic_generation() {
159        let mut gen = BidGenerator::new(42);
160        let rfx = test_rfx();
161        let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
162        let bids = gen.generate(&rfx, &test_responding_vendors(), date);
163
164        assert_eq!(bids.len(), 3);
165        for bid in &bids {
166            assert!(!bid.bid_id.is_empty());
167            assert_eq!(bid.rfx_id, "RFX-001");
168            assert_eq!(bid.company_code, "C001");
169            assert_eq!(bid.submission_date, date);
170            assert_eq!(bid.line_items.len(), 2);
171            assert!(bid.total_amount > Decimal::ZERO);
172            assert_eq!(bid.status, BidStatus::Submitted);
173        }
174    }
175
176    #[test]
177    fn test_deterministic() {
178        let rfx = test_rfx();
179        let vendors = test_responding_vendors();
180        let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
181
182        let mut gen1 = BidGenerator::new(42);
183        let mut gen2 = BidGenerator::new(42);
184
185        let r1 = gen1.generate(&rfx, &vendors, date);
186        let r2 = gen2.generate(&rfx, &vendors, date);
187
188        assert_eq!(r1.len(), r2.len());
189        for (a, b) in r1.iter().zip(r2.iter()) {
190            assert_eq!(a.bid_id, b.bid_id);
191            assert_eq!(a.vendor_id, b.vendor_id);
192            assert_eq!(a.total_amount, b.total_amount);
193            assert_eq!(a.payment_terms, b.payment_terms);
194        }
195    }
196
197    #[test]
198    fn test_field_constraints() {
199        let mut gen = BidGenerator::new(99);
200        let rfx = test_rfx();
201        let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
202        let bids = gen.generate(&rfx, &test_responding_vendors(), date);
203
204        for bid in &bids {
205            // Validity days should be in range
206            assert!(bid.validity_days >= 30 && bid.validity_days <= 90);
207
208            // Payment terms should be one of the valid options
209            assert!(["NET30", "NET45", "NET60", "2/10 NET30"].contains(&bid.payment_terms.as_str()));
210
211            // Line items should match RFx line items
212            for line in &bid.line_items {
213                assert!(line.unit_price > Decimal::ZERO);
214                assert!(line.total_amount > Decimal::ZERO);
215                assert!(line.lead_time_days >= 5 && line.lead_time_days <= 60);
216            }
217
218            // Total amount should equal sum of line totals
219            let line_sum: Decimal = bid.line_items.iter().map(|l| l.total_amount).sum();
220            assert_eq!(bid.total_amount, line_sum);
221        }
222    }
223}