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