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