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