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::uuid_factory::{DeterministicUuidFactory, GeneratorType};
6use rand::prelude::*;
7use rand_chacha::ChaCha8Rng;
8use rust_decimal::Decimal;
9
10/// Generates supplier bids in response to RFx events.
11pub struct BidGenerator {
12    rng: ChaCha8Rng,
13    uuid_factory: DeterministicUuidFactory,
14}
15
16impl BidGenerator {
17    /// Create a new bid generator.
18    pub fn new(seed: u64) -> Self {
19        Self {
20            rng: ChaCha8Rng::seed_from_u64(seed),
21            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::SupplierBid),
22        }
23    }
24
25    /// Generate bids for an RFx event from responding vendors.
26    pub fn generate(
27        &mut self,
28        rfx: &RfxEvent,
29        responding_vendor_ids: &[String],
30        submission_date: NaiveDate,
31    ) -> Vec<SupplierBid> {
32        let mut bids = Vec::new();
33
34        for vendor_id in responding_vendor_ids {
35            let line_items: Vec<BidLineItem> = rfx
36                .line_items
37                .iter()
38                .map(|rfx_item| {
39                    // Vendor offers price within ±30% of target
40                    let target = rfx_item.target_price.unwrap_or(Decimal::from(100));
41                    let target_f64: f64 = target.to_string().parse().unwrap_or(100.0);
42                    let price_factor = self.rng.gen_range(0.70..=1.30);
43                    let unit_price =
44                        Decimal::from_f64_retain(target_f64 * price_factor).unwrap_or(target);
45                    let quantity = rfx_item.quantity;
46                    let total = unit_price * quantity;
47
48                    BidLineItem {
49                        item_number: rfx_item.item_number,
50                        unit_price,
51                        quantity,
52                        total_amount: total,
53                        lead_time_days: self.rng.gen_range(5..=60),
54                        notes: None,
55                    }
56                })
57                .collect();
58
59            let total_amount: Decimal = line_items.iter().map(|i| i.total_amount).sum();
60
61            let is_on_time = self.rng.gen_bool(0.92);
62            let is_compliant = self.rng.gen_bool(0.88);
63
64            bids.push(SupplierBid {
65                bid_id: self.uuid_factory.next().to_string(),
66                rfx_id: rfx.rfx_id.clone(),
67                vendor_id: vendor_id.clone(),
68                company_code: rfx.company_code.clone(),
69                status: BidStatus::Submitted,
70                submission_date,
71                line_items,
72                total_amount,
73                validity_days: self.rng.gen_range(30..=90),
74                payment_terms: ["NET30", "NET45", "NET60", "2/10 NET30"][self.rng.gen_range(0..4)]
75                    .to_string(),
76                delivery_terms: Some("FCA".to_string()),
77                technical_summary: None,
78                is_on_time,
79                is_compliant,
80            });
81        }
82
83        bids
84    }
85}
86
87#[cfg(test)]
88#[allow(clippy::unwrap_used)]
89mod tests {
90    use super::*;
91    use datasynth_core::models::sourcing::{
92        RfxEvaluationCriterion, RfxLineItem, RfxStatus, RfxType, ScoringMethod,
93    };
94
95    fn test_rfx() -> RfxEvent {
96        RfxEvent {
97            rfx_id: "RFX-001".to_string(),
98            rfx_type: RfxType::Rfp,
99            company_code: "C001".to_string(),
100            title: "Test RFx".to_string(),
101            description: "Test description".to_string(),
102            status: RfxStatus::Awarded,
103            sourcing_project_id: "SP-001".to_string(),
104            category_id: "CAT-001".to_string(),
105            scoring_method: ScoringMethod::BestValue,
106            criteria: vec![
107                RfxEvaluationCriterion {
108                    name: "Price".to_string(),
109                    weight: 0.40,
110                    description: "Cost".to_string(),
111                },
112                RfxEvaluationCriterion {
113                    name: "Quality".to_string(),
114                    weight: 0.35,
115                    description: "Quality".to_string(),
116                },
117                RfxEvaluationCriterion {
118                    name: "Delivery".to_string(),
119                    weight: 0.25,
120                    description: "Delivery".to_string(),
121                },
122            ],
123            line_items: vec![
124                RfxLineItem {
125                    item_number: 1,
126                    description: "Item A".to_string(),
127                    material_id: None,
128                    quantity: Decimal::from(100),
129                    uom: "EA".to_string(),
130                    target_price: Some(Decimal::from(50)),
131                },
132                RfxLineItem {
133                    item_number: 2,
134                    description: "Item B".to_string(),
135                    material_id: None,
136                    quantity: Decimal::from(200),
137                    uom: "EA".to_string(),
138                    target_price: Some(Decimal::from(25)),
139                },
140            ],
141            invited_vendors: vec!["V001".to_string(), "V002".to_string(), "V003".to_string()],
142            publish_date: NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
143            response_deadline: NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
144            bid_count: 3,
145            owner_id: "BUYER-01".to_string(),
146            awarded_vendor_id: None,
147            awarded_bid_id: None,
148        }
149    }
150
151    fn test_responding_vendors() -> Vec<String> {
152        vec!["V001".to_string(), "V002".to_string(), "V003".to_string()]
153    }
154
155    #[test]
156    fn test_basic_generation() {
157        let mut gen = BidGenerator::new(42);
158        let rfx = test_rfx();
159        let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
160        let bids = gen.generate(&rfx, &test_responding_vendors(), date);
161
162        assert_eq!(bids.len(), 3);
163        for bid in &bids {
164            assert!(!bid.bid_id.is_empty());
165            assert_eq!(bid.rfx_id, "RFX-001");
166            assert_eq!(bid.company_code, "C001");
167            assert_eq!(bid.submission_date, date);
168            assert_eq!(bid.line_items.len(), 2);
169            assert!(bid.total_amount > Decimal::ZERO);
170            assert_eq!(bid.status, BidStatus::Submitted);
171        }
172    }
173
174    #[test]
175    fn test_deterministic() {
176        let rfx = test_rfx();
177        let vendors = test_responding_vendors();
178        let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
179
180        let mut gen1 = BidGenerator::new(42);
181        let mut gen2 = BidGenerator::new(42);
182
183        let r1 = gen1.generate(&rfx, &vendors, date);
184        let r2 = gen2.generate(&rfx, &vendors, date);
185
186        assert_eq!(r1.len(), r2.len());
187        for (a, b) in r1.iter().zip(r2.iter()) {
188            assert_eq!(a.bid_id, b.bid_id);
189            assert_eq!(a.vendor_id, b.vendor_id);
190            assert_eq!(a.total_amount, b.total_amount);
191            assert_eq!(a.payment_terms, b.payment_terms);
192        }
193    }
194
195    #[test]
196    fn test_field_constraints() {
197        let mut gen = BidGenerator::new(99);
198        let rfx = test_rfx();
199        let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
200        let bids = gen.generate(&rfx, &test_responding_vendors(), date);
201
202        for bid in &bids {
203            // Validity days should be in range
204            assert!(bid.validity_days >= 30 && bid.validity_days <= 90);
205
206            // Payment terms should be one of the valid options
207            assert!(["NET30", "NET45", "NET60", "2/10 NET30"].contains(&bid.payment_terms.as_str()));
208
209            // Line items should match RFx line items
210            for line in &bid.line_items {
211                assert!(line.unit_price > Decimal::ZERO);
212                assert!(line.total_amount > Decimal::ZERO);
213                assert!(line.lead_time_days >= 5 && line.lead_time_days <= 60);
214            }
215
216            // Total amount should equal sum of line totals
217            let line_sum: Decimal = bid.line_items.iter().map(|l| l.total_amount).sum();
218            assert_eq!(bid.total_amount, line_sum);
219        }
220    }
221}