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::distributions::TemporalContext;
8use datasynth_core::utils::seeded_rng;
9use rand::prelude::*;
10use rand_chacha::ChaCha8Rng;
11use rust_decimal::Decimal;
12use std::sync::Arc;
13
14use datasynth_core::models::{
15    documents::{
16        DocumentReference, DocumentType, GoodsReceipt, GoodsReceiptItem, MovementType, Payment,
17        PaymentMethod, PurchaseOrder, PurchaseOrderItem, ReferenceType, VendorInvoice,
18        VendorInvoiceItem,
19    },
20    Material, MaterialPool, PaymentTerms, Vendor, VendorPool,
21};
22use datasynth_core::CountryPack;
23
24use super::three_way_match::ThreeWayMatcher;
25
26/// Configuration for P2P flow generation.
27#[derive(Debug, Clone)]
28pub struct P2PGeneratorConfig {
29    /// Three-way match success rate (PO-GR-Invoice match)
30    pub three_way_match_rate: f64,
31    /// Rate of partial deliveries
32    pub partial_delivery_rate: f64,
33    /// Rate of over-delivery (quantity exceeds PO)
34    pub over_delivery_rate: f64,
35    /// Rate of price variance (invoice price differs from PO)
36    pub price_variance_rate: f64,
37    /// Max price variance percentage
38    pub max_price_variance_percent: f64,
39    /// Average days between PO and GR
40    pub avg_days_po_to_gr: u32,
41    /// Average days between GR and Invoice
42    pub avg_days_gr_to_invoice: u32,
43    /// Average days between Invoice and Payment
44    pub avg_days_invoice_to_payment: u32,
45    /// Payment method distribution
46    pub payment_method_distribution: Vec<(PaymentMethod, f64)>,
47    /// Probability of early payment discount being taken
48    pub early_payment_discount_rate: f64,
49    /// Payment behavior configuration
50    pub payment_behavior: P2PPaymentBehavior,
51}
52
53/// Payment behavior configuration for P2P.
54#[derive(Debug, Clone)]
55pub struct P2PPaymentBehavior {
56    /// Rate of late payments (beyond due date)
57    pub late_payment_rate: f64,
58    /// Distribution of late payment days
59    pub late_payment_distribution: LatePaymentDistribution,
60    /// Rate of partial payments
61    pub partial_payment_rate: f64,
62    /// Rate of payment corrections
63    pub payment_correction_rate: f64,
64    /// Average days until partial payment remainder is paid
65    pub avg_days_until_remainder: u32,
66}
67
68impl Default for P2PPaymentBehavior {
69    fn default() -> Self {
70        Self {
71            late_payment_rate: 0.15,
72            late_payment_distribution: LatePaymentDistribution::default(),
73            partial_payment_rate: 0.05,
74            payment_correction_rate: 0.02,
75            avg_days_until_remainder: 30,
76        }
77    }
78}
79
80/// Distribution of late payment days.
81#[derive(Debug, Clone)]
82pub struct LatePaymentDistribution {
83    /// 1-7 days late
84    pub slightly_late_1_to_7: f64,
85    /// 8-14 days late
86    pub late_8_to_14: f64,
87    /// 15-30 days late
88    pub very_late_15_to_30: f64,
89    /// 31-60 days late
90    pub severely_late_31_to_60: f64,
91    /// Over 60 days late
92    pub extremely_late_over_60: f64,
93}
94
95impl Default for LatePaymentDistribution {
96    fn default() -> Self {
97        Self {
98            slightly_late_1_to_7: 0.50,
99            late_8_to_14: 0.25,
100            very_late_15_to_30: 0.15,
101            severely_late_31_to_60: 0.07,
102            extremely_late_over_60: 0.03,
103        }
104    }
105}
106
107impl Default for P2PGeneratorConfig {
108    fn default() -> Self {
109        Self {
110            three_way_match_rate: 0.95,
111            partial_delivery_rate: 0.10,
112            over_delivery_rate: 0.02,
113            price_variance_rate: 0.05,
114            max_price_variance_percent: 0.05,
115            avg_days_po_to_gr: 7,
116            avg_days_gr_to_invoice: 5,
117            avg_days_invoice_to_payment: 30,
118            payment_method_distribution: vec![
119                (PaymentMethod::BankTransfer, 0.60),
120                (PaymentMethod::Check, 0.25),
121                (PaymentMethod::Wire, 0.10),
122                (PaymentMethod::CreditCard, 0.05),
123            ],
124            early_payment_discount_rate: 0.30,
125            payment_behavior: P2PPaymentBehavior::default(),
126        }
127    }
128}
129
130/// A complete P2P document chain.
131#[derive(Debug, Clone)]
132pub struct P2PDocumentChain {
133    /// Purchase Order
134    pub purchase_order: PurchaseOrder,
135    /// Goods Receipts (may be multiple for partial deliveries)
136    pub goods_receipts: Vec<GoodsReceipt>,
137    /// Vendor Invoice
138    pub vendor_invoice: Option<VendorInvoice>,
139    /// Payment
140    pub payment: Option<Payment>,
141    /// Remainder payments (follow-up to partial payments)
142    pub remainder_payments: Vec<Payment>,
143    /// Chain completion status
144    pub is_complete: bool,
145    /// Three-way match status
146    pub three_way_match_passed: bool,
147    /// Payment timing information
148    pub payment_timing: Option<PaymentTimingInfo>,
149}
150
151/// Information about payment timing.
152#[derive(Debug, Clone)]
153pub struct PaymentTimingInfo {
154    /// Invoice due date
155    pub due_date: NaiveDate,
156    /// Actual payment date
157    pub payment_date: NaiveDate,
158    /// Days late (0 if on time or early)
159    pub days_late: i32,
160    /// Whether payment was late
161    pub is_late: bool,
162    /// Whether early payment discount was taken
163    pub discount_taken: bool,
164}
165
166/// Generator for P2P document flows.
167pub struct P2PGenerator {
168    rng: ChaCha8Rng,
169    seed: u64,
170    config: P2PGeneratorConfig,
171    po_counter: usize,
172    gr_counter: usize,
173    vi_counter: usize,
174    pay_counter: usize,
175    three_way_matcher: ThreeWayMatcher,
176    country_pack: Option<CountryPack>,
177    /// v3.4.1+ temporal context for business-day-aware date selection.
178    /// `None` preserves the legacy raw-rng behavior (byte-identical).
179    temporal_context: Option<Arc<TemporalContext>>,
180}
181
182impl P2PGenerator {
183    /// Create a new P2P generator.
184    pub fn new(seed: u64) -> Self {
185        Self::with_config(seed, P2PGeneratorConfig::default())
186    }
187
188    /// Create a new P2P generator with custom configuration.
189    pub fn with_config(seed: u64, config: P2PGeneratorConfig) -> Self {
190        Self {
191            rng: seeded_rng(seed, 0),
192            seed,
193            config,
194            po_counter: 0,
195            gr_counter: 0,
196            vi_counter: 0,
197            pay_counter: 0,
198            three_way_matcher: ThreeWayMatcher::new(),
199            country_pack: None,
200            temporal_context: None,
201        }
202    }
203
204    /// Set the country pack for locale-aware document texts.
205    pub fn set_country_pack(&mut self, pack: CountryPack) {
206        self.country_pack = Some(pack);
207    }
208
209    /// Set the shared [`TemporalContext`] so PO/GR/invoice/payment dates
210    /// snap to business days. `None` restores pre-v3.4.1 raw-rng behavior
211    /// (byte-identical to v3.4.0 for the same seed).
212    pub fn set_temporal_context(&mut self, ctx: Arc<TemporalContext>) {
213        self.temporal_context = Some(ctx);
214    }
215
216    /// Build a document ID, preferring the country pack `reference_prefix` when set.
217    fn make_doc_id(
218        &self,
219        default_prefix: &str,
220        pack_key: &str,
221        company_code: &str,
222        counter: usize,
223    ) -> String {
224        let prefix = self
225            .country_pack
226            .as_ref()
227            .map(|p| {
228                let grp = match pack_key {
229                    "purchase_order" => &p.document_texts.purchase_order,
230                    "goods_receipt" => &p.document_texts.goods_receipt,
231                    "vendor_invoice" => &p.document_texts.vendor_invoice,
232                    "payment" => &p.document_texts.payment,
233                    _ => return default_prefix.to_string(),
234                };
235                if grp.reference_prefix.is_empty() {
236                    default_prefix.to_string()
237                } else {
238                    grp.reference_prefix.clone()
239                }
240            })
241            .unwrap_or_else(|| default_prefix.to_string());
242        format!("{prefix}-{company_code}-{counter:010}")
243    }
244
245    /// Pick a random line description from the country pack for the given
246    /// document type, falling back to the provided default.
247    fn pick_line_description(&mut self, pack_key: &str, default: &str) -> String {
248        if let Some(pack) = &self.country_pack {
249            let descriptions = match pack_key {
250                "purchase_order" => &pack.document_texts.purchase_order.line_descriptions,
251                "goods_receipt" => &pack.document_texts.goods_receipt.line_descriptions,
252                "vendor_invoice" => &pack.document_texts.vendor_invoice.line_descriptions,
253                "payment" => &pack.document_texts.payment.line_descriptions,
254                _ => return default.to_string(),
255            };
256            if !descriptions.is_empty() {
257                let idx = self.rng.random_range(0..descriptions.len());
258                return descriptions[idx].clone();
259            }
260        }
261        default.to_string()
262    }
263
264    /// Generate a complete P2P chain.
265    pub fn generate_chain(
266        &mut self,
267        company_code: &str,
268        vendor: &Vendor,
269        materials: &[&Material],
270        po_date: NaiveDate,
271        fiscal_year: u16,
272        fiscal_period: u8,
273        created_by: &str,
274    ) -> P2PDocumentChain {
275        // v3.4.1: snap the incoming po_date to a business day when a
276        // `TemporalContext` is configured. This protects callers like
277        // `enhanced_orchestrator` that compute `po_date` externally via
278        // raw `start_date + Duration::days(offset)` arithmetic.
279        let po_date = self.snap_to_business_day(po_date);
280        // Generate PO
281        let po = self.generate_purchase_order(
282            company_code,
283            vendor,
284            materials,
285            po_date,
286            fiscal_year,
287            fiscal_period,
288            created_by,
289        );
290
291        // Calculate GR date
292        let gr_date = self.calculate_gr_date(po_date);
293        let gr_fiscal_period = self.get_fiscal_period(gr_date);
294
295        // Generate GR(s)
296        let goods_receipts = self.generate_goods_receipts(
297            &po,
298            company_code,
299            gr_date,
300            fiscal_year,
301            gr_fiscal_period,
302            created_by,
303        );
304
305        // Calculate invoice date
306        let invoice_date = self.calculate_invoice_date(gr_date);
307        let invoice_fiscal_period = self.get_fiscal_period(invoice_date);
308
309        // Determine if we should introduce variances based on configuration
310        // This simulates real-world scenarios where not all invoices match perfectly
311        let should_have_variance = self.rng.random::<f64>() >= self.config.three_way_match_rate;
312
313        // Generate invoice (may introduce variances based on config)
314        let vendor_invoice = self.generate_vendor_invoice(
315            &po,
316            &goods_receipts,
317            company_code,
318            vendor,
319            invoice_date,
320            fiscal_year,
321            invoice_fiscal_period,
322            created_by,
323            !should_have_variance, // Pass whether this should be a clean match
324        );
325
326        // Perform actual three-way match validation
327        let three_way_match_passed = if let Some(ref invoice) = vendor_invoice {
328            let gr_refs: Vec<&GoodsReceipt> = goods_receipts.iter().collect();
329            let match_result = self.three_way_matcher.validate(&po, &gr_refs, invoice);
330            match_result.passed
331        } else {
332            false
333        };
334
335        // Calculate payment date based on payment terms
336        let payment_date = self.calculate_payment_date(invoice_date, &vendor.payment_terms);
337        let payment_fiscal_period = self.get_fiscal_period(payment_date);
338
339        // Calculate due date for timing info
340        let due_date = self.calculate_due_date(invoice_date, &vendor.payment_terms);
341
342        // Determine if this is a partial payment
343        let is_partial_payment =
344            self.rng.random::<f64>() < self.config.payment_behavior.partial_payment_rate;
345
346        // Generate payment (possibly partial)
347        let (payment, remainder_payments) = if let Some(ref invoice) = vendor_invoice {
348            if is_partial_payment {
349                // Partial payment: 50-75% of invoice amount
350                let partial_pct = 0.50 + self.rng.random::<f64>() * 0.25;
351                let partial_amount = (invoice.payable_amount
352                    * Decimal::from_f64_retain(partial_pct).unwrap_or(Decimal::ONE))
353                .round_dp(2);
354
355                let initial_payment = self.generate_payment_for_amount(
356                    invoice,
357                    company_code,
358                    vendor,
359                    payment_date,
360                    fiscal_year,
361                    payment_fiscal_period,
362                    created_by,
363                    partial_amount,
364                );
365
366                // Generate remainder payment
367                let remainder_amount = invoice.payable_amount - partial_amount;
368                let remainder_days_variance = self.rng.random_range(0..10) as i64;
369                let remainder_date = payment_date
370                    + chrono::Duration::days(
371                        self.config.payment_behavior.avg_days_until_remainder as i64
372                            + remainder_days_variance,
373                    );
374                let remainder_fiscal_period = self.get_fiscal_period(remainder_date);
375
376                let remainder_payment = self.generate_remainder_payment(
377                    invoice,
378                    company_code,
379                    vendor,
380                    remainder_date,
381                    fiscal_year,
382                    remainder_fiscal_period,
383                    created_by,
384                    remainder_amount,
385                    &initial_payment,
386                );
387
388                (Some(initial_payment), vec![remainder_payment])
389            } else {
390                // Full payment
391                let full_payment = self.generate_payment(
392                    invoice,
393                    company_code,
394                    vendor,
395                    payment_date,
396                    fiscal_year,
397                    payment_fiscal_period,
398                    created_by,
399                );
400                (Some(full_payment), Vec::new())
401            }
402        } else {
403            (None, Vec::new())
404        };
405
406        let is_complete = payment.is_some();
407
408        // Calculate payment timing information
409        let payment_timing = if payment.is_some() {
410            let days_diff = (payment_date - due_date).num_days() as i32;
411            let is_late = days_diff > 0;
412            let discount_taken = payment
413                .as_ref()
414                .map(|p| {
415                    p.allocations
416                        .iter()
417                        .any(|a| a.discount_taken > Decimal::ZERO)
418                })
419                .unwrap_or(false);
420
421            Some(PaymentTimingInfo {
422                due_date,
423                payment_date,
424                days_late: days_diff.max(0),
425                is_late,
426                discount_taken,
427            })
428        } else {
429            None
430        };
431
432        P2PDocumentChain {
433            purchase_order: po,
434            goods_receipts,
435            vendor_invoice,
436            payment,
437            remainder_payments,
438            is_complete,
439            three_way_match_passed,
440            payment_timing,
441        }
442    }
443
444    /// Generate a purchase order.
445    pub fn generate_purchase_order(
446        &mut self,
447        company_code: &str,
448        vendor: &Vendor,
449        materials: &[&Material],
450        po_date: NaiveDate,
451        fiscal_year: u16,
452        fiscal_period: u8,
453        created_by: &str,
454    ) -> PurchaseOrder {
455        self.po_counter += 1;
456
457        let po_id = self.make_doc_id("PO", "purchase_order", company_code, self.po_counter);
458
459        let mut po = PurchaseOrder::new(
460            po_id,
461            company_code,
462            &vendor.vendor_id,
463            fiscal_year,
464            fiscal_period,
465            po_date,
466            created_by,
467        )
468        .with_payment_terms(vendor.payment_terms.code());
469
470        // Denormalize vendor name (DS-011)
471        po.vendor_name = Some(vendor.name.clone());
472
473        // Add line items
474        for (idx, material) in materials.iter().enumerate() {
475            let quantity = Decimal::from(self.rng.random_range(1..100));
476            let unit_price = material.standard_cost;
477
478            let description = self.pick_line_description("purchase_order", &material.description);
479            let item =
480                PurchaseOrderItem::new((idx + 1) as u16 * 10, &description, quantity, unit_price)
481                    .with_material(&material.material_id);
482
483            po.add_item(item);
484        }
485
486        // Release the PO
487        po.release(created_by);
488
489        po
490    }
491
492    /// Generate goods receipt(s) for a PO.
493    fn generate_goods_receipts(
494        &mut self,
495        po: &PurchaseOrder,
496        company_code: &str,
497        gr_date: NaiveDate,
498        fiscal_year: u16,
499        fiscal_period: u8,
500        created_by: &str,
501    ) -> Vec<GoodsReceipt> {
502        let mut receipts = Vec::new();
503
504        // Determine if partial delivery
505        let is_partial = self.rng.random::<f64>() < self.config.partial_delivery_rate;
506
507        if is_partial {
508            // First partial delivery (60-80% of quantity)
509            let first_pct = 0.6 + self.rng.random::<f64>() * 0.2;
510            let gr1 = self.create_goods_receipt(
511                po,
512                company_code,
513                gr_date,
514                fiscal_year,
515                fiscal_period,
516                created_by,
517                first_pct,
518            );
519            receipts.push(gr1);
520
521            // Second delivery (remaining quantity)
522            let raw_second = gr_date + chrono::Duration::days(self.rng.random_range(3..10) as i64);
523            let second_date = self.snap_to_business_day(raw_second);
524            let second_period = self.get_fiscal_period(second_date);
525            let gr2 = self.create_goods_receipt(
526                po,
527                company_code,
528                second_date,
529                fiscal_year,
530                second_period,
531                created_by,
532                1.0 - first_pct,
533            );
534            receipts.push(gr2);
535        } else {
536            // Full delivery
537            let delivery_pct = if self.rng.random::<f64>() < self.config.over_delivery_rate {
538                1.0 + self.rng.random::<f64>() * 0.1 // Up to 10% over
539            } else {
540                1.0
541            };
542
543            let gr = self.create_goods_receipt(
544                po,
545                company_code,
546                gr_date,
547                fiscal_year,
548                fiscal_period,
549                created_by,
550                delivery_pct,
551            );
552            receipts.push(gr);
553        }
554
555        receipts
556    }
557
558    /// Create a single goods receipt.
559    fn create_goods_receipt(
560        &mut self,
561        po: &PurchaseOrder,
562        company_code: &str,
563        gr_date: NaiveDate,
564        fiscal_year: u16,
565        fiscal_period: u8,
566        created_by: &str,
567        quantity_pct: f64,
568    ) -> GoodsReceipt {
569        self.gr_counter += 1;
570
571        let gr_id = self.make_doc_id("GR", "goods_receipt", company_code, self.gr_counter);
572
573        let mut gr = GoodsReceipt::from_purchase_order(
574            gr_id,
575            company_code,
576            &po.header.document_id,
577            &po.vendor_id,
578            format!("P{company_code}"),
579            "0001",
580            fiscal_year,
581            fiscal_period,
582            gr_date,
583            created_by,
584        );
585
586        // Add items based on PO items
587        for po_item in &po.items {
588            let received_qty = (po_item.base.quantity
589                * Decimal::from_f64_retain(quantity_pct).unwrap_or(Decimal::ONE))
590            .round_dp(0);
591
592            if received_qty > Decimal::ZERO {
593                let description =
594                    self.pick_line_description("goods_receipt", &po_item.base.description);
595                let mut gr_item = GoodsReceiptItem::from_po(
596                    po_item.base.line_number,
597                    &description,
598                    received_qty,
599                    po_item.base.unit_price,
600                    &po.header.document_id,
601                    po_item.base.line_number,
602                )
603                .with_movement_type(MovementType::GrForPo);
604
605                // Carry material_id from PO item to GR item
606                if let Some(ref mat_id) = po_item.base.material_id {
607                    gr_item = gr_item.with_material(mat_id);
608                }
609
610                gr.add_item(gr_item);
611            }
612        }
613
614        // Post the GR
615        gr.post(created_by, gr_date);
616
617        gr
618    }
619
620    /// Generate vendor invoice.
621    fn generate_vendor_invoice(
622        &mut self,
623        po: &PurchaseOrder,
624        goods_receipts: &[GoodsReceipt],
625        company_code: &str,
626        vendor: &Vendor,
627        invoice_date: NaiveDate,
628        fiscal_year: u16,
629        fiscal_period: u8,
630        created_by: &str,
631        three_way_match_passed: bool,
632    ) -> Option<VendorInvoice> {
633        if goods_receipts.is_empty() {
634            return None;
635        }
636
637        self.vi_counter += 1;
638
639        let invoice_id = self.make_doc_id("VI", "vendor_invoice", company_code, self.vi_counter);
640        let vendor_invoice_number = format!("INV-{:08}", self.rng.random_range(10000000..99999999));
641
642        // Calculate due date based on payment terms
643        let _due_date = self.calculate_due_date(invoice_date, &vendor.payment_terms);
644
645        let net_days = vendor.payment_terms.net_days() as i64;
646
647        let mut invoice = VendorInvoice::new(
648            invoice_id,
649            company_code,
650            &vendor.vendor_id,
651            vendor_invoice_number,
652            fiscal_year,
653            fiscal_period,
654            invoice_date,
655            created_by,
656        )
657        .with_payment_terms(vendor.payment_terms.code(), net_days);
658
659        // Populate top-level FK fields (DS-GEP-004)
660        invoice.purchase_order_id = Some(po.header.document_id.clone());
661        invoice.goods_receipt_id = goods_receipts
662            .first()
663            .map(|gr| gr.header.document_id.clone());
664
665        // Denormalize vendor name (DS-011)
666        invoice.vendor_name = Some(vendor.name.clone());
667
668        // Apply cash discount if payment terms have one
669        if let (Some(discount_days), Some(discount_percent)) = (
670            vendor.payment_terms.discount_days(),
671            vendor.payment_terms.discount_percent(),
672        ) {
673            invoice = invoice.with_cash_discount(discount_percent, discount_days as i64);
674        }
675
676        // Calculate total received quantity per item
677        let mut received_quantities: std::collections::HashMap<u16, Decimal> =
678            std::collections::HashMap::new();
679
680        for gr in goods_receipts {
681            for gr_item in &gr.items {
682                *received_quantities
683                    .entry(gr_item.base.line_number)
684                    .or_insert(Decimal::ZERO) += gr_item.base.quantity;
685            }
686        }
687
688        // Add invoice items based on received quantities
689        for po_item in &po.items {
690            if let Some(&qty) = received_quantities.get(&po_item.base.line_number) {
691                // Apply price variance if configured
692                let unit_price = if !three_way_match_passed
693                    && self.rng.random::<f64>() < self.config.price_variance_rate
694                {
695                    let variance = Decimal::from_f64_retain(
696                        1.0 + (self.rng.random::<f64>() - 0.5)
697                            * 2.0
698                            * self.config.max_price_variance_percent,
699                    )
700                    .unwrap_or(Decimal::ONE);
701                    (po_item.base.unit_price * variance).round_dp(2)
702                } else {
703                    po_item.base.unit_price
704                };
705
706                let vi_description =
707                    self.pick_line_description("vendor_invoice", &po_item.base.description);
708                let item = VendorInvoiceItem::from_po_gr(
709                    po_item.base.line_number,
710                    &vi_description,
711                    qty,
712                    unit_price,
713                    &po.header.document_id,
714                    po_item.base.line_number,
715                    goods_receipts
716                        .first()
717                        .map(|gr| gr.header.document_id.clone()),
718                    Some(po_item.base.line_number),
719                );
720
721                invoice.add_item(item);
722            }
723        }
724
725        // Link to PO
726        invoice.header.add_reference(DocumentReference::new(
727            DocumentType::PurchaseOrder,
728            &po.header.document_id,
729            DocumentType::VendorInvoice,
730            &invoice.header.document_id,
731            ReferenceType::FollowOn,
732            company_code,
733            invoice_date,
734        ));
735
736        // Link to GRs
737        for gr in goods_receipts {
738            invoice.header.add_reference(DocumentReference::new(
739                DocumentType::GoodsReceipt,
740                &gr.header.document_id,
741                DocumentType::VendorInvoice,
742                &invoice.header.document_id,
743                ReferenceType::FollowOn,
744                company_code,
745                invoice_date,
746            ));
747        }
748
749        // Verify three-way match
750        if three_way_match_passed {
751            invoice.verify(true);
752        }
753
754        // Post the invoice
755        invoice.post(created_by, invoice_date);
756
757        Some(invoice)
758    }
759
760    /// Generate payment for an invoice.
761    fn generate_payment(
762        &mut self,
763        invoice: &VendorInvoice,
764        company_code: &str,
765        vendor: &Vendor,
766        payment_date: NaiveDate,
767        fiscal_year: u16,
768        fiscal_period: u8,
769        created_by: &str,
770    ) -> Payment {
771        self.pay_counter += 1;
772
773        let payment_id = self.make_doc_id("PAY", "payment", company_code, self.pay_counter);
774
775        // Determine if early payment discount applies
776        let take_discount = invoice.discount_due_date.is_some_and(|disc_date| {
777            payment_date <= disc_date
778                && self.rng.random::<f64>() < self.config.early_payment_discount_rate
779        });
780
781        let discount_amount = if take_discount {
782            invoice.cash_discount_amount
783        } else {
784            Decimal::ZERO
785        };
786
787        let payment_amount = invoice.payable_amount - discount_amount;
788
789        let mut payment = Payment::new_ap_payment(
790            payment_id,
791            company_code,
792            &vendor.vendor_id,
793            payment_amount,
794            fiscal_year,
795            fiscal_period,
796            payment_date,
797            created_by,
798        )
799        .with_payment_method(self.select_payment_method())
800        .with_value_date(payment_date + chrono::Duration::days(1));
801
802        // Allocate to invoice
803        payment.allocate_to_invoice(
804            &invoice.header.document_id,
805            DocumentType::VendorInvoice,
806            payment_amount,
807            discount_amount,
808        );
809
810        // Add document reference linking payment to invoice
811        payment.header.add_reference(DocumentReference::new(
812            DocumentType::ApPayment,
813            &payment.header.document_id,
814            DocumentType::VendorInvoice,
815            &invoice.header.document_id,
816            ReferenceType::Payment,
817            &payment.header.company_code,
818            payment_date,
819        ));
820
821        // Approve and send to bank
822        payment.approve(created_by);
823        payment.send_to_bank(created_by);
824
825        // Post the payment
826        payment.post(created_by, payment_date);
827
828        payment
829    }
830
831    /// Generate a payment for a specific amount (used for partial payments).
832    fn generate_payment_for_amount(
833        &mut self,
834        invoice: &VendorInvoice,
835        company_code: &str,
836        vendor: &Vendor,
837        payment_date: NaiveDate,
838        fiscal_year: u16,
839        fiscal_period: u8,
840        created_by: &str,
841        amount: Decimal,
842    ) -> Payment {
843        self.pay_counter += 1;
844
845        let payment_id = self.make_doc_id("PAY", "payment", company_code, self.pay_counter);
846
847        let mut payment = Payment::new_ap_payment(
848            payment_id,
849            company_code,
850            &vendor.vendor_id,
851            amount,
852            fiscal_year,
853            fiscal_period,
854            payment_date,
855            created_by,
856        )
857        .with_payment_method(self.select_payment_method())
858        .with_value_date(payment_date + chrono::Duration::days(1));
859
860        // Allocate to invoice (partial amount, no discount on partial)
861        payment.allocate_to_invoice(
862            &invoice.header.document_id,
863            DocumentType::VendorInvoice,
864            amount,
865            Decimal::ZERO,
866        );
867
868        // Add document reference linking payment to invoice
869        payment.header.add_reference(DocumentReference::new(
870            DocumentType::ApPayment,
871            &payment.header.document_id,
872            DocumentType::VendorInvoice,
873            &invoice.header.document_id,
874            ReferenceType::Payment,
875            &payment.header.company_code,
876            payment_date,
877        ));
878
879        // Approve and send to bank
880        payment.approve(created_by);
881        payment.send_to_bank(created_by);
882
883        // Post the payment
884        payment.post(created_by, payment_date);
885
886        payment
887    }
888
889    /// Generate a remainder payment for the balance after a partial payment.
890    fn generate_remainder_payment(
891        &mut self,
892        invoice: &VendorInvoice,
893        company_code: &str,
894        vendor: &Vendor,
895        payment_date: NaiveDate,
896        fiscal_year: u16,
897        fiscal_period: u8,
898        created_by: &str,
899        amount: Decimal,
900        initial_payment: &Payment,
901    ) -> Payment {
902        self.pay_counter += 1;
903
904        let payment_id = self.make_doc_id("PAY", "payment", company_code, self.pay_counter);
905
906        let mut payment = Payment::new_ap_payment(
907            payment_id,
908            company_code,
909            &vendor.vendor_id,
910            amount,
911            fiscal_year,
912            fiscal_period,
913            payment_date,
914            created_by,
915        )
916        .with_payment_method(self.select_payment_method())
917        .with_value_date(payment_date + chrono::Duration::days(1));
918
919        // Allocate remainder to the same invoice
920        payment.allocate_to_invoice(
921            &invoice.header.document_id,
922            DocumentType::VendorInvoice,
923            amount,
924            Decimal::ZERO,
925        );
926
927        // Add document reference linking remainder payment to invoice
928        payment.header.add_reference(DocumentReference::new(
929            DocumentType::ApPayment,
930            &payment.header.document_id,
931            DocumentType::VendorInvoice,
932            &invoice.header.document_id,
933            ReferenceType::Payment,
934            &payment.header.company_code,
935            payment_date,
936        ));
937
938        // Add document reference linking remainder payment to initial payment
939        payment.header.add_reference(DocumentReference::new(
940            DocumentType::ApPayment,
941            &payment.header.document_id,
942            DocumentType::ApPayment,
943            &initial_payment.header.document_id,
944            ReferenceType::FollowOn,
945            &payment.header.company_code,
946            payment_date,
947        ));
948
949        // Approve and send to bank
950        payment.approve(created_by);
951        payment.send_to_bank(created_by);
952
953        // Post the payment
954        payment.post(created_by, payment_date);
955
956        payment
957    }
958
959    /// Generate multiple P2P chains.
960    pub fn generate_chains(
961        &mut self,
962        count: usize,
963        company_code: &str,
964        vendors: &VendorPool,
965        materials: &MaterialPool,
966        date_range: (NaiveDate, NaiveDate),
967        fiscal_year: u16,
968        created_by: &str,
969    ) -> Vec<P2PDocumentChain> {
970        tracing::debug!(count, company_code, "Generating P2P document chains");
971        let mut chains = Vec::new();
972
973        let (start_date, end_date) = date_range;
974        let days_range = (end_date - start_date).num_days() as u64;
975
976        for _ in 0..count {
977            // Select random vendor
978            let vendor_idx = self.rng.random_range(0..vendors.vendors.len());
979            let vendor = &vendors.vendors[vendor_idx];
980
981            // Select random materials (1-5 items per PO)
982            let num_items = self.rng.random_range(1..=5).min(materials.materials.len());
983            let selected_materials: Vec<&Material> = materials
984                .materials
985                .iter()
986                .sample(&mut self.rng, num_items)
987                .into_iter()
988                .collect();
989
990            // Select random PO date. When a `TemporalContext` is present,
991            // snap the draw to the nearest business day so weekend/holiday
992            // postings don't leak through.
993            let raw_offset = self.rng.random_range(0..=days_range) as i64;
994            let raw_date = start_date + chrono::Duration::days(raw_offset);
995            let po_date = match &self.temporal_context {
996                Some(ctx) => {
997                    let end = start_date + chrono::Duration::days(days_range as i64);
998                    let snapped = ctx.adjust_to_business_day(raw_date);
999                    if snapped > end {
1000                        ctx.adjust_to_previous_business_day(end)
1001                    } else {
1002                        snapped
1003                    }
1004                }
1005                None => raw_date,
1006            };
1007            let fiscal_period = self.get_fiscal_period(po_date);
1008
1009            let chain = self.generate_chain(
1010                company_code,
1011                vendor,
1012                &selected_materials,
1013                po_date,
1014                fiscal_year,
1015                fiscal_period,
1016                created_by,
1017            );
1018
1019            chains.push(chain);
1020        }
1021
1022        chains
1023    }
1024
1025    /// Calculate GR date based on PO date.
1026    fn calculate_gr_date(&mut self, po_date: NaiveDate) -> NaiveDate {
1027        let variance = self.rng.random_range(0..5) as i64;
1028        let raw = po_date + chrono::Duration::days(self.config.avg_days_po_to_gr as i64 + variance);
1029        self.snap_to_business_day(raw)
1030    }
1031
1032    /// Calculate invoice date based on GR date.
1033    fn calculate_invoice_date(&mut self, gr_date: NaiveDate) -> NaiveDate {
1034        let variance = self.rng.random_range(0..3) as i64;
1035        let raw =
1036            gr_date + chrono::Duration::days(self.config.avg_days_gr_to_invoice as i64 + variance);
1037        self.snap_to_business_day(raw)
1038    }
1039
1040    /// Snap a date to the next business day when a [`TemporalContext`] is
1041    /// configured; otherwise return the date unchanged. Centralises the
1042    /// temporal-aware rewrite so GR/invoice/payment helpers share one impl.
1043    fn snap_to_business_day(&self, date: NaiveDate) -> NaiveDate {
1044        match &self.temporal_context {
1045            Some(ctx) => ctx.adjust_to_business_day(date),
1046            None => date,
1047        }
1048    }
1049
1050    /// Calculate payment date based on invoice date and payment terms.
1051    fn calculate_payment_date(
1052        &mut self,
1053        invoice_date: NaiveDate,
1054        payment_terms: &PaymentTerms,
1055    ) -> NaiveDate {
1056        let due_days = payment_terms.net_days() as i64;
1057        let due_date = invoice_date + chrono::Duration::days(due_days);
1058
1059        // Determine if this is a late payment
1060        let raw = if self.rng.random::<f64>() < self.config.payment_behavior.late_payment_rate {
1061            // Calculate late days based on distribution
1062            let late_days = self.calculate_late_days();
1063            due_date + chrono::Duration::days(late_days as i64)
1064        } else {
1065            // On-time or slightly early payment (-5 to +5 days variance)
1066            let variance = self.rng.random_range(-5..=5) as i64;
1067            due_date + chrono::Duration::days(variance)
1068        };
1069        self.snap_to_business_day(raw)
1070    }
1071
1072    /// Calculate late payment days based on the distribution.
1073    fn calculate_late_days(&mut self) -> u32 {
1074        let roll: f64 = self.rng.random();
1075        let dist = &self.config.payment_behavior.late_payment_distribution;
1076
1077        let mut cumulative = 0.0;
1078
1079        cumulative += dist.slightly_late_1_to_7;
1080        if roll < cumulative {
1081            return self.rng.random_range(1..=7);
1082        }
1083
1084        cumulative += dist.late_8_to_14;
1085        if roll < cumulative {
1086            return self.rng.random_range(8..=14);
1087        }
1088
1089        cumulative += dist.very_late_15_to_30;
1090        if roll < cumulative {
1091            return self.rng.random_range(15..=30);
1092        }
1093
1094        cumulative += dist.severely_late_31_to_60;
1095        if roll < cumulative {
1096            return self.rng.random_range(31..=60);
1097        }
1098
1099        // Extremely late: 61-120 days
1100        self.rng.random_range(61..=120)
1101    }
1102
1103    /// Calculate due date based on payment terms.
1104    fn calculate_due_date(
1105        &self,
1106        invoice_date: NaiveDate,
1107        payment_terms: &PaymentTerms,
1108    ) -> NaiveDate {
1109        invoice_date + chrono::Duration::days(payment_terms.net_days() as i64)
1110    }
1111
1112    /// Select payment method based on distribution.
1113    fn select_payment_method(&mut self) -> PaymentMethod {
1114        let roll: f64 = self.rng.random();
1115        let mut cumulative = 0.0;
1116
1117        for (method, prob) in &self.config.payment_method_distribution {
1118            cumulative += prob;
1119            if roll < cumulative {
1120                return *method;
1121            }
1122        }
1123
1124        PaymentMethod::BankTransfer
1125    }
1126
1127    /// Get fiscal period from date (simple month-based).
1128    fn get_fiscal_period(&self, date: NaiveDate) -> u8 {
1129        date.month() as u8
1130    }
1131
1132    /// Reset the generator.
1133    pub fn reset(&mut self) {
1134        self.rng = seeded_rng(self.seed, 0);
1135        self.po_counter = 0;
1136        self.gr_counter = 0;
1137        self.vi_counter = 0;
1138        self.pay_counter = 0;
1139    }
1140}
1141
1142#[cfg(test)]
1143#[allow(clippy::unwrap_used)]
1144mod tests {
1145    use super::*;
1146    use datasynth_core::models::documents::DocumentStatus;
1147    use datasynth_core::models::MaterialType;
1148
1149    fn create_test_vendor() -> Vendor {
1150        Vendor::new(
1151            "V-000001",
1152            "Test Vendor Inc.",
1153            datasynth_core::models::VendorType::Supplier,
1154        )
1155    }
1156
1157    fn create_test_materials() -> Vec<Material> {
1158        vec![
1159            Material::new("MAT-001", "Test Material 1", MaterialType::RawMaterial)
1160                .with_standard_cost(Decimal::from(100)),
1161            Material::new("MAT-002", "Test Material 2", MaterialType::RawMaterial)
1162                .with_standard_cost(Decimal::from(50)),
1163        ]
1164    }
1165
1166    #[test]
1167    fn test_p2p_chain_generation() {
1168        let mut gen = P2PGenerator::new(42);
1169        let vendor = create_test_vendor();
1170        let materials = create_test_materials();
1171        let material_refs: Vec<&Material> = materials.iter().collect();
1172
1173        let chain = gen.generate_chain(
1174            "1000",
1175            &vendor,
1176            &material_refs,
1177            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1178            2024,
1179            1,
1180            "JSMITH",
1181        );
1182
1183        assert!(!chain.purchase_order.items.is_empty());
1184        assert!(!chain.goods_receipts.is_empty());
1185        assert!(chain.vendor_invoice.is_some());
1186        assert!(chain.payment.is_some());
1187        assert!(chain.is_complete);
1188    }
1189
1190    #[test]
1191    fn test_purchase_order_generation() {
1192        let mut gen = P2PGenerator::new(42);
1193        let vendor = create_test_vendor();
1194        let materials = create_test_materials();
1195        let material_refs: Vec<&Material> = materials.iter().collect();
1196
1197        let po = gen.generate_purchase_order(
1198            "1000",
1199            &vendor,
1200            &material_refs,
1201            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1202            2024,
1203            1,
1204            "JSMITH",
1205        );
1206
1207        assert_eq!(po.vendor_id, "V-000001");
1208        assert_eq!(po.items.len(), 2);
1209        assert!(po.total_net_amount > Decimal::ZERO);
1210        assert_eq!(po.header.status, DocumentStatus::Released);
1211    }
1212
1213    #[test]
1214    fn test_document_references() {
1215        let mut gen = P2PGenerator::new(42);
1216        let vendor = create_test_vendor();
1217        let materials = create_test_materials();
1218        let material_refs: Vec<&Material> = materials.iter().collect();
1219
1220        let chain = gen.generate_chain(
1221            "1000",
1222            &vendor,
1223            &material_refs,
1224            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1225            2024,
1226            1,
1227            "JSMITH",
1228        );
1229
1230        // GR should reference PO
1231        let gr = &chain.goods_receipts[0];
1232        assert!(!gr.header.document_references.is_empty());
1233
1234        // Invoice should reference PO and GR
1235        if let Some(invoice) = &chain.vendor_invoice {
1236            assert!(invoice.header.document_references.len() >= 2);
1237        }
1238    }
1239
1240    #[test]
1241    fn test_deterministic_generation() {
1242        let vendor = create_test_vendor();
1243        let materials = create_test_materials();
1244        let material_refs: Vec<&Material> = materials.iter().collect();
1245
1246        let mut gen1 = P2PGenerator::new(42);
1247        let mut gen2 = P2PGenerator::new(42);
1248
1249        let chain1 = gen1.generate_chain(
1250            "1000",
1251            &vendor,
1252            &material_refs,
1253            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1254            2024,
1255            1,
1256            "JSMITH",
1257        );
1258        let chain2 = gen2.generate_chain(
1259            "1000",
1260            &vendor,
1261            &material_refs,
1262            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1263            2024,
1264            1,
1265            "JSMITH",
1266        );
1267
1268        assert_eq!(
1269            chain1.purchase_order.header.document_id,
1270            chain2.purchase_order.header.document_id
1271        );
1272        assert_eq!(
1273            chain1.purchase_order.total_net_amount,
1274            chain2.purchase_order.total_net_amount
1275        );
1276    }
1277
1278    #[test]
1279    fn test_partial_delivery_config() {
1280        let config = P2PGeneratorConfig {
1281            partial_delivery_rate: 1.0, // Force partial delivery
1282            ..Default::default()
1283        };
1284
1285        let mut gen = P2PGenerator::with_config(42, config);
1286        let vendor = create_test_vendor();
1287        let materials = create_test_materials();
1288        let material_refs: Vec<&Material> = materials.iter().collect();
1289
1290        let chain = gen.generate_chain(
1291            "1000",
1292            &vendor,
1293            &material_refs,
1294            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1295            2024,
1296            1,
1297            "JSMITH",
1298        );
1299
1300        // Should have multiple goods receipts due to partial delivery
1301        assert!(chain.goods_receipts.len() >= 2);
1302    }
1303
1304    #[test]
1305    fn test_partial_payment_produces_remainder() {
1306        let config = P2PGeneratorConfig {
1307            payment_behavior: P2PPaymentBehavior {
1308                partial_payment_rate: 1.0, // Force partial payment
1309                avg_days_until_remainder: 30,
1310                ..Default::default()
1311            },
1312            ..Default::default()
1313        };
1314
1315        let mut gen = P2PGenerator::with_config(42, config);
1316        let vendor = create_test_vendor();
1317        let materials = create_test_materials();
1318        let material_refs: Vec<&Material> = materials.iter().collect();
1319
1320        let chain = gen.generate_chain(
1321            "1000",
1322            &vendor,
1323            &material_refs,
1324            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1325            2024,
1326            1,
1327            "JSMITH",
1328        );
1329
1330        // With 100% partial_payment_rate, chain must have both payment and remainder
1331        assert!(
1332            chain.payment.is_some(),
1333            "Chain should have an initial payment"
1334        );
1335        assert_eq!(
1336            chain.remainder_payments.len(),
1337            1,
1338            "Chain should have exactly one remainder payment"
1339        );
1340    }
1341
1342    #[test]
1343    fn test_partial_payment_amounts_sum_to_invoice() {
1344        let config = P2PGeneratorConfig {
1345            payment_behavior: P2PPaymentBehavior {
1346                partial_payment_rate: 1.0, // Force partial payment
1347                avg_days_until_remainder: 30,
1348                ..Default::default()
1349            },
1350            ..Default::default()
1351        };
1352
1353        let mut gen = P2PGenerator::with_config(42, config);
1354        let vendor = create_test_vendor();
1355        let materials = create_test_materials();
1356        let material_refs: Vec<&Material> = materials.iter().collect();
1357
1358        let chain = gen.generate_chain(
1359            "1000",
1360            &vendor,
1361            &material_refs,
1362            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1363            2024,
1364            1,
1365            "JSMITH",
1366        );
1367
1368        let invoice = chain.vendor_invoice.as_ref().unwrap();
1369        let initial_payment = chain.payment.as_ref().unwrap();
1370        let remainder = &chain.remainder_payments[0];
1371
1372        // payment amount + remainder amount = invoice payable_amount
1373        let total_paid = initial_payment.amount + remainder.amount;
1374        assert_eq!(
1375            total_paid, invoice.payable_amount,
1376            "Initial payment ({}) + remainder ({}) = {} but invoice payable is {}",
1377            initial_payment.amount, remainder.amount, total_paid, invoice.payable_amount
1378        );
1379    }
1380
1381    #[test]
1382    fn test_remainder_payment_date_after_initial() {
1383        let config = P2PGeneratorConfig {
1384            payment_behavior: P2PPaymentBehavior {
1385                partial_payment_rate: 1.0, // Force partial payment
1386                avg_days_until_remainder: 30,
1387                ..Default::default()
1388            },
1389            ..Default::default()
1390        };
1391
1392        let mut gen = P2PGenerator::with_config(42, config);
1393        let vendor = create_test_vendor();
1394        let materials = create_test_materials();
1395        let material_refs: Vec<&Material> = materials.iter().collect();
1396
1397        let chain = gen.generate_chain(
1398            "1000",
1399            &vendor,
1400            &material_refs,
1401            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1402            2024,
1403            1,
1404            "JSMITH",
1405        );
1406
1407        let initial_payment = chain.payment.as_ref().unwrap();
1408        let remainder = &chain.remainder_payments[0];
1409
1410        // Remainder date should be after initial payment date
1411        assert!(
1412            remainder.header.document_date > initial_payment.header.document_date,
1413            "Remainder date ({}) should be after initial payment date ({})",
1414            remainder.header.document_date,
1415            initial_payment.header.document_date
1416        );
1417    }
1418
1419    #[test]
1420    fn test_no_partial_payment_means_no_remainder() {
1421        let config = P2PGeneratorConfig {
1422            payment_behavior: P2PPaymentBehavior {
1423                partial_payment_rate: 0.0, // Never partial payment
1424                ..Default::default()
1425            },
1426            ..Default::default()
1427        };
1428
1429        let mut gen = P2PGenerator::with_config(42, config);
1430        let vendor = create_test_vendor();
1431        let materials = create_test_materials();
1432        let material_refs: Vec<&Material> = materials.iter().collect();
1433
1434        let chain = gen.generate_chain(
1435            "1000",
1436            &vendor,
1437            &material_refs,
1438            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1439            2024,
1440            1,
1441            "JSMITH",
1442        );
1443
1444        assert!(chain.payment.is_some(), "Chain should have a full payment");
1445        assert!(
1446            chain.remainder_payments.is_empty(),
1447            "Chain should have no remainder payments when partial_payment_rate is 0"
1448        );
1449    }
1450
1451    #[test]
1452    fn test_partial_payment_amount_in_expected_range() {
1453        let config = P2PGeneratorConfig {
1454            payment_behavior: P2PPaymentBehavior {
1455                partial_payment_rate: 1.0, // Force partial payment
1456                avg_days_until_remainder: 30,
1457                ..Default::default()
1458            },
1459            ..Default::default()
1460        };
1461
1462        let mut gen = P2PGenerator::with_config(42, config);
1463        let vendor = create_test_vendor();
1464        let materials = create_test_materials();
1465        let material_refs: Vec<&Material> = materials.iter().collect();
1466
1467        let chain = gen.generate_chain(
1468            "1000",
1469            &vendor,
1470            &material_refs,
1471            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1472            2024,
1473            1,
1474            "JSMITH",
1475        );
1476
1477        let invoice = chain.vendor_invoice.as_ref().unwrap();
1478        let initial_payment = chain.payment.as_ref().unwrap();
1479
1480        // Partial payment should be 50-75% of invoice amount
1481        let min_pct = Decimal::from_f64_retain(0.50).unwrap();
1482        let max_pct = Decimal::from_f64_retain(0.75).unwrap();
1483        let min_amount = (invoice.payable_amount * min_pct).round_dp(2);
1484        let max_amount = (invoice.payable_amount * max_pct).round_dp(2);
1485
1486        assert!(
1487            initial_payment.amount >= min_amount && initial_payment.amount <= max_amount,
1488            "Partial payment {} should be between {} and {} (50-75% of {})",
1489            initial_payment.amount,
1490            min_amount,
1491            max_amount,
1492            invoice.payable_amount
1493        );
1494    }
1495}