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