Skip to main content

datasynth_generators/sourcing/
catalog_generator.rs

1//! Catalog item generator.
2
3use datasynth_config::schema::CatalogConfig;
4use datasynth_core::models::sourcing::{CatalogItem, ProcurementContract};
5use datasynth_core::utils::seeded_rng;
6use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
7use rand::prelude::*;
8use rand_chacha::ChaCha8Rng;
9
10/// Generates catalog items from active contracts.
11pub struct CatalogGenerator {
12    rng: ChaCha8Rng,
13    uuid_factory: DeterministicUuidFactory,
14    config: CatalogConfig,
15}
16
17impl CatalogGenerator {
18    /// Create a new catalog generator.
19    pub fn new(seed: u64) -> Self {
20        Self {
21            rng: seeded_rng(seed, 0),
22            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::CatalogItem),
23            config: CatalogConfig::default(),
24        }
25    }
26
27    /// Create with custom configuration.
28    pub fn with_config(seed: u64, config: CatalogConfig) -> Self {
29        Self {
30            rng: seeded_rng(seed, 0),
31            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::CatalogItem),
32            config,
33        }
34    }
35
36    /// Generate catalog items from a list of active contracts.
37    pub fn generate(&mut self, contracts: &[ProcurementContract]) -> Vec<CatalogItem> {
38        let mut items = Vec::new();
39
40        for contract in contracts {
41            for line in &contract.line_items {
42                let is_preferred = self.rng.random_bool(self.config.preferred_vendor_flag_rate);
43
44                items.push(CatalogItem {
45                    catalog_item_id: self.uuid_factory.next().to_string(),
46                    contract_id: contract.contract_id.clone(),
47                    contract_line_number: line.line_number,
48                    vendor_id: contract.vendor_id.clone(),
49                    material_id: line.material_id.clone(),
50                    description: line.description.clone(),
51                    catalog_price: line.unit_price,
52                    uom: line.uom.clone(),
53                    is_preferred,
54                    category: contract.category_id.clone(),
55                    min_order_quantity: line.min_quantity,
56                    lead_time_days: Some(self.rng.random_range(3..=30)),
57                    is_active: true,
58                });
59
60                // Possibly add alternative source
61                if self.rng.random_bool(self.config.multi_source_rate) {
62                    items.push(CatalogItem {
63                        catalog_item_id: self.uuid_factory.next().to_string(),
64                        contract_id: contract.contract_id.clone(),
65                        contract_line_number: line.line_number,
66                        vendor_id: format!("{}-ALT", contract.vendor_id),
67                        material_id: line.material_id.clone(),
68                        description: format!("{} (alternate)", line.description),
69                        catalog_price: line.unit_price
70                            * rust_decimal::Decimal::from_f64_retain(
71                                self.rng.random_range(0.95..=1.10),
72                            )
73                            .unwrap_or(rust_decimal::Decimal::ONE),
74                        uom: line.uom.clone(),
75                        is_preferred: false,
76                        category: contract.category_id.clone(),
77                        min_order_quantity: line.min_quantity,
78                        lead_time_days: Some(self.rng.random_range(5..=45)),
79                        is_active: true,
80                    });
81                }
82            }
83        }
84
85        items
86    }
87}
88
89#[cfg(test)]
90#[allow(clippy::unwrap_used)]
91mod tests {
92    use super::*;
93    use datasynth_core::models::sourcing::{
94        ContractLineItem, ContractSla, ContractStatus, ContractTerms, ContractType,
95    };
96    use rust_decimal::Decimal;
97
98    fn test_contract() -> ProcurementContract {
99        ProcurementContract {
100            contract_id: "CTR-001".to_string(),
101            company_code: "C001".to_string(),
102            contract_type: ContractType::FixedPrice,
103            status: ContractStatus::Active,
104            vendor_id: "V001".to_string(),
105            title: "Test Contract".to_string(),
106            sourcing_project_id: None,
107            bid_id: None,
108            start_date: chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
109            end_date: chrono::NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
110            total_value: Decimal::from(100_000),
111            consumed_value: Decimal::ZERO,
112            terms: ContractTerms {
113                payment_terms: "NET30".to_string(),
114                delivery_terms: Some("FCA".to_string()),
115                warranty_months: None,
116                early_termination_penalty_pct: None,
117                auto_renewal: false,
118                termination_notice_days: 60,
119                price_adjustment_clause: false,
120                max_annual_price_increase_pct: None,
121            },
122            slas: vec![ContractSla {
123                metric_name: "on_time_delivery".to_string(),
124                target_value: 0.95,
125                minimum_value: 0.90,
126                breach_penalty_pct: 0.02,
127                measurement_frequency: "monthly".to_string(),
128            }],
129            line_items: vec![
130                ContractLineItem {
131                    line_number: 1,
132                    material_id: None,
133                    description: "Widget A".to_string(),
134                    unit_price: Decimal::from(50),
135                    uom: "EA".to_string(),
136                    min_quantity: Some(Decimal::from(100)),
137                    max_quantity: Some(Decimal::from(1200)),
138                    quantity_released: Decimal::ZERO,
139                    value_released: Decimal::ZERO,
140                },
141                ContractLineItem {
142                    line_number: 2,
143                    material_id: Some("MAT-002".to_string()),
144                    description: "Widget B".to_string(),
145                    unit_price: Decimal::from(25),
146                    uom: "EA".to_string(),
147                    min_quantity: Some(Decimal::from(200)),
148                    max_quantity: Some(Decimal::from(2400)),
149                    quantity_released: Decimal::ZERO,
150                    value_released: Decimal::ZERO,
151                },
152            ],
153            category_id: "CAT-001".to_string(),
154            owner_id: "BUYER-01".to_string(),
155            amendment_count: 0,
156            previous_contract_id: None,
157        }
158    }
159
160    #[test]
161    fn test_basic_generation() {
162        let mut gen = CatalogGenerator::new(42);
163        let contracts = vec![test_contract()];
164        let items = gen.generate(&contracts);
165
166        // Should have at least 2 items (one per contract line)
167        assert!(items.len() >= 2);
168        for item in &items {
169            assert!(!item.catalog_item_id.is_empty());
170            assert_eq!(item.contract_id, "CTR-001");
171            assert!(!item.vendor_id.is_empty());
172            assert!(item.catalog_price > Decimal::ZERO);
173            assert!(item.is_active);
174            assert_eq!(item.category, "CAT-001");
175        }
176    }
177
178    #[test]
179    fn test_deterministic() {
180        let contracts = vec![test_contract()];
181
182        let mut gen1 = CatalogGenerator::new(42);
183        let mut gen2 = CatalogGenerator::new(42);
184
185        let r1 = gen1.generate(&contracts);
186        let r2 = gen2.generate(&contracts);
187
188        assert_eq!(r1.len(), r2.len());
189        for (a, b) in r1.iter().zip(r2.iter()) {
190            assert_eq!(a.catalog_item_id, b.catalog_item_id);
191            assert_eq!(a.vendor_id, b.vendor_id);
192            assert_eq!(a.catalog_price, b.catalog_price);
193            assert_eq!(a.is_preferred, b.is_preferred);
194        }
195    }
196
197    #[test]
198    fn test_field_constraints() {
199        let mut gen = CatalogGenerator::new(99);
200        let contracts = vec![test_contract()];
201        let items = gen.generate(&contracts);
202
203        for item in &items {
204            // Lead time should be within expected range
205            assert!(item.lead_time_days.is_some());
206            let lt = item.lead_time_days.unwrap();
207            assert!(lt >= 3 && lt <= 45);
208
209            // UOM should be set
210            assert!(!item.uom.is_empty());
211
212            // Contract line number should be valid
213            assert!(item.contract_line_number >= 1);
214        }
215
216        // Check alternate sources have "-ALT" suffix on vendor_id
217        let alt_items: Vec<_> = items
218            .iter()
219            .filter(|i| i.vendor_id.contains("-ALT"))
220            .collect();
221        for alt in &alt_items {
222            assert!(!alt.is_preferred); // Alternates should not be preferred
223            assert!(alt.description.contains("(alternate)"));
224        }
225    }
226}