datasynth_generators/sourcing/
catalog_generator.rs1use 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
10pub struct CatalogGenerator {
12 rng: ChaCha8Rng,
13 uuid_factory: DeterministicUuidFactory,
14 config: CatalogConfig,
15}
16
17impl CatalogGenerator {
18 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 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 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 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 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 assert!(item.lead_time_days.is_some());
206 let lt = item.lead_time_days.unwrap();
207 assert!(lt >= 3 && lt <= 45);
208
209 assert!(!item.uom.is_empty());
211
212 assert!(item.contract_line_number >= 1);
214 }
215
216 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); assert!(alt.description.contains("(alternate)"));
224 }
225 }
226}