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