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