Skip to main content

datasynth_generators/document_flow/
p2p_generator.rs

1//! Procure-to-Pay (P2P) flow generator.
2//!
3//! Generates complete P2P document chains:
4//! PurchaseOrder → GoodsReceipt → VendorInvoice → Payment
5
6use chrono::{Datelike, NaiveDate};
7use rand::prelude::*;
8use rand_chacha::ChaCha8Rng;
9use rust_decimal::Decimal;
10
11use datasynth_core::models::{
12    documents::{
13        DocumentReference, DocumentType, GoodsReceipt, GoodsReceiptItem, MovementType, Payment,
14        PaymentMethod, PurchaseOrder, PurchaseOrderItem, ReferenceType, VendorInvoice,
15        VendorInvoiceItem,
16    },
17    Material, MaterialPool, PaymentTerms, Vendor, VendorPool,
18};
19
20use super::three_way_match::ThreeWayMatcher;
21
22/// Configuration for P2P flow generation.
23#[derive(Debug, Clone)]
24pub struct P2PGeneratorConfig {
25    /// Three-way match success rate (PO-GR-Invoice match)
26    pub three_way_match_rate: f64,
27    /// Rate of partial deliveries
28    pub partial_delivery_rate: f64,
29    /// Rate of over-delivery (quantity exceeds PO)
30    pub over_delivery_rate: f64,
31    /// Rate of price variance (invoice price differs from PO)
32    pub price_variance_rate: f64,
33    /// Max price variance percentage
34    pub max_price_variance_percent: f64,
35    /// Average days between PO and GR
36    pub avg_days_po_to_gr: u32,
37    /// Average days between GR and Invoice
38    pub avg_days_gr_to_invoice: u32,
39    /// Average days between Invoice and Payment
40    pub avg_days_invoice_to_payment: u32,
41    /// Payment method distribution
42    pub payment_method_distribution: Vec<(PaymentMethod, f64)>,
43    /// Probability of early payment discount being taken
44    pub early_payment_discount_rate: f64,
45    /// Payment behavior configuration
46    pub payment_behavior: P2PPaymentBehavior,
47}
48
49/// Payment behavior configuration for P2P.
50#[derive(Debug, Clone)]
51pub struct P2PPaymentBehavior {
52    /// Rate of late payments (beyond due date)
53    pub late_payment_rate: f64,
54    /// Distribution of late payment days
55    pub late_payment_distribution: LatePaymentDistribution,
56    /// Rate of partial payments
57    pub partial_payment_rate: f64,
58    /// Rate of payment corrections
59    pub payment_correction_rate: f64,
60}
61
62impl Default for P2PPaymentBehavior {
63    fn default() -> Self {
64        Self {
65            late_payment_rate: 0.15,
66            late_payment_distribution: LatePaymentDistribution::default(),
67            partial_payment_rate: 0.05,
68            payment_correction_rate: 0.02,
69        }
70    }
71}
72
73/// Distribution of late payment days.
74#[derive(Debug, Clone)]
75pub struct LatePaymentDistribution {
76    /// 1-7 days late
77    pub slightly_late_1_to_7: f64,
78    /// 8-14 days late
79    pub late_8_to_14: f64,
80    /// 15-30 days late
81    pub very_late_15_to_30: f64,
82    /// 31-60 days late
83    pub severely_late_31_to_60: f64,
84    /// Over 60 days late
85    pub extremely_late_over_60: f64,
86}
87
88impl Default for LatePaymentDistribution {
89    fn default() -> Self {
90        Self {
91            slightly_late_1_to_7: 0.50,
92            late_8_to_14: 0.25,
93            very_late_15_to_30: 0.15,
94            severely_late_31_to_60: 0.07,
95            extremely_late_over_60: 0.03,
96        }
97    }
98}
99
100impl Default for P2PGeneratorConfig {
101    fn default() -> Self {
102        Self {
103            three_way_match_rate: 0.95,
104            partial_delivery_rate: 0.10,
105            over_delivery_rate: 0.02,
106            price_variance_rate: 0.05,
107            max_price_variance_percent: 0.05,
108            avg_days_po_to_gr: 7,
109            avg_days_gr_to_invoice: 5,
110            avg_days_invoice_to_payment: 30,
111            payment_method_distribution: vec![
112                (PaymentMethod::BankTransfer, 0.60),
113                (PaymentMethod::Check, 0.25),
114                (PaymentMethod::Wire, 0.10),
115                (PaymentMethod::CreditCard, 0.05),
116            ],
117            early_payment_discount_rate: 0.30,
118            payment_behavior: P2PPaymentBehavior::default(),
119        }
120    }
121}
122
123/// A complete P2P document chain.
124#[derive(Debug, Clone)]
125pub struct P2PDocumentChain {
126    /// Purchase Order
127    pub purchase_order: PurchaseOrder,
128    /// Goods Receipts (may be multiple for partial deliveries)
129    pub goods_receipts: Vec<GoodsReceipt>,
130    /// Vendor Invoice
131    pub vendor_invoice: Option<VendorInvoice>,
132    /// Payment
133    pub payment: Option<Payment>,
134    /// Chain completion status
135    pub is_complete: bool,
136    /// Three-way match status
137    pub three_way_match_passed: bool,
138    /// Payment timing information
139    pub payment_timing: Option<PaymentTimingInfo>,
140}
141
142/// Information about payment timing.
143#[derive(Debug, Clone)]
144pub struct PaymentTimingInfo {
145    /// Invoice due date
146    pub due_date: NaiveDate,
147    /// Actual payment date
148    pub payment_date: NaiveDate,
149    /// Days late (0 if on time or early)
150    pub days_late: i32,
151    /// Whether payment was late
152    pub is_late: bool,
153    /// Whether early payment discount was taken
154    pub discount_taken: bool,
155}
156
157/// Generator for P2P document flows.
158pub struct P2PGenerator {
159    rng: ChaCha8Rng,
160    seed: u64,
161    config: P2PGeneratorConfig,
162    po_counter: usize,
163    gr_counter: usize,
164    vi_counter: usize,
165    pay_counter: usize,
166    three_way_matcher: ThreeWayMatcher,
167}
168
169impl P2PGenerator {
170    /// Create a new P2P generator.
171    pub fn new(seed: u64) -> Self {
172        Self::with_config(seed, P2PGeneratorConfig::default())
173    }
174
175    /// Create a new P2P generator with custom configuration.
176    pub fn with_config(seed: u64, config: P2PGeneratorConfig) -> Self {
177        Self {
178            rng: ChaCha8Rng::seed_from_u64(seed),
179            seed,
180            config,
181            po_counter: 0,
182            gr_counter: 0,
183            vi_counter: 0,
184            pay_counter: 0,
185            three_way_matcher: ThreeWayMatcher::new(),
186        }
187    }
188
189    /// Generate a complete P2P chain.
190    pub fn generate_chain(
191        &mut self,
192        company_code: &str,
193        vendor: &Vendor,
194        materials: &[&Material],
195        po_date: NaiveDate,
196        fiscal_year: u16,
197        fiscal_period: u8,
198        created_by: &str,
199    ) -> P2PDocumentChain {
200        // Generate PO
201        let po = self.generate_purchase_order(
202            company_code,
203            vendor,
204            materials,
205            po_date,
206            fiscal_year,
207            fiscal_period,
208            created_by,
209        );
210
211        // Calculate GR date
212        let gr_date = self.calculate_gr_date(po_date);
213        let gr_fiscal_period = self.get_fiscal_period(gr_date);
214
215        // Generate GR(s)
216        let goods_receipts = self.generate_goods_receipts(
217            &po,
218            company_code,
219            gr_date,
220            fiscal_year,
221            gr_fiscal_period,
222            created_by,
223        );
224
225        // Calculate invoice date
226        let invoice_date = self.calculate_invoice_date(gr_date);
227        let invoice_fiscal_period = self.get_fiscal_period(invoice_date);
228
229        // Determine if we should introduce variances based on configuration
230        // This simulates real-world scenarios where not all invoices match perfectly
231        let should_have_variance = self.rng.gen::<f64>() >= self.config.three_way_match_rate;
232
233        // Generate invoice (may introduce variances based on config)
234        let vendor_invoice = self.generate_vendor_invoice(
235            &po,
236            &goods_receipts,
237            company_code,
238            vendor,
239            invoice_date,
240            fiscal_year,
241            invoice_fiscal_period,
242            created_by,
243            !should_have_variance, // Pass whether this should be a clean match
244        );
245
246        // Perform actual three-way match validation
247        let three_way_match_passed = if let Some(ref invoice) = vendor_invoice {
248            let gr_refs: Vec<&GoodsReceipt> = goods_receipts.iter().collect();
249            let match_result = self.three_way_matcher.validate(&po, &gr_refs, invoice);
250            match_result.passed
251        } else {
252            false
253        };
254
255        // Calculate payment date based on payment terms
256        let payment_date = self.calculate_payment_date(invoice_date, &vendor.payment_terms);
257        let payment_fiscal_period = self.get_fiscal_period(payment_date);
258
259        // Calculate due date for timing info
260        let due_date = self.calculate_due_date(invoice_date, &vendor.payment_terms);
261
262        // Generate payment
263        let payment = vendor_invoice.as_ref().map(|invoice| {
264            self.generate_payment(
265                invoice,
266                company_code,
267                vendor,
268                payment_date,
269                fiscal_year,
270                payment_fiscal_period,
271                created_by,
272            )
273        });
274
275        let is_complete = payment.is_some();
276
277        // Calculate payment timing information
278        let payment_timing = if payment.is_some() {
279            let days_diff = (payment_date - due_date).num_days() as i32;
280            let is_late = days_diff > 0;
281            let discount_taken = payment
282                .as_ref()
283                .map(|p| {
284                    p.allocations
285                        .iter()
286                        .any(|a| a.discount_taken > Decimal::ZERO)
287                })
288                .unwrap_or(false);
289
290            Some(PaymentTimingInfo {
291                due_date,
292                payment_date,
293                days_late: days_diff.max(0),
294                is_late,
295                discount_taken,
296            })
297        } else {
298            None
299        };
300
301        P2PDocumentChain {
302            purchase_order: po,
303            goods_receipts,
304            vendor_invoice,
305            payment,
306            is_complete,
307            three_way_match_passed,
308            payment_timing,
309        }
310    }
311
312    /// Generate a purchase order.
313    pub fn generate_purchase_order(
314        &mut self,
315        company_code: &str,
316        vendor: &Vendor,
317        materials: &[&Material],
318        po_date: NaiveDate,
319        fiscal_year: u16,
320        fiscal_period: u8,
321        created_by: &str,
322    ) -> PurchaseOrder {
323        self.po_counter += 1;
324
325        let po_id = format!("PO-{}-{:010}", company_code, self.po_counter);
326
327        let mut po = PurchaseOrder::new(
328            po_id,
329            company_code,
330            &vendor.vendor_id,
331            fiscal_year,
332            fiscal_period,
333            po_date,
334            created_by,
335        )
336        .with_payment_terms(vendor.payment_terms.code());
337
338        // Add line items
339        for (idx, material) in materials.iter().enumerate() {
340            let quantity = Decimal::from(self.rng.gen_range(1..100));
341            let unit_price = material.standard_cost;
342
343            let item = PurchaseOrderItem::new(
344                (idx + 1) as u16 * 10,
345                &material.description,
346                quantity,
347                unit_price,
348            )
349            .with_material(&material.material_id);
350
351            po.add_item(item);
352        }
353
354        // Release the PO
355        po.release(created_by);
356
357        po
358    }
359
360    /// Generate goods receipt(s) for a PO.
361    fn generate_goods_receipts(
362        &mut self,
363        po: &PurchaseOrder,
364        company_code: &str,
365        gr_date: NaiveDate,
366        fiscal_year: u16,
367        fiscal_period: u8,
368        created_by: &str,
369    ) -> Vec<GoodsReceipt> {
370        let mut receipts = Vec::new();
371
372        // Determine if partial delivery
373        let is_partial = self.rng.gen::<f64>() < self.config.partial_delivery_rate;
374
375        if is_partial {
376            // First partial delivery (60-80% of quantity)
377            let first_pct = 0.6 + self.rng.gen::<f64>() * 0.2;
378            let gr1 = self.create_goods_receipt(
379                po,
380                company_code,
381                gr_date,
382                fiscal_year,
383                fiscal_period,
384                created_by,
385                first_pct,
386            );
387            receipts.push(gr1);
388
389            // Second delivery (remaining quantity)
390            let second_date = gr_date + chrono::Duration::days(self.rng.gen_range(3..10) as i64);
391            let second_period = self.get_fiscal_period(second_date);
392            let gr2 = self.create_goods_receipt(
393                po,
394                company_code,
395                second_date,
396                fiscal_year,
397                second_period,
398                created_by,
399                1.0 - first_pct,
400            );
401            receipts.push(gr2);
402        } else {
403            // Full delivery
404            let delivery_pct = if self.rng.gen::<f64>() < self.config.over_delivery_rate {
405                1.0 + self.rng.gen::<f64>() * 0.1 // Up to 10% over
406            } else {
407                1.0
408            };
409
410            let gr = self.create_goods_receipt(
411                po,
412                company_code,
413                gr_date,
414                fiscal_year,
415                fiscal_period,
416                created_by,
417                delivery_pct,
418            );
419            receipts.push(gr);
420        }
421
422        receipts
423    }
424
425    /// Create a single goods receipt.
426    fn create_goods_receipt(
427        &mut self,
428        po: &PurchaseOrder,
429        company_code: &str,
430        gr_date: NaiveDate,
431        fiscal_year: u16,
432        fiscal_period: u8,
433        created_by: &str,
434        quantity_pct: f64,
435    ) -> GoodsReceipt {
436        self.gr_counter += 1;
437
438        let gr_id = format!("GR-{}-{:010}", company_code, self.gr_counter);
439
440        let mut gr = GoodsReceipt::from_purchase_order(
441            gr_id,
442            company_code,
443            &po.header.document_id,
444            &po.vendor_id,
445            format!("P{}", company_code),
446            "0001",
447            fiscal_year,
448            fiscal_period,
449            gr_date,
450            created_by,
451        );
452
453        // Add items based on PO items
454        for po_item in &po.items {
455            let received_qty = (po_item.base.quantity
456                * Decimal::from_f64_retain(quantity_pct).unwrap_or(Decimal::ONE))
457            .round_dp(0);
458
459            if received_qty > Decimal::ZERO {
460                let gr_item = GoodsReceiptItem::from_po(
461                    po_item.base.line_number,
462                    &po_item.base.description,
463                    received_qty,
464                    po_item.base.unit_price,
465                    &po.header.document_id,
466                    po_item.base.line_number,
467                )
468                .with_movement_type(MovementType::GrForPo);
469
470                gr.add_item(gr_item);
471            }
472        }
473
474        // Post the GR
475        gr.post(created_by, gr_date);
476
477        gr
478    }
479
480    /// Generate vendor invoice.
481    fn generate_vendor_invoice(
482        &mut self,
483        po: &PurchaseOrder,
484        goods_receipts: &[GoodsReceipt],
485        company_code: &str,
486        vendor: &Vendor,
487        invoice_date: NaiveDate,
488        fiscal_year: u16,
489        fiscal_period: u8,
490        created_by: &str,
491        three_way_match_passed: bool,
492    ) -> Option<VendorInvoice> {
493        if goods_receipts.is_empty() {
494            return None;
495        }
496
497        self.vi_counter += 1;
498
499        let invoice_id = format!("VI-{}-{:010}", company_code, self.vi_counter);
500        let vendor_invoice_number = format!("INV-{:08}", self.rng.gen_range(10000000..99999999));
501
502        // Calculate due date based on payment terms
503        let _due_date = self.calculate_due_date(invoice_date, &vendor.payment_terms);
504
505        let net_days = vendor.payment_terms.net_days() as i64;
506
507        let mut invoice = VendorInvoice::new(
508            invoice_id,
509            company_code,
510            &vendor.vendor_id,
511            vendor_invoice_number,
512            fiscal_year,
513            fiscal_period,
514            invoice_date,
515            created_by,
516        )
517        .with_payment_terms(vendor.payment_terms.code(), net_days);
518
519        // Apply cash discount if payment terms have one
520        if let (Some(discount_days), Some(discount_percent)) = (
521            vendor.payment_terms.discount_days(),
522            vendor.payment_terms.discount_percent(),
523        ) {
524            invoice = invoice.with_cash_discount(discount_percent, discount_days as i64);
525        }
526
527        // Calculate total received quantity per item
528        let mut received_quantities: std::collections::HashMap<u16, Decimal> =
529            std::collections::HashMap::new();
530
531        for gr in goods_receipts {
532            for gr_item in &gr.items {
533                *received_quantities
534                    .entry(gr_item.base.line_number)
535                    .or_insert(Decimal::ZERO) += gr_item.base.quantity;
536            }
537        }
538
539        // Add invoice items based on received quantities
540        for po_item in &po.items {
541            if let Some(&qty) = received_quantities.get(&po_item.base.line_number) {
542                // Apply price variance if configured
543                let unit_price = if !three_way_match_passed
544                    && self.rng.gen::<f64>() < self.config.price_variance_rate
545                {
546                    let variance = Decimal::from_f64_retain(
547                        1.0 + (self.rng.gen::<f64>() - 0.5)
548                            * 2.0
549                            * self.config.max_price_variance_percent,
550                    )
551                    .unwrap_or(Decimal::ONE);
552                    (po_item.base.unit_price * variance).round_dp(2)
553                } else {
554                    po_item.base.unit_price
555                };
556
557                let item = VendorInvoiceItem::from_po_gr(
558                    po_item.base.line_number,
559                    &po_item.base.description,
560                    qty,
561                    unit_price,
562                    &po.header.document_id,
563                    po_item.base.line_number,
564                    goods_receipts
565                        .first()
566                        .map(|gr| gr.header.document_id.clone()),
567                    Some(po_item.base.line_number),
568                );
569
570                invoice.add_item(item);
571            }
572        }
573
574        // Link to PO
575        invoice.header.add_reference(DocumentReference::new(
576            DocumentType::PurchaseOrder,
577            &po.header.document_id,
578            DocumentType::VendorInvoice,
579            &invoice.header.document_id,
580            ReferenceType::FollowOn,
581            company_code,
582            invoice_date,
583        ));
584
585        // Link to GRs
586        for gr in goods_receipts {
587            invoice.header.add_reference(DocumentReference::new(
588                DocumentType::GoodsReceipt,
589                &gr.header.document_id,
590                DocumentType::VendorInvoice,
591                &invoice.header.document_id,
592                ReferenceType::FollowOn,
593                company_code,
594                invoice_date,
595            ));
596        }
597
598        // Verify three-way match
599        if three_way_match_passed {
600            invoice.verify(true);
601        }
602
603        // Post the invoice
604        invoice.post(created_by, invoice_date);
605
606        Some(invoice)
607    }
608
609    /// Generate payment for an invoice.
610    fn generate_payment(
611        &mut self,
612        invoice: &VendorInvoice,
613        company_code: &str,
614        vendor: &Vendor,
615        payment_date: NaiveDate,
616        fiscal_year: u16,
617        fiscal_period: u8,
618        created_by: &str,
619    ) -> Payment {
620        self.pay_counter += 1;
621
622        let payment_id = format!("PAY-{}-{:010}", company_code, self.pay_counter);
623
624        // Determine if early payment discount applies
625        let take_discount = invoice.discount_due_date.is_some_and(|disc_date| {
626            payment_date <= disc_date
627                && self.rng.gen::<f64>() < self.config.early_payment_discount_rate
628        });
629
630        let discount_amount = if take_discount {
631            invoice.cash_discount_amount
632        } else {
633            Decimal::ZERO
634        };
635
636        let payment_amount = invoice.payable_amount - discount_amount;
637
638        let mut payment = Payment::new_ap_payment(
639            payment_id,
640            company_code,
641            &vendor.vendor_id,
642            payment_amount,
643            fiscal_year,
644            fiscal_period,
645            payment_date,
646            created_by,
647        )
648        .with_payment_method(self.select_payment_method())
649        .with_value_date(payment_date + chrono::Duration::days(1));
650
651        // Allocate to invoice
652        payment.allocate_to_invoice(
653            &invoice.header.document_id,
654            DocumentType::VendorInvoice,
655            payment_amount,
656            discount_amount,
657        );
658
659        // Add document reference linking payment to invoice
660        payment.header.add_reference(DocumentReference::new(
661            DocumentType::ApPayment,
662            &payment.header.document_id,
663            DocumentType::VendorInvoice,
664            &invoice.header.document_id,
665            ReferenceType::Payment,
666            &payment.header.company_code,
667            payment_date,
668        ));
669
670        // Approve and send to bank
671        payment.approve(created_by);
672        payment.send_to_bank(created_by);
673
674        // Post the payment
675        payment.post(created_by, payment_date);
676
677        payment
678    }
679
680    /// Generate multiple P2P chains.
681    pub fn generate_chains(
682        &mut self,
683        count: usize,
684        company_code: &str,
685        vendors: &VendorPool,
686        materials: &MaterialPool,
687        date_range: (NaiveDate, NaiveDate),
688        fiscal_year: u16,
689        created_by: &str,
690    ) -> Vec<P2PDocumentChain> {
691        let mut chains = Vec::new();
692
693        let (start_date, end_date) = date_range;
694        let days_range = (end_date - start_date).num_days() as u64;
695
696        for _ in 0..count {
697            // Select random vendor
698            let vendor_idx = self.rng.gen_range(0..vendors.vendors.len());
699            let vendor = &vendors.vendors[vendor_idx];
700
701            // Select random materials (1-5 items per PO)
702            let num_items = self.rng.gen_range(1..=5).min(materials.materials.len());
703            let selected_materials: Vec<&Material> = materials
704                .materials
705                .iter()
706                .choose_multiple(&mut self.rng, num_items)
707                .into_iter()
708                .collect();
709
710            // Select random PO date
711            let po_date =
712                start_date + chrono::Duration::days(self.rng.gen_range(0..=days_range) as i64);
713            let fiscal_period = self.get_fiscal_period(po_date);
714
715            let chain = self.generate_chain(
716                company_code,
717                vendor,
718                &selected_materials,
719                po_date,
720                fiscal_year,
721                fiscal_period,
722                created_by,
723            );
724
725            chains.push(chain);
726        }
727
728        chains
729    }
730
731    /// Calculate GR date based on PO date.
732    fn calculate_gr_date(&mut self, po_date: NaiveDate) -> NaiveDate {
733        let variance = self.rng.gen_range(0..5) as i64;
734        po_date + chrono::Duration::days(self.config.avg_days_po_to_gr as i64 + variance)
735    }
736
737    /// Calculate invoice date based on GR date.
738    fn calculate_invoice_date(&mut self, gr_date: NaiveDate) -> NaiveDate {
739        let variance = self.rng.gen_range(0..3) as i64;
740        gr_date + chrono::Duration::days(self.config.avg_days_gr_to_invoice as i64 + variance)
741    }
742
743    /// Calculate payment date based on invoice date and payment terms.
744    fn calculate_payment_date(
745        &mut self,
746        invoice_date: NaiveDate,
747        payment_terms: &PaymentTerms,
748    ) -> NaiveDate {
749        let due_days = payment_terms.net_days() as i64;
750        let due_date = invoice_date + chrono::Duration::days(due_days);
751
752        // Determine if this is a late payment
753        if self.rng.gen::<f64>() < self.config.payment_behavior.late_payment_rate {
754            // Calculate late days based on distribution
755            let late_days = self.calculate_late_days();
756            due_date + chrono::Duration::days(late_days as i64)
757        } else {
758            // On-time or slightly early payment (-5 to +5 days variance)
759            let variance = self.rng.gen_range(-5..=5) as i64;
760            due_date + chrono::Duration::days(variance)
761        }
762    }
763
764    /// Calculate late payment days based on the distribution.
765    fn calculate_late_days(&mut self) -> u32 {
766        let roll: f64 = self.rng.gen();
767        let dist = &self.config.payment_behavior.late_payment_distribution;
768
769        let mut cumulative = 0.0;
770
771        cumulative += dist.slightly_late_1_to_7;
772        if roll < cumulative {
773            return self.rng.gen_range(1..=7);
774        }
775
776        cumulative += dist.late_8_to_14;
777        if roll < cumulative {
778            return self.rng.gen_range(8..=14);
779        }
780
781        cumulative += dist.very_late_15_to_30;
782        if roll < cumulative {
783            return self.rng.gen_range(15..=30);
784        }
785
786        cumulative += dist.severely_late_31_to_60;
787        if roll < cumulative {
788            return self.rng.gen_range(31..=60);
789        }
790
791        // Extremely late: 61-120 days
792        self.rng.gen_range(61..=120)
793    }
794
795    /// Calculate due date based on payment terms.
796    fn calculate_due_date(
797        &self,
798        invoice_date: NaiveDate,
799        payment_terms: &PaymentTerms,
800    ) -> NaiveDate {
801        invoice_date + chrono::Duration::days(payment_terms.net_days() as i64)
802    }
803
804    /// Select payment method based on distribution.
805    fn select_payment_method(&mut self) -> PaymentMethod {
806        let roll: f64 = self.rng.gen();
807        let mut cumulative = 0.0;
808
809        for (method, prob) in &self.config.payment_method_distribution {
810            cumulative += prob;
811            if roll < cumulative {
812                return *method;
813            }
814        }
815
816        PaymentMethod::BankTransfer
817    }
818
819    /// Get fiscal period from date (simple month-based).
820    fn get_fiscal_period(&self, date: NaiveDate) -> u8 {
821        date.month() as u8
822    }
823
824    /// Reset the generator.
825    pub fn reset(&mut self) {
826        self.rng = ChaCha8Rng::seed_from_u64(self.seed);
827        self.po_counter = 0;
828        self.gr_counter = 0;
829        self.vi_counter = 0;
830        self.pay_counter = 0;
831    }
832}
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837    use datasynth_core::models::documents::DocumentStatus;
838    use datasynth_core::models::MaterialType;
839
840    fn create_test_vendor() -> Vendor {
841        Vendor::new(
842            "V-000001",
843            "Test Vendor Inc.",
844            datasynth_core::models::VendorType::Supplier,
845        )
846    }
847
848    fn create_test_materials() -> Vec<Material> {
849        vec![
850            Material::new("MAT-001", "Test Material 1", MaterialType::RawMaterial)
851                .with_standard_cost(Decimal::from(100)),
852            Material::new("MAT-002", "Test Material 2", MaterialType::RawMaterial)
853                .with_standard_cost(Decimal::from(50)),
854        ]
855    }
856
857    #[test]
858    fn test_p2p_chain_generation() {
859        let mut gen = P2PGenerator::new(42);
860        let vendor = create_test_vendor();
861        let materials = create_test_materials();
862        let material_refs: Vec<&Material> = materials.iter().collect();
863
864        let chain = gen.generate_chain(
865            "1000",
866            &vendor,
867            &material_refs,
868            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
869            2024,
870            1,
871            "JSMITH",
872        );
873
874        assert!(!chain.purchase_order.items.is_empty());
875        assert!(!chain.goods_receipts.is_empty());
876        assert!(chain.vendor_invoice.is_some());
877        assert!(chain.payment.is_some());
878        assert!(chain.is_complete);
879    }
880
881    #[test]
882    fn test_purchase_order_generation() {
883        let mut gen = P2PGenerator::new(42);
884        let vendor = create_test_vendor();
885        let materials = create_test_materials();
886        let material_refs: Vec<&Material> = materials.iter().collect();
887
888        let po = gen.generate_purchase_order(
889            "1000",
890            &vendor,
891            &material_refs,
892            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
893            2024,
894            1,
895            "JSMITH",
896        );
897
898        assert_eq!(po.vendor_id, "V-000001");
899        assert_eq!(po.items.len(), 2);
900        assert!(po.total_net_amount > Decimal::ZERO);
901        assert_eq!(po.header.status, DocumentStatus::Released);
902    }
903
904    #[test]
905    fn test_document_references() {
906        let mut gen = P2PGenerator::new(42);
907        let vendor = create_test_vendor();
908        let materials = create_test_materials();
909        let material_refs: Vec<&Material> = materials.iter().collect();
910
911        let chain = gen.generate_chain(
912            "1000",
913            &vendor,
914            &material_refs,
915            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
916            2024,
917            1,
918            "JSMITH",
919        );
920
921        // GR should reference PO
922        let gr = &chain.goods_receipts[0];
923        assert!(!gr.header.document_references.is_empty());
924
925        // Invoice should reference PO and GR
926        if let Some(invoice) = &chain.vendor_invoice {
927            assert!(invoice.header.document_references.len() >= 2);
928        }
929    }
930
931    #[test]
932    fn test_deterministic_generation() {
933        let vendor = create_test_vendor();
934        let materials = create_test_materials();
935        let material_refs: Vec<&Material> = materials.iter().collect();
936
937        let mut gen1 = P2PGenerator::new(42);
938        let mut gen2 = P2PGenerator::new(42);
939
940        let chain1 = gen1.generate_chain(
941            "1000",
942            &vendor,
943            &material_refs,
944            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
945            2024,
946            1,
947            "JSMITH",
948        );
949        let chain2 = gen2.generate_chain(
950            "1000",
951            &vendor,
952            &material_refs,
953            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
954            2024,
955            1,
956            "JSMITH",
957        );
958
959        assert_eq!(
960            chain1.purchase_order.header.document_id,
961            chain2.purchase_order.header.document_id
962        );
963        assert_eq!(
964            chain1.purchase_order.total_net_amount,
965            chain2.purchase_order.total_net_amount
966        );
967    }
968
969    #[test]
970    fn test_partial_delivery_config() {
971        let config = P2PGeneratorConfig {
972            partial_delivery_rate: 1.0, // Force partial delivery
973            ..Default::default()
974        };
975
976        let mut gen = P2PGenerator::with_config(42, config);
977        let vendor = create_test_vendor();
978        let materials = create_test_materials();
979        let material_refs: Vec<&Material> = materials.iter().collect();
980
981        let chain = gen.generate_chain(
982            "1000",
983            &vendor,
984            &material_refs,
985            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
986            2024,
987            1,
988            "JSMITH",
989        );
990
991        // Should have multiple goods receipts due to partial delivery
992        assert!(chain.goods_receipts.len() >= 2);
993    }
994}