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