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