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