Skip to main content

datasynth_generators/sourcing/
rfx_generator.rs

1//! RFx event generator.
2
3use chrono::NaiveDate;
4use datasynth_config::schema::RfxConfig;
5use datasynth_core::models::sourcing::{
6    RfxEvaluationCriterion, RfxEvent, RfxLineItem, RfxStatus, RfxType, ScoringMethod,
7};
8use datasynth_core::utils::seeded_rng;
9use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
10use rand::prelude::*;
11use rand_chacha::ChaCha8Rng;
12use rust_decimal::Decimal;
13
14/// Generates RFx events (RFI/RFP/RFQ).
15pub struct RfxGenerator {
16    rng: ChaCha8Rng,
17    uuid_factory: DeterministicUuidFactory,
18    config: RfxConfig,
19}
20
21impl RfxGenerator {
22    /// Create a new RFx generator.
23    pub fn new(seed: u64) -> Self {
24        Self {
25            rng: seeded_rng(seed, 0),
26            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::RfxEvent),
27            config: RfxConfig::default(),
28        }
29    }
30
31    /// Create with custom configuration.
32    pub fn with_config(seed: u64, config: RfxConfig) -> Self {
33        Self {
34            rng: seeded_rng(seed, 0),
35            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::RfxEvent),
36            config,
37        }
38    }
39
40    /// Generate an RFx event for a sourcing project.
41    pub fn generate(
42        &mut self,
43        company_code: &str,
44        sourcing_project_id: &str,
45        category_id: &str,
46        qualified_vendor_ids: &[String],
47        owner_id: &str,
48        publish_date: NaiveDate,
49        estimated_spend: f64,
50    ) -> RfxEvent {
51        let rfx_type = if estimated_spend > self.config.rfi_threshold {
52            if self.rng.gen_bool(0.3) {
53                RfxType::Rfi
54            } else {
55                RfxType::Rfp
56            }
57        } else {
58            RfxType::Rfq
59        };
60
61        let invited_count = self
62            .rng
63            .gen_range(self.config.min_invited_vendors..=self.config.max_invited_vendors)
64            .min(qualified_vendor_ids.len() as u32) as usize;
65
66        let invited_vendors: Vec<String> = qualified_vendor_ids
67            .choose_multiple(&mut self.rng, invited_count)
68            .cloned()
69            .collect();
70
71        let response_deadline = publish_date + chrono::Duration::days(self.rng.gen_range(14..=45));
72
73        let bid_count = (invited_vendors.len() as f64 * self.config.response_rate).round() as u32;
74
75        let criteria = vec![
76            RfxEvaluationCriterion {
77                name: "Price".to_string(),
78                weight: self.config.default_price_weight,
79                description: "Total cost of ownership".to_string(),
80            },
81            RfxEvaluationCriterion {
82                name: "Quality".to_string(),
83                weight: self.config.default_quality_weight,
84                description: "Quality management and track record".to_string(),
85            },
86            RfxEvaluationCriterion {
87                name: "Delivery".to_string(),
88                weight: self.config.default_delivery_weight,
89                description: "Lead time and reliability".to_string(),
90            },
91        ];
92
93        let line_count = self.rng.gen_range(1u16..=5);
94        let line_items: Vec<RfxLineItem> = (1..=line_count)
95            .map(|i| RfxLineItem {
96                item_number: i,
97                description: format!("Item {}", i),
98                material_id: None,
99                quantity: Decimal::from(self.rng.gen_range(10..=1000)),
100                uom: "EA".to_string(),
101                target_price: Some(Decimal::from(self.rng.gen_range(10..=5000))),
102            })
103            .collect();
104
105        let scoring_method = match rfx_type {
106            RfxType::Rfq => ScoringMethod::LowestPrice,
107            RfxType::Rfp => ScoringMethod::BestValue,
108            RfxType::Rfi => ScoringMethod::QualityBased,
109        };
110
111        RfxEvent {
112            rfx_id: self.uuid_factory.next().to_string(),
113            rfx_type,
114            company_code: company_code.to_string(),
115            title: format!("RFx for {}", category_id),
116            description: format!(
117                "Sourcing event for category {} under project {}",
118                category_id, sourcing_project_id
119            ),
120            status: RfxStatus::Awarded,
121            sourcing_project_id: sourcing_project_id.to_string(),
122            category_id: category_id.to_string(),
123            scoring_method,
124            criteria,
125            line_items,
126            invited_vendors,
127            publish_date,
128            response_deadline,
129            bid_count,
130            owner_id: owner_id.to_string(),
131            awarded_vendor_id: None,
132            awarded_bid_id: None,
133        }
134    }
135}
136
137#[cfg(test)]
138#[allow(clippy::unwrap_used)]
139mod tests {
140    use super::*;
141
142    fn test_vendor_ids() -> Vec<String> {
143        (1..=6).map(|i| format!("V{:04}", i)).collect()
144    }
145
146    #[test]
147    fn test_basic_generation() {
148        let mut gen = RfxGenerator::new(42);
149        let date = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
150        let rfx = gen.generate(
151            "C001",
152            "SP-001",
153            "CAT-001",
154            &test_vendor_ids(),
155            "BUYER-01",
156            date,
157            200_000.0,
158        );
159
160        assert!(!rfx.rfx_id.is_empty());
161        assert_eq!(rfx.company_code, "C001");
162        assert_eq!(rfx.sourcing_project_id, "SP-001");
163        assert_eq!(rfx.category_id, "CAT-001");
164        assert_eq!(rfx.owner_id, "BUYER-01");
165        assert_eq!(rfx.status, RfxStatus::Awarded);
166        assert!(!rfx.invited_vendors.is_empty());
167        assert!(!rfx.criteria.is_empty());
168        assert!(!rfx.line_items.is_empty());
169        assert!(rfx.response_deadline > date);
170    }
171
172    #[test]
173    fn test_deterministic() {
174        let date = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
175        let vendors = test_vendor_ids();
176
177        let mut gen1 = RfxGenerator::new(42);
178        let mut gen2 = RfxGenerator::new(42);
179
180        let r1 = gen1.generate(
181            "C001", "SP-001", "CAT-001", &vendors, "BUYER-01", date, 200_000.0,
182        );
183        let r2 = gen2.generate(
184            "C001", "SP-001", "CAT-001", &vendors, "BUYER-01", date, 200_000.0,
185        );
186
187        assert_eq!(r1.rfx_id, r2.rfx_id);
188        assert_eq!(r1.rfx_type, r2.rfx_type);
189        assert_eq!(r1.invited_vendors, r2.invited_vendors);
190        assert_eq!(r1.line_items.len(), r2.line_items.len());
191        assert_eq!(r1.bid_count, r2.bid_count);
192    }
193
194    #[test]
195    fn test_field_constraints() {
196        let mut gen = RfxGenerator::new(99);
197        let date = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
198
199        // High spend should produce RFI or RFP
200        let rfx_high = gen.generate(
201            "C001",
202            "SP-001",
203            "CAT-001",
204            &test_vendor_ids(),
205            "BUYER-01",
206            date,
207            500_000.0,
208        );
209        assert!(matches!(rfx_high.rfx_type, RfxType::Rfi | RfxType::Rfp));
210
211        // Low spend should produce RFQ
212        let rfx_low = gen.generate(
213            "C001",
214            "SP-002",
215            "CAT-002",
216            &test_vendor_ids(),
217            "BUYER-01",
218            date,
219            50_000.0,
220        );
221        assert_eq!(rfx_low.rfx_type, RfxType::Rfq);
222
223        // Criteria weights should exist
224        assert_eq!(rfx_high.criteria.len(), 3);
225        let weight_sum: f64 = rfx_high.criteria.iter().map(|c| c.weight).sum();
226        assert!((weight_sum - 1.0).abs() < 0.01);
227
228        // Line items should have valid item numbers
229        for (i, item) in rfx_high.line_items.iter().enumerate() {
230            assert_eq!(item.item_number, (i + 1) as u16);
231            assert!(item.quantity > Decimal::ZERO);
232        }
233    }
234}