datasynth_generators/sourcing/
contract_generator.rs1use chrono::NaiveDate;
4use datasynth_config::schema::ContractConfig;
5use datasynth_core::models::sourcing::{
6 ContractLineItem, ContractSla, ContractStatus, ContractTerms, ContractType,
7 ProcurementContract, SupplierBid,
8};
9use datasynth_core::utils::seeded_rng;
10use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
11use rand::prelude::*;
12use rand_chacha::ChaCha8Rng;
13use rust_decimal::Decimal;
14
15pub struct ContractGenerator {
17 rng: ChaCha8Rng,
18 uuid_factory: DeterministicUuidFactory,
19 config: ContractConfig,
20}
21
22impl ContractGenerator {
23 pub fn new(seed: u64) -> Self {
25 Self {
26 rng: seeded_rng(seed, 0),
27 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::ProcurementContract),
28 config: ContractConfig::default(),
29 }
30 }
31
32 pub fn with_config(seed: u64, config: ContractConfig) -> Self {
34 Self {
35 rng: seeded_rng(seed, 0),
36 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::ProcurementContract),
37 config,
38 }
39 }
40
41 pub fn generate_from_bid(
43 &mut self,
44 winning_bid: &SupplierBid,
45 sourcing_project_id: Option<&str>,
46 category_id: &str,
47 owner_id: &str,
48 start_date: NaiveDate,
49 ) -> ProcurementContract {
50 let duration_months = self
51 .rng
52 .random_range(self.config.min_duration_months..=self.config.max_duration_months);
53 let end_date = start_date + chrono::Duration::days((duration_months * 30) as i64);
54
55 let contract_type = self.select_contract_type();
56
57 let auto_renewal = self.rng.random_bool(self.config.auto_renewal_rate);
58
59 let terms = ContractTerms {
60 payment_terms: winning_bid.payment_terms.clone(),
61 delivery_terms: winning_bid.delivery_terms.clone(),
62 warranty_months: if self.rng.random_bool(0.5) {
63 Some(self.rng.random_range(6..=24))
64 } else {
65 None
66 },
67 early_termination_penalty_pct: Some(self.rng.random_range(0.02..=0.10)),
68 auto_renewal,
69 termination_notice_days: self.rng.random_range(30..=120),
70 price_adjustment_clause: self.rng.random_bool(0.3),
71 max_annual_price_increase_pct: if self.rng.random_bool(0.4) {
72 Some(self.rng.random_range(0.02..=0.05))
73 } else {
74 None
75 },
76 };
77
78 let slas = vec![
79 ContractSla {
80 metric_name: "on_time_delivery".to_string(),
81 target_value: 0.95,
82 minimum_value: 0.90,
83 breach_penalty_pct: 0.02,
84 measurement_frequency: "monthly".to_string(),
85 },
86 ContractSla {
87 metric_name: "defect_rate".to_string(),
88 target_value: 0.02,
89 minimum_value: 0.05,
90 breach_penalty_pct: 0.03,
91 measurement_frequency: "quarterly".to_string(),
92 },
93 ];
94
95 let line_items: Vec<ContractLineItem> = winning_bid
96 .line_items
97 .iter()
98 .map(|bl| {
99 let annual_qty = bl.quantity * Decimal::from(12);
100 ContractLineItem {
101 line_number: bl.item_number,
102 material_id: None,
103 description: format!("Contract item {}", bl.item_number),
104 unit_price: bl.unit_price,
105 uom: "EA".to_string(),
106 min_quantity: Some(bl.quantity),
107 max_quantity: Some(annual_qty),
108 quantity_released: Decimal::ZERO,
109 value_released: Decimal::ZERO,
110 }
111 })
112 .collect();
113
114 let total_value: Decimal = line_items
116 .iter()
117 .map(|li| li.max_quantity.unwrap_or(Decimal::ZERO) * li.unit_price)
118 .sum();
119
120 ProcurementContract {
121 contract_id: self.uuid_factory.next().to_string(),
122 company_code: winning_bid.company_code.clone(),
123 contract_type,
124 status: ContractStatus::Active,
125 vendor_id: winning_bid.vendor_id.clone(),
126 title: format!(
127 "Contract with {} for {}",
128 winning_bid.vendor_id, category_id
129 ),
130 sourcing_project_id: sourcing_project_id.map(std::string::ToString::to_string),
131 bid_id: Some(winning_bid.bid_id.clone()),
132 start_date,
133 end_date,
134 total_value,
135 consumed_value: Decimal::ZERO,
136 terms,
137 slas,
138 line_items,
139 category_id: category_id.to_string(),
140 owner_id: owner_id.to_string(),
141 amendment_count: if self.rng.random_bool(self.config.amendment_rate) {
142 self.rng.random_range(1..=3)
143 } else {
144 0
145 },
146 previous_contract_id: None,
147 purchase_order_ids: Vec::new(),
148 }
149 }
150
151 fn select_contract_type(&mut self) -> ContractType {
152 let dist = &self.config.type_distribution;
153 let r: f64 = self.rng.random();
154 if r < dist.fixed_price {
155 ContractType::FixedPrice
156 } else if r < dist.fixed_price + dist.blanket {
157 ContractType::Blanket
158 } else if r < dist.fixed_price + dist.blanket + dist.time_and_materials {
159 ContractType::TimeAndMaterials
160 } else {
161 ContractType::ServiceAgreement
162 }
163 }
164}
165
166#[cfg(test)]
167#[allow(clippy::unwrap_used)]
168mod tests {
169 use super::*;
170 use datasynth_core::models::sourcing::{BidLineItem, BidStatus};
171
172 fn test_winning_bid() -> SupplierBid {
173 SupplierBid {
174 bid_id: "BID-001".to_string(),
175 rfx_id: "RFX-001".to_string(),
176 vendor_id: "V001".to_string(),
177 company_code: "C001".to_string(),
178 status: BidStatus::Submitted,
179 submission_date: NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
180 line_items: vec![
181 BidLineItem {
182 item_number: 1,
183 unit_price: Decimal::from(50),
184 quantity: Decimal::from(100),
185 total_amount: Decimal::from(5000),
186 lead_time_days: 10,
187 notes: None,
188 },
189 BidLineItem {
190 item_number: 2,
191 unit_price: Decimal::from(25),
192 quantity: Decimal::from(200),
193 total_amount: Decimal::from(5000),
194 lead_time_days: 15,
195 notes: None,
196 },
197 ],
198 total_amount: Decimal::from(10000),
199 validity_days: 60,
200 payment_terms: "NET30".to_string(),
201 delivery_terms: Some("FCA".to_string()),
202 technical_summary: None,
203 is_on_time: true,
204 is_compliant: true,
205 }
206 }
207
208 #[test]
209 fn test_basic_generation() {
210 let mut gen = ContractGenerator::new(42);
211 let bid = test_winning_bid();
212 let start = NaiveDate::from_ymd_opt(2024, 4, 1).unwrap();
213 let contract = gen.generate_from_bid(&bid, Some("SP-001"), "CAT-001", "BUYER-01", start);
214
215 assert!(!contract.contract_id.is_empty());
216 assert_eq!(contract.company_code, "C001");
217 assert_eq!(contract.vendor_id, "V001");
218 assert_eq!(contract.status, ContractStatus::Active);
219 assert_eq!(contract.bid_id.as_deref(), Some("BID-001"));
220 assert_eq!(contract.sourcing_project_id.as_deref(), Some("SP-001"));
221 assert_eq!(contract.category_id, "CAT-001");
222 assert_eq!(contract.owner_id, "BUYER-01");
223 assert_eq!(contract.start_date, start);
224 assert!(contract.end_date > start);
225 assert!(contract.total_value > Decimal::ZERO);
226 assert_eq!(contract.consumed_value, Decimal::ZERO);
227 assert_eq!(contract.line_items.len(), 2);
228 assert_eq!(contract.slas.len(), 2);
229 }
230
231 #[test]
232 fn test_deterministic() {
233 let bid = test_winning_bid();
234 let start = NaiveDate::from_ymd_opt(2024, 4, 1).unwrap();
235
236 let mut gen1 = ContractGenerator::new(42);
237 let mut gen2 = ContractGenerator::new(42);
238
239 let r1 = gen1.generate_from_bid(&bid, Some("SP-001"), "CAT-001", "BUYER-01", start);
240 let r2 = gen2.generate_from_bid(&bid, Some("SP-001"), "CAT-001", "BUYER-01", start);
241
242 assert_eq!(r1.contract_id, r2.contract_id);
243 assert_eq!(r1.contract_type, r2.contract_type);
244 assert_eq!(r1.end_date, r2.end_date);
245 assert_eq!(r1.total_value, r2.total_value);
246 assert_eq!(r1.terms.payment_terms, r2.terms.payment_terms);
247 assert_eq!(r1.terms.auto_renewal, r2.terms.auto_renewal);
248 assert_eq!(r1.amendment_count, r2.amendment_count);
249 }
250
251 #[test]
252 fn test_field_constraints() {
253 let mut gen = ContractGenerator::new(99);
254 let bid = test_winning_bid();
255 let start = NaiveDate::from_ymd_opt(2024, 4, 1).unwrap();
256 let contract = gen.generate_from_bid(&bid, None, "CAT-001", "BUYER-01", start);
257
258 let duration_days = (contract.end_date - contract.start_date).num_days();
260 assert!((12 * 30..=36 * 30).contains(&duration_days));
261
262 assert!(contract.terms.termination_notice_days >= 30);
264 assert!(contract.terms.termination_notice_days <= 120);
265 assert_eq!(contract.terms.payment_terms, "NET30"); for sla in &contract.slas {
269 assert!(!sla.metric_name.is_empty());
270 assert!(!sla.measurement_frequency.is_empty());
271 }
272
273 assert_eq!(contract.line_items.len(), 2);
275 for line in &contract.line_items {
276 assert!(line.unit_price > Decimal::ZERO);
277 assert_eq!(line.quantity_released, Decimal::ZERO);
278 assert_eq!(line.value_released, Decimal::ZERO);
279 }
280
281 assert!(contract.sourcing_project_id.is_none());
283 }
284}