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