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.random_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            .random_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 =
72            publish_date + chrono::Duration::days(self.rng.random_range(14..=45));
73
74        let bid_count = (invited_vendors.len() as f64 * self.config.response_rate).round() as u32;
75
76        let criteria = vec![
77            RfxEvaluationCriterion {
78                name: "Price".to_string(),
79                weight: self.config.default_price_weight,
80                description: "Total cost of ownership".to_string(),
81            },
82            RfxEvaluationCriterion {
83                name: "Quality".to_string(),
84                weight: self.config.default_quality_weight,
85                description: "Quality management and track record".to_string(),
86            },
87            RfxEvaluationCriterion {
88                name: "Delivery".to_string(),
89                weight: self.config.default_delivery_weight,
90                description: "Lead time and reliability".to_string(),
91            },
92        ];
93
94        let line_count = self.rng.random_range(1u16..=5);
95        let line_items: Vec<RfxLineItem> = (1..=line_count)
96            .map(|i| RfxLineItem {
97                item_number: i,
98                description: format!("Item {}", i),
99                material_id: None,
100                quantity: Decimal::from(self.rng.random_range(10..=1000)),
101                uom: "EA".to_string(),
102                target_price: Some(Decimal::from(self.rng.random_range(10..=5000))),
103            })
104            .collect();
105
106        let scoring_method = match rfx_type {
107            RfxType::Rfq => ScoringMethod::LowestPrice,
108            RfxType::Rfp => ScoringMethod::BestValue,
109            RfxType::Rfi => ScoringMethod::QualityBased,
110        };
111
112        RfxEvent {
113            rfx_id: self.uuid_factory.next().to_string(),
114            rfx_type,
115            company_code: company_code.to_string(),
116            title: format!("RFx for {}", category_id),
117            description: format!(
118                "Sourcing event for category {} under project {}",
119                category_id, sourcing_project_id
120            ),
121            status: RfxStatus::Awarded,
122            sourcing_project_id: sourcing_project_id.to_string(),
123            category_id: category_id.to_string(),
124            scoring_method,
125            criteria,
126            line_items,
127            invited_vendors,
128            publish_date,
129            response_deadline,
130            bid_count,
131            owner_id: owner_id.to_string(),
132            awarded_vendor_id: None,
133            awarded_bid_id: None,
134        }
135    }
136}
137
138#[cfg(test)]
139#[allow(clippy::unwrap_used)]
140mod tests {
141    use super::*;
142
143    fn test_vendor_ids() -> Vec<String> {
144        (1..=6).map(|i| format!("V{:04}", i)).collect()
145    }
146
147    #[test]
148    fn test_basic_generation() {
149        let mut gen = RfxGenerator::new(42);
150        let date = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
151        let rfx = gen.generate(
152            "C001",
153            "SP-001",
154            "CAT-001",
155            &test_vendor_ids(),
156            "BUYER-01",
157            date,
158            200_000.0,
159        );
160
161        assert!(!rfx.rfx_id.is_empty());
162        assert_eq!(rfx.company_code, "C001");
163        assert_eq!(rfx.sourcing_project_id, "SP-001");
164        assert_eq!(rfx.category_id, "CAT-001");
165        assert_eq!(rfx.owner_id, "BUYER-01");
166        assert_eq!(rfx.status, RfxStatus::Awarded);
167        assert!(!rfx.invited_vendors.is_empty());
168        assert!(!rfx.criteria.is_empty());
169        assert!(!rfx.line_items.is_empty());
170        assert!(rfx.response_deadline > date);
171    }
172
173    #[test]
174    fn test_deterministic() {
175        let date = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
176        let vendors = test_vendor_ids();
177
178        let mut gen1 = RfxGenerator::new(42);
179        let mut gen2 = RfxGenerator::new(42);
180
181        let r1 = gen1.generate(
182            "C001", "SP-001", "CAT-001", &vendors, "BUYER-01", date, 200_000.0,
183        );
184        let r2 = gen2.generate(
185            "C001", "SP-001", "CAT-001", &vendors, "BUYER-01", date, 200_000.0,
186        );
187
188        assert_eq!(r1.rfx_id, r2.rfx_id);
189        assert_eq!(r1.rfx_type, r2.rfx_type);
190        assert_eq!(r1.invited_vendors, r2.invited_vendors);
191        assert_eq!(r1.line_items.len(), r2.line_items.len());
192        assert_eq!(r1.bid_count, r2.bid_count);
193    }
194
195    #[test]
196    fn test_field_constraints() {
197        let mut gen = RfxGenerator::new(99);
198        let date = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
199
200        // High spend should produce RFI or RFP
201        let rfx_high = gen.generate(
202            "C001",
203            "SP-001",
204            "CAT-001",
205            &test_vendor_ids(),
206            "BUYER-01",
207            date,
208            500_000.0,
209        );
210        assert!(matches!(rfx_high.rfx_type, RfxType::Rfi | RfxType::Rfp));
211
212        // Low spend should produce RFQ
213        let rfx_low = gen.generate(
214            "C001",
215            "SP-002",
216            "CAT-002",
217            &test_vendor_ids(),
218            "BUYER-01",
219            date,
220            50_000.0,
221        );
222        assert_eq!(rfx_low.rfx_type, RfxType::Rfq);
223
224        // Criteria weights should exist
225        assert_eq!(rfx_high.criteria.len(), 3);
226        let weight_sum: f64 = rfx_high.criteria.iter().map(|c| c.weight).sum();
227        assert!((weight_sum - 1.0).abs() < 0.01);
228
229        // Line items should have valid item numbers
230        for (i, item) in rfx_high.line_items.iter().enumerate() {
231            assert_eq!(item.item_number, (i + 1) as u16);
232            assert!(item.quantity > Decimal::ZERO);
233        }
234    }
235}