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