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