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!("{prefix}-{company_code}-{counter:010}")
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 mut 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                // Carry material_id from PO item to GR item
587                if let Some(ref mat_id) = po_item.base.material_id {
588                    gr_item = gr_item.with_material(mat_id);
589                }
590
591                gr.add_item(gr_item);
592            }
593        }
594
595        // Post the GR
596        gr.post(created_by, gr_date);
597
598        gr
599    }
600
601    /// Generate vendor invoice.
602    fn generate_vendor_invoice(
603        &mut self,
604        po: &PurchaseOrder,
605        goods_receipts: &[GoodsReceipt],
606        company_code: &str,
607        vendor: &Vendor,
608        invoice_date: NaiveDate,
609        fiscal_year: u16,
610        fiscal_period: u8,
611        created_by: &str,
612        three_way_match_passed: bool,
613    ) -> Option<VendorInvoice> {
614        if goods_receipts.is_empty() {
615            return None;
616        }
617
618        self.vi_counter += 1;
619
620        let invoice_id = self.make_doc_id("VI", "vendor_invoice", company_code, self.vi_counter);
621        let vendor_invoice_number = format!("INV-{:08}", self.rng.random_range(10000000..99999999));
622
623        // Calculate due date based on payment terms
624        let _due_date = self.calculate_due_date(invoice_date, &vendor.payment_terms);
625
626        let net_days = vendor.payment_terms.net_days() as i64;
627
628        let mut invoice = VendorInvoice::new(
629            invoice_id,
630            company_code,
631            &vendor.vendor_id,
632            vendor_invoice_number,
633            fiscal_year,
634            fiscal_period,
635            invoice_date,
636            created_by,
637        )
638        .with_payment_terms(vendor.payment_terms.code(), net_days);
639
640        // Denormalize vendor name (DS-011)
641        invoice.vendor_name = Some(vendor.name.clone());
642
643        // Apply cash discount if payment terms have one
644        if let (Some(discount_days), Some(discount_percent)) = (
645            vendor.payment_terms.discount_days(),
646            vendor.payment_terms.discount_percent(),
647        ) {
648            invoice = invoice.with_cash_discount(discount_percent, discount_days as i64);
649        }
650
651        // Calculate total received quantity per item
652        let mut received_quantities: std::collections::HashMap<u16, Decimal> =
653            std::collections::HashMap::new();
654
655        for gr in goods_receipts {
656            for gr_item in &gr.items {
657                *received_quantities
658                    .entry(gr_item.base.line_number)
659                    .or_insert(Decimal::ZERO) += gr_item.base.quantity;
660            }
661        }
662
663        // Add invoice items based on received quantities
664        for po_item in &po.items {
665            if let Some(&qty) = received_quantities.get(&po_item.base.line_number) {
666                // Apply price variance if configured
667                let unit_price = if !three_way_match_passed
668                    && self.rng.random::<f64>() < self.config.price_variance_rate
669                {
670                    let variance = Decimal::from_f64_retain(
671                        1.0 + (self.rng.random::<f64>() - 0.5)
672                            * 2.0
673                            * self.config.max_price_variance_percent,
674                    )
675                    .unwrap_or(Decimal::ONE);
676                    (po_item.base.unit_price * variance).round_dp(2)
677                } else {
678                    po_item.base.unit_price
679                };
680
681                let vi_description =
682                    self.pick_line_description("vendor_invoice", &po_item.base.description);
683                let item = VendorInvoiceItem::from_po_gr(
684                    po_item.base.line_number,
685                    &vi_description,
686                    qty,
687                    unit_price,
688                    &po.header.document_id,
689                    po_item.base.line_number,
690                    goods_receipts
691                        .first()
692                        .map(|gr| gr.header.document_id.clone()),
693                    Some(po_item.base.line_number),
694                );
695
696                invoice.add_item(item);
697            }
698        }
699
700        // Link to PO
701        invoice.header.add_reference(DocumentReference::new(
702            DocumentType::PurchaseOrder,
703            &po.header.document_id,
704            DocumentType::VendorInvoice,
705            &invoice.header.document_id,
706            ReferenceType::FollowOn,
707            company_code,
708            invoice_date,
709        ));
710
711        // Link to GRs
712        for gr in goods_receipts {
713            invoice.header.add_reference(DocumentReference::new(
714                DocumentType::GoodsReceipt,
715                &gr.header.document_id,
716                DocumentType::VendorInvoice,
717                &invoice.header.document_id,
718                ReferenceType::FollowOn,
719                company_code,
720                invoice_date,
721            ));
722        }
723
724        // Verify three-way match
725        if three_way_match_passed {
726            invoice.verify(true);
727        }
728
729        // Post the invoice
730        invoice.post(created_by, invoice_date);
731
732        Some(invoice)
733    }
734
735    /// Generate payment for an invoice.
736    fn generate_payment(
737        &mut self,
738        invoice: &VendorInvoice,
739        company_code: &str,
740        vendor: &Vendor,
741        payment_date: NaiveDate,
742        fiscal_year: u16,
743        fiscal_period: u8,
744        created_by: &str,
745    ) -> Payment {
746        self.pay_counter += 1;
747
748        let payment_id = self.make_doc_id("PAY", "payment", company_code, self.pay_counter);
749
750        // Determine if early payment discount applies
751        let take_discount = invoice.discount_due_date.is_some_and(|disc_date| {
752            payment_date <= disc_date
753                && self.rng.random::<f64>() < self.config.early_payment_discount_rate
754        });
755
756        let discount_amount = if take_discount {
757            invoice.cash_discount_amount
758        } else {
759            Decimal::ZERO
760        };
761
762        let payment_amount = invoice.payable_amount - discount_amount;
763
764        let mut payment = Payment::new_ap_payment(
765            payment_id,
766            company_code,
767            &vendor.vendor_id,
768            payment_amount,
769            fiscal_year,
770            fiscal_period,
771            payment_date,
772            created_by,
773        )
774        .with_payment_method(self.select_payment_method())
775        .with_value_date(payment_date + chrono::Duration::days(1));
776
777        // Allocate to invoice
778        payment.allocate_to_invoice(
779            &invoice.header.document_id,
780            DocumentType::VendorInvoice,
781            payment_amount,
782            discount_amount,
783        );
784
785        // Add document reference linking payment to invoice
786        payment.header.add_reference(DocumentReference::new(
787            DocumentType::ApPayment,
788            &payment.header.document_id,
789            DocumentType::VendorInvoice,
790            &invoice.header.document_id,
791            ReferenceType::Payment,
792            &payment.header.company_code,
793            payment_date,
794        ));
795
796        // Approve and send to bank
797        payment.approve(created_by);
798        payment.send_to_bank(created_by);
799
800        // Post the payment
801        payment.post(created_by, payment_date);
802
803        payment
804    }
805
806    /// Generate a payment for a specific amount (used for partial payments).
807    fn generate_payment_for_amount(
808        &mut self,
809        invoice: &VendorInvoice,
810        company_code: &str,
811        vendor: &Vendor,
812        payment_date: NaiveDate,
813        fiscal_year: u16,
814        fiscal_period: u8,
815        created_by: &str,
816        amount: Decimal,
817    ) -> Payment {
818        self.pay_counter += 1;
819
820        let payment_id = self.make_doc_id("PAY", "payment", company_code, self.pay_counter);
821
822        let mut payment = Payment::new_ap_payment(
823            payment_id,
824            company_code,
825            &vendor.vendor_id,
826            amount,
827            fiscal_year,
828            fiscal_period,
829            payment_date,
830            created_by,
831        )
832        .with_payment_method(self.select_payment_method())
833        .with_value_date(payment_date + chrono::Duration::days(1));
834
835        // Allocate to invoice (partial amount, no discount on partial)
836        payment.allocate_to_invoice(
837            &invoice.header.document_id,
838            DocumentType::VendorInvoice,
839            amount,
840            Decimal::ZERO,
841        );
842
843        // Add document reference linking payment to invoice
844        payment.header.add_reference(DocumentReference::new(
845            DocumentType::ApPayment,
846            &payment.header.document_id,
847            DocumentType::VendorInvoice,
848            &invoice.header.document_id,
849            ReferenceType::Payment,
850            &payment.header.company_code,
851            payment_date,
852        ));
853
854        // Approve and send to bank
855        payment.approve(created_by);
856        payment.send_to_bank(created_by);
857
858        // Post the payment
859        payment.post(created_by, payment_date);
860
861        payment
862    }
863
864    /// Generate a remainder payment for the balance after a partial payment.
865    fn generate_remainder_payment(
866        &mut self,
867        invoice: &VendorInvoice,
868        company_code: &str,
869        vendor: &Vendor,
870        payment_date: NaiveDate,
871        fiscal_year: u16,
872        fiscal_period: u8,
873        created_by: &str,
874        amount: Decimal,
875        initial_payment: &Payment,
876    ) -> Payment {
877        self.pay_counter += 1;
878
879        let payment_id = self.make_doc_id("PAY", "payment", company_code, self.pay_counter);
880
881        let mut payment = Payment::new_ap_payment(
882            payment_id,
883            company_code,
884            &vendor.vendor_id,
885            amount,
886            fiscal_year,
887            fiscal_period,
888            payment_date,
889            created_by,
890        )
891        .with_payment_method(self.select_payment_method())
892        .with_value_date(payment_date + chrono::Duration::days(1));
893
894        // Allocate remainder to the same invoice
895        payment.allocate_to_invoice(
896            &invoice.header.document_id,
897            DocumentType::VendorInvoice,
898            amount,
899            Decimal::ZERO,
900        );
901
902        // Add document reference linking remainder payment to invoice
903        payment.header.add_reference(DocumentReference::new(
904            DocumentType::ApPayment,
905            &payment.header.document_id,
906            DocumentType::VendorInvoice,
907            &invoice.header.document_id,
908            ReferenceType::Payment,
909            &payment.header.company_code,
910            payment_date,
911        ));
912
913        // Add document reference linking remainder payment to initial payment
914        payment.header.add_reference(DocumentReference::new(
915            DocumentType::ApPayment,
916            &payment.header.document_id,
917            DocumentType::ApPayment,
918            &initial_payment.header.document_id,
919            ReferenceType::FollowOn,
920            &payment.header.company_code,
921            payment_date,
922        ));
923
924        // Approve and send to bank
925        payment.approve(created_by);
926        payment.send_to_bank(created_by);
927
928        // Post the payment
929        payment.post(created_by, payment_date);
930
931        payment
932    }
933
934    /// Generate multiple P2P chains.
935    pub fn generate_chains(
936        &mut self,
937        count: usize,
938        company_code: &str,
939        vendors: &VendorPool,
940        materials: &MaterialPool,
941        date_range: (NaiveDate, NaiveDate),
942        fiscal_year: u16,
943        created_by: &str,
944    ) -> Vec<P2PDocumentChain> {
945        tracing::debug!(count, company_code, "Generating P2P document chains");
946        let mut chains = Vec::new();
947
948        let (start_date, end_date) = date_range;
949        let days_range = (end_date - start_date).num_days() as u64;
950
951        for _ in 0..count {
952            // Select random vendor
953            let vendor_idx = self.rng.random_range(0..vendors.vendors.len());
954            let vendor = &vendors.vendors[vendor_idx];
955
956            // Select random materials (1-5 items per PO)
957            let num_items = self.rng.random_range(1..=5).min(materials.materials.len());
958            let selected_materials: Vec<&Material> = materials
959                .materials
960                .iter()
961                .choose_multiple(&mut self.rng, num_items)
962                .into_iter()
963                .collect();
964
965            // Select random PO date
966            let po_date =
967                start_date + chrono::Duration::days(self.rng.random_range(0..=days_range) as i64);
968            let fiscal_period = self.get_fiscal_period(po_date);
969
970            let chain = self.generate_chain(
971                company_code,
972                vendor,
973                &selected_materials,
974                po_date,
975                fiscal_year,
976                fiscal_period,
977                created_by,
978            );
979
980            chains.push(chain);
981        }
982
983        chains
984    }
985
986    /// Calculate GR date based on PO date.
987    fn calculate_gr_date(&mut self, po_date: NaiveDate) -> NaiveDate {
988        let variance = self.rng.random_range(0..5) as i64;
989        po_date + chrono::Duration::days(self.config.avg_days_po_to_gr as i64 + variance)
990    }
991
992    /// Calculate invoice date based on GR date.
993    fn calculate_invoice_date(&mut self, gr_date: NaiveDate) -> NaiveDate {
994        let variance = self.rng.random_range(0..3) as i64;
995        gr_date + chrono::Duration::days(self.config.avg_days_gr_to_invoice as i64 + variance)
996    }
997
998    /// Calculate payment date based on invoice date and payment terms.
999    fn calculate_payment_date(
1000        &mut self,
1001        invoice_date: NaiveDate,
1002        payment_terms: &PaymentTerms,
1003    ) -> NaiveDate {
1004        let due_days = payment_terms.net_days() as i64;
1005        let due_date = invoice_date + chrono::Duration::days(due_days);
1006
1007        // Determine if this is a late payment
1008        if self.rng.random::<f64>() < self.config.payment_behavior.late_payment_rate {
1009            // Calculate late days based on distribution
1010            let late_days = self.calculate_late_days();
1011            due_date + chrono::Duration::days(late_days as i64)
1012        } else {
1013            // On-time or slightly early payment (-5 to +5 days variance)
1014            let variance = self.rng.random_range(-5..=5) as i64;
1015            due_date + chrono::Duration::days(variance)
1016        }
1017    }
1018
1019    /// Calculate late payment days based on the distribution.
1020    fn calculate_late_days(&mut self) -> u32 {
1021        let roll: f64 = self.rng.random();
1022        let dist = &self.config.payment_behavior.late_payment_distribution;
1023
1024        let mut cumulative = 0.0;
1025
1026        cumulative += dist.slightly_late_1_to_7;
1027        if roll < cumulative {
1028            return self.rng.random_range(1..=7);
1029        }
1030
1031        cumulative += dist.late_8_to_14;
1032        if roll < cumulative {
1033            return self.rng.random_range(8..=14);
1034        }
1035
1036        cumulative += dist.very_late_15_to_30;
1037        if roll < cumulative {
1038            return self.rng.random_range(15..=30);
1039        }
1040
1041        cumulative += dist.severely_late_31_to_60;
1042        if roll < cumulative {
1043            return self.rng.random_range(31..=60);
1044        }
1045
1046        // Extremely late: 61-120 days
1047        self.rng.random_range(61..=120)
1048    }
1049
1050    /// Calculate due date based on payment terms.
1051    fn calculate_due_date(
1052        &self,
1053        invoice_date: NaiveDate,
1054        payment_terms: &PaymentTerms,
1055    ) -> NaiveDate {
1056        invoice_date + chrono::Duration::days(payment_terms.net_days() as i64)
1057    }
1058
1059    /// Select payment method based on distribution.
1060    fn select_payment_method(&mut self) -> PaymentMethod {
1061        let roll: f64 = self.rng.random();
1062        let mut cumulative = 0.0;
1063
1064        for (method, prob) in &self.config.payment_method_distribution {
1065            cumulative += prob;
1066            if roll < cumulative {
1067                return *method;
1068            }
1069        }
1070
1071        PaymentMethod::BankTransfer
1072    }
1073
1074    /// Get fiscal period from date (simple month-based).
1075    fn get_fiscal_period(&self, date: NaiveDate) -> u8 {
1076        date.month() as u8
1077    }
1078
1079    /// Reset the generator.
1080    pub fn reset(&mut self) {
1081        self.rng = seeded_rng(self.seed, 0);
1082        self.po_counter = 0;
1083        self.gr_counter = 0;
1084        self.vi_counter = 0;
1085        self.pay_counter = 0;
1086    }
1087}
1088
1089#[cfg(test)]
1090#[allow(clippy::unwrap_used)]
1091mod tests {
1092    use super::*;
1093    use datasynth_core::models::documents::DocumentStatus;
1094    use datasynth_core::models::MaterialType;
1095
1096    fn create_test_vendor() -> Vendor {
1097        Vendor::new(
1098            "V-000001",
1099            "Test Vendor Inc.",
1100            datasynth_core::models::VendorType::Supplier,
1101        )
1102    }
1103
1104    fn create_test_materials() -> Vec<Material> {
1105        vec![
1106            Material::new("MAT-001", "Test Material 1", MaterialType::RawMaterial)
1107                .with_standard_cost(Decimal::from(100)),
1108            Material::new("MAT-002", "Test Material 2", MaterialType::RawMaterial)
1109                .with_standard_cost(Decimal::from(50)),
1110        ]
1111    }
1112
1113    #[test]
1114    fn test_p2p_chain_generation() {
1115        let mut gen = P2PGenerator::new(42);
1116        let vendor = create_test_vendor();
1117        let materials = create_test_materials();
1118        let material_refs: Vec<&Material> = materials.iter().collect();
1119
1120        let chain = gen.generate_chain(
1121            "1000",
1122            &vendor,
1123            &material_refs,
1124            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1125            2024,
1126            1,
1127            "JSMITH",
1128        );
1129
1130        assert!(!chain.purchase_order.items.is_empty());
1131        assert!(!chain.goods_receipts.is_empty());
1132        assert!(chain.vendor_invoice.is_some());
1133        assert!(chain.payment.is_some());
1134        assert!(chain.is_complete);
1135    }
1136
1137    #[test]
1138    fn test_purchase_order_generation() {
1139        let mut gen = P2PGenerator::new(42);
1140        let vendor = create_test_vendor();
1141        let materials = create_test_materials();
1142        let material_refs: Vec<&Material> = materials.iter().collect();
1143
1144        let po = gen.generate_purchase_order(
1145            "1000",
1146            &vendor,
1147            &material_refs,
1148            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1149            2024,
1150            1,
1151            "JSMITH",
1152        );
1153
1154        assert_eq!(po.vendor_id, "V-000001");
1155        assert_eq!(po.items.len(), 2);
1156        assert!(po.total_net_amount > Decimal::ZERO);
1157        assert_eq!(po.header.status, DocumentStatus::Released);
1158    }
1159
1160    #[test]
1161    fn test_document_references() {
1162        let mut gen = P2PGenerator::new(42);
1163        let vendor = create_test_vendor();
1164        let materials = create_test_materials();
1165        let material_refs: Vec<&Material> = materials.iter().collect();
1166
1167        let chain = gen.generate_chain(
1168            "1000",
1169            &vendor,
1170            &material_refs,
1171            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1172            2024,
1173            1,
1174            "JSMITH",
1175        );
1176
1177        // GR should reference PO
1178        let gr = &chain.goods_receipts[0];
1179        assert!(!gr.header.document_references.is_empty());
1180
1181        // Invoice should reference PO and GR
1182        if let Some(invoice) = &chain.vendor_invoice {
1183            assert!(invoice.header.document_references.len() >= 2);
1184        }
1185    }
1186
1187    #[test]
1188    fn test_deterministic_generation() {
1189        let vendor = create_test_vendor();
1190        let materials = create_test_materials();
1191        let material_refs: Vec<&Material> = materials.iter().collect();
1192
1193        let mut gen1 = P2PGenerator::new(42);
1194        let mut gen2 = P2PGenerator::new(42);
1195
1196        let chain1 = gen1.generate_chain(
1197            "1000",
1198            &vendor,
1199            &material_refs,
1200            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1201            2024,
1202            1,
1203            "JSMITH",
1204        );
1205        let chain2 = gen2.generate_chain(
1206            "1000",
1207            &vendor,
1208            &material_refs,
1209            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1210            2024,
1211            1,
1212            "JSMITH",
1213        );
1214
1215        assert_eq!(
1216            chain1.purchase_order.header.document_id,
1217            chain2.purchase_order.header.document_id
1218        );
1219        assert_eq!(
1220            chain1.purchase_order.total_net_amount,
1221            chain2.purchase_order.total_net_amount
1222        );
1223    }
1224
1225    #[test]
1226    fn test_partial_delivery_config() {
1227        let config = P2PGeneratorConfig {
1228            partial_delivery_rate: 1.0, // Force partial delivery
1229            ..Default::default()
1230        };
1231
1232        let mut gen = P2PGenerator::with_config(42, config);
1233        let vendor = create_test_vendor();
1234        let materials = create_test_materials();
1235        let material_refs: Vec<&Material> = materials.iter().collect();
1236
1237        let chain = gen.generate_chain(
1238            "1000",
1239            &vendor,
1240            &material_refs,
1241            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1242            2024,
1243            1,
1244            "JSMITH",
1245        );
1246
1247        // Should have multiple goods receipts due to partial delivery
1248        assert!(chain.goods_receipts.len() >= 2);
1249    }
1250
1251    #[test]
1252    fn test_partial_payment_produces_remainder() {
1253        let config = P2PGeneratorConfig {
1254            payment_behavior: P2PPaymentBehavior {
1255                partial_payment_rate: 1.0, // Force partial payment
1256                avg_days_until_remainder: 30,
1257                ..Default::default()
1258            },
1259            ..Default::default()
1260        };
1261
1262        let mut gen = P2PGenerator::with_config(42, config);
1263        let vendor = create_test_vendor();
1264        let materials = create_test_materials();
1265        let material_refs: Vec<&Material> = materials.iter().collect();
1266
1267        let chain = gen.generate_chain(
1268            "1000",
1269            &vendor,
1270            &material_refs,
1271            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1272            2024,
1273            1,
1274            "JSMITH",
1275        );
1276
1277        // With 100% partial_payment_rate, chain must have both payment and remainder
1278        assert!(
1279            chain.payment.is_some(),
1280            "Chain should have an initial payment"
1281        );
1282        assert_eq!(
1283            chain.remainder_payments.len(),
1284            1,
1285            "Chain should have exactly one remainder payment"
1286        );
1287    }
1288
1289    #[test]
1290    fn test_partial_payment_amounts_sum_to_invoice() {
1291        let config = P2PGeneratorConfig {
1292            payment_behavior: P2PPaymentBehavior {
1293                partial_payment_rate: 1.0, // Force partial payment
1294                avg_days_until_remainder: 30,
1295                ..Default::default()
1296            },
1297            ..Default::default()
1298        };
1299
1300        let mut gen = P2PGenerator::with_config(42, config);
1301        let vendor = create_test_vendor();
1302        let materials = create_test_materials();
1303        let material_refs: Vec<&Material> = materials.iter().collect();
1304
1305        let chain = gen.generate_chain(
1306            "1000",
1307            &vendor,
1308            &material_refs,
1309            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1310            2024,
1311            1,
1312            "JSMITH",
1313        );
1314
1315        let invoice = chain.vendor_invoice.as_ref().unwrap();
1316        let initial_payment = chain.payment.as_ref().unwrap();
1317        let remainder = &chain.remainder_payments[0];
1318
1319        // payment amount + remainder amount = invoice payable_amount
1320        let total_paid = initial_payment.amount + remainder.amount;
1321        assert_eq!(
1322            total_paid, invoice.payable_amount,
1323            "Initial payment ({}) + remainder ({}) = {} but invoice payable is {}",
1324            initial_payment.amount, remainder.amount, total_paid, invoice.payable_amount
1325        );
1326    }
1327
1328    #[test]
1329    fn test_remainder_payment_date_after_initial() {
1330        let config = P2PGeneratorConfig {
1331            payment_behavior: P2PPaymentBehavior {
1332                partial_payment_rate: 1.0, // Force partial payment
1333                avg_days_until_remainder: 30,
1334                ..Default::default()
1335            },
1336            ..Default::default()
1337        };
1338
1339        let mut gen = P2PGenerator::with_config(42, config);
1340        let vendor = create_test_vendor();
1341        let materials = create_test_materials();
1342        let material_refs: Vec<&Material> = materials.iter().collect();
1343
1344        let chain = gen.generate_chain(
1345            "1000",
1346            &vendor,
1347            &material_refs,
1348            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1349            2024,
1350            1,
1351            "JSMITH",
1352        );
1353
1354        let initial_payment = chain.payment.as_ref().unwrap();
1355        let remainder = &chain.remainder_payments[0];
1356
1357        // Remainder date should be after initial payment date
1358        assert!(
1359            remainder.header.document_date > initial_payment.header.document_date,
1360            "Remainder date ({}) should be after initial payment date ({})",
1361            remainder.header.document_date,
1362            initial_payment.header.document_date
1363        );
1364    }
1365
1366    #[test]
1367    fn test_no_partial_payment_means_no_remainder() {
1368        let config = P2PGeneratorConfig {
1369            payment_behavior: P2PPaymentBehavior {
1370                partial_payment_rate: 0.0, // Never partial payment
1371                ..Default::default()
1372            },
1373            ..Default::default()
1374        };
1375
1376        let mut gen = P2PGenerator::with_config(42, config);
1377        let vendor = create_test_vendor();
1378        let materials = create_test_materials();
1379        let material_refs: Vec<&Material> = materials.iter().collect();
1380
1381        let chain = gen.generate_chain(
1382            "1000",
1383            &vendor,
1384            &material_refs,
1385            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1386            2024,
1387            1,
1388            "JSMITH",
1389        );
1390
1391        assert!(chain.payment.is_some(), "Chain should have a full payment");
1392        assert!(
1393            chain.remainder_payments.is_empty(),
1394            "Chain should have no remainder payments when partial_payment_rate is 0"
1395        );
1396    }
1397
1398    #[test]
1399    fn test_partial_payment_amount_in_expected_range() {
1400        let config = P2PGeneratorConfig {
1401            payment_behavior: P2PPaymentBehavior {
1402                partial_payment_rate: 1.0, // Force partial payment
1403                avg_days_until_remainder: 30,
1404                ..Default::default()
1405            },
1406            ..Default::default()
1407        };
1408
1409        let mut gen = P2PGenerator::with_config(42, config);
1410        let vendor = create_test_vendor();
1411        let materials = create_test_materials();
1412        let material_refs: Vec<&Material> = materials.iter().collect();
1413
1414        let chain = gen.generate_chain(
1415            "1000",
1416            &vendor,
1417            &material_refs,
1418            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1419            2024,
1420            1,
1421            "JSMITH",
1422        );
1423
1424        let invoice = chain.vendor_invoice.as_ref().unwrap();
1425        let initial_payment = chain.payment.as_ref().unwrap();
1426
1427        // Partial payment should be 50-75% of invoice amount
1428        let min_pct = Decimal::from_f64_retain(0.50).unwrap();
1429        let max_pct = Decimal::from_f64_retain(0.75).unwrap();
1430        let min_amount = (invoice.payable_amount * min_pct).round_dp(2);
1431        let max_amount = (invoice.payable_amount * max_pct).round_dp(2);
1432
1433        assert!(
1434            initial_payment.amount >= min_amount && initial_payment.amount <= max_amount,
1435            "Partial payment {} should be between {} and {} (50-75% of {})",
1436            initial_payment.amount,
1437            min_amount,
1438            max_amount,
1439            invoice.payable_amount
1440        );
1441    }
1442}