Skip to main content

datasynth_generators/sourcing/
contract_generator.rs

1//! Procurement contract generator.
2
3use 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
15/// Generates procurement contracts from awarded bids.
16pub struct ContractGenerator {
17    rng: ChaCha8Rng,
18    uuid_factory: DeterministicUuidFactory,
19    config: ContractConfig,
20}
21
22impl ContractGenerator {
23    /// Create a new contract generator.
24    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    /// Create with custom configuration.
33    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    /// Generate a contract from a winning bid.
42    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        // Total value = sum of max quantities * prices
115        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        // Duration should be within configured range (12-36 months default)
259        let duration_days = (contract.end_date - contract.start_date).num_days();
260        assert!((12 * 30..=36 * 30).contains(&duration_days));
261
262        // Terms constraints
263        assert!(contract.terms.termination_notice_days >= 30);
264        assert!(contract.terms.termination_notice_days <= 120);
265        assert_eq!(contract.terms.payment_terms, "NET30"); // From winning bid
266
267        // SLA metrics
268        for sla in &contract.slas {
269            assert!(!sla.metric_name.is_empty());
270            assert!(!sla.measurement_frequency.is_empty());
271        }
272
273        // Contract line items should match bid line items
274        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        // No sourcing project when None passed
282        assert!(contract.sourcing_project_id.is_none());
283    }
284}