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!("{}-{}-{:010}", prefix, company_code, counter)
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 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 po.release(created_by);
467
468 po
469 }
470
471 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 let is_partial = self.rng.random::<f64>() < self.config.partial_delivery_rate;
485
486 if is_partial {
487 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 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 let delivery_pct = if self.rng.random::<f64>() < self.config.over_delivery_rate {
516 1.0 + self.rng.random::<f64>() * 0.1 } 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 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 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 gr.post(created_by, gr_date);
589
590 gr
591 }
592
593 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 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 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 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 for po_item in &po.items {
654 if let Some(&qty) = received_quantities.get(&po_item.base.line_number) {
655 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 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 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 if three_way_match_passed {
715 invoice.verify(true);
716 }
717
718 invoice.post(created_by, invoice_date);
720
721 Some(invoice)
722 }
723
724 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 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 payment.allocate_to_invoice(
768 &invoice.header.document_id,
769 DocumentType::VendorInvoice,
770 payment_amount,
771 discount_amount,
772 );
773
774 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 payment.approve(created_by);
787 payment.send_to_bank(created_by);
788
789 payment.post(created_by, payment_date);
791
792 payment
793 }
794
795 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 payment.allocate_to_invoice(
826 &invoice.header.document_id,
827 DocumentType::VendorInvoice,
828 amount,
829 Decimal::ZERO,
830 );
831
832 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 payment.approve(created_by);
845 payment.send_to_bank(created_by);
846
847 payment.post(created_by, payment_date);
849
850 payment
851 }
852
853 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 payment.allocate_to_invoice(
885 &invoice.header.document_id,
886 DocumentType::VendorInvoice,
887 amount,
888 Decimal::ZERO,
889 );
890
891 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 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 payment.approve(created_by);
915 payment.send_to_bank(created_by);
916
917 payment.post(created_by, payment_date);
919
920 payment
921 }
922
923 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 let vendor_idx = self.rng.random_range(0..vendors.vendors.len());
943 let vendor = &vendors.vendors[vendor_idx];
944
945 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 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 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 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 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 if self.rng.random::<f64>() < self.config.payment_behavior.late_payment_rate {
998 let late_days = self.calculate_late_days();
1000 due_date + chrono::Duration::days(late_days as i64)
1001 } else {
1002 let variance = self.rng.random_range(-5..=5) as i64;
1004 due_date + chrono::Duration::days(variance)
1005 }
1006 }
1007
1008 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 self.rng.random_range(61..=120)
1037 }
1038
1039 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 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 fn get_fiscal_period(&self, date: NaiveDate) -> u8 {
1065 date.month() as u8
1066 }
1067
1068 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 let gr = &chain.goods_receipts[0];
1168 assert!(!gr.header.document_references.is_empty());
1169
1170 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, ..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 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, 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 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, 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 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, 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 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, ..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, 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 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}