Skip to main content

datasynth_generators/document_flow/
p2p_generator.rs

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