1use chrono::{Datelike, NaiveDate};
7use rand::prelude::*;
8use rand_chacha::ChaCha8Rng;
9use rust_decimal::Decimal;
10
11use datasynth_core::models::{
12 documents::{
13 DocumentReference, DocumentType, GoodsReceipt, GoodsReceiptItem, MovementType, Payment,
14 PaymentMethod, PurchaseOrder, PurchaseOrderItem, ReferenceType, VendorInvoice,
15 VendorInvoiceItem,
16 },
17 Material, MaterialPool, PaymentTerms, Vendor, VendorPool,
18};
19
20use super::three_way_match::ThreeWayMatcher;
21
22#[derive(Debug, Clone)]
24pub struct P2PGeneratorConfig {
25 pub three_way_match_rate: f64,
27 pub partial_delivery_rate: f64,
29 pub over_delivery_rate: f64,
31 pub price_variance_rate: f64,
33 pub max_price_variance_percent: f64,
35 pub avg_days_po_to_gr: u32,
37 pub avg_days_gr_to_invoice: u32,
39 pub avg_days_invoice_to_payment: u32,
41 pub payment_method_distribution: Vec<(PaymentMethod, f64)>,
43 pub early_payment_discount_rate: f64,
45 pub payment_behavior: P2PPaymentBehavior,
47}
48
49#[derive(Debug, Clone)]
51pub struct P2PPaymentBehavior {
52 pub late_payment_rate: f64,
54 pub late_payment_distribution: LatePaymentDistribution,
56 pub partial_payment_rate: f64,
58 pub payment_correction_rate: f64,
60}
61
62impl Default for P2PPaymentBehavior {
63 fn default() -> Self {
64 Self {
65 late_payment_rate: 0.15,
66 late_payment_distribution: LatePaymentDistribution::default(),
67 partial_payment_rate: 0.05,
68 payment_correction_rate: 0.02,
69 }
70 }
71}
72
73#[derive(Debug, Clone)]
75pub struct LatePaymentDistribution {
76 pub slightly_late_1_to_7: f64,
78 pub late_8_to_14: f64,
80 pub very_late_15_to_30: f64,
82 pub severely_late_31_to_60: f64,
84 pub extremely_late_over_60: f64,
86}
87
88impl Default for LatePaymentDistribution {
89 fn default() -> Self {
90 Self {
91 slightly_late_1_to_7: 0.50,
92 late_8_to_14: 0.25,
93 very_late_15_to_30: 0.15,
94 severely_late_31_to_60: 0.07,
95 extremely_late_over_60: 0.03,
96 }
97 }
98}
99
100impl Default for P2PGeneratorConfig {
101 fn default() -> Self {
102 Self {
103 three_way_match_rate: 0.95,
104 partial_delivery_rate: 0.10,
105 over_delivery_rate: 0.02,
106 price_variance_rate: 0.05,
107 max_price_variance_percent: 0.05,
108 avg_days_po_to_gr: 7,
109 avg_days_gr_to_invoice: 5,
110 avg_days_invoice_to_payment: 30,
111 payment_method_distribution: vec![
112 (PaymentMethod::BankTransfer, 0.60),
113 (PaymentMethod::Check, 0.25),
114 (PaymentMethod::Wire, 0.10),
115 (PaymentMethod::CreditCard, 0.05),
116 ],
117 early_payment_discount_rate: 0.30,
118 payment_behavior: P2PPaymentBehavior::default(),
119 }
120 }
121}
122
123#[derive(Debug, Clone)]
125pub struct P2PDocumentChain {
126 pub purchase_order: PurchaseOrder,
128 pub goods_receipts: Vec<GoodsReceipt>,
130 pub vendor_invoice: Option<VendorInvoice>,
132 pub payment: Option<Payment>,
134 pub is_complete: bool,
136 pub three_way_match_passed: bool,
138 pub payment_timing: Option<PaymentTimingInfo>,
140}
141
142#[derive(Debug, Clone)]
144pub struct PaymentTimingInfo {
145 pub due_date: NaiveDate,
147 pub payment_date: NaiveDate,
149 pub days_late: i32,
151 pub is_late: bool,
153 pub discount_taken: bool,
155}
156
157pub struct P2PGenerator {
159 rng: ChaCha8Rng,
160 seed: u64,
161 config: P2PGeneratorConfig,
162 po_counter: usize,
163 gr_counter: usize,
164 vi_counter: usize,
165 pay_counter: usize,
166 three_way_matcher: ThreeWayMatcher,
167}
168
169impl P2PGenerator {
170 pub fn new(seed: u64) -> Self {
172 Self::with_config(seed, P2PGeneratorConfig::default())
173 }
174
175 pub fn with_config(seed: u64, config: P2PGeneratorConfig) -> Self {
177 Self {
178 rng: ChaCha8Rng::seed_from_u64(seed),
179 seed,
180 config,
181 po_counter: 0,
182 gr_counter: 0,
183 vi_counter: 0,
184 pay_counter: 0,
185 three_way_matcher: ThreeWayMatcher::new(),
186 }
187 }
188
189 pub fn generate_chain(
191 &mut self,
192 company_code: &str,
193 vendor: &Vendor,
194 materials: &[&Material],
195 po_date: NaiveDate,
196 fiscal_year: u16,
197 fiscal_period: u8,
198 created_by: &str,
199 ) -> P2PDocumentChain {
200 let po = self.generate_purchase_order(
202 company_code,
203 vendor,
204 materials,
205 po_date,
206 fiscal_year,
207 fiscal_period,
208 created_by,
209 );
210
211 let gr_date = self.calculate_gr_date(po_date);
213 let gr_fiscal_period = self.get_fiscal_period(gr_date);
214
215 let goods_receipts = self.generate_goods_receipts(
217 &po,
218 company_code,
219 gr_date,
220 fiscal_year,
221 gr_fiscal_period,
222 created_by,
223 );
224
225 let invoice_date = self.calculate_invoice_date(gr_date);
227 let invoice_fiscal_period = self.get_fiscal_period(invoice_date);
228
229 let should_have_variance = self.rng.gen::<f64>() >= self.config.three_way_match_rate;
232
233 let vendor_invoice = self.generate_vendor_invoice(
235 &po,
236 &goods_receipts,
237 company_code,
238 vendor,
239 invoice_date,
240 fiscal_year,
241 invoice_fiscal_period,
242 created_by,
243 !should_have_variance, );
245
246 let three_way_match_passed = if let Some(ref invoice) = vendor_invoice {
248 let gr_refs: Vec<&GoodsReceipt> = goods_receipts.iter().collect();
249 let match_result = self.three_way_matcher.validate(&po, &gr_refs, invoice);
250 match_result.passed
251 } else {
252 false
253 };
254
255 let payment_date = self.calculate_payment_date(invoice_date, &vendor.payment_terms);
257 let payment_fiscal_period = self.get_fiscal_period(payment_date);
258
259 let due_date = self.calculate_due_date(invoice_date, &vendor.payment_terms);
261
262 let payment = vendor_invoice.as_ref().map(|invoice| {
264 self.generate_payment(
265 invoice,
266 company_code,
267 vendor,
268 payment_date,
269 fiscal_year,
270 payment_fiscal_period,
271 created_by,
272 )
273 });
274
275 let is_complete = payment.is_some();
276
277 let payment_timing = if payment.is_some() {
279 let days_diff = (payment_date - due_date).num_days() as i32;
280 let is_late = days_diff > 0;
281 let discount_taken = payment
282 .as_ref()
283 .map(|p| {
284 p.allocations
285 .iter()
286 .any(|a| a.discount_taken > Decimal::ZERO)
287 })
288 .unwrap_or(false);
289
290 Some(PaymentTimingInfo {
291 due_date,
292 payment_date,
293 days_late: days_diff.max(0),
294 is_late,
295 discount_taken,
296 })
297 } else {
298 None
299 };
300
301 P2PDocumentChain {
302 purchase_order: po,
303 goods_receipts,
304 vendor_invoice,
305 payment,
306 is_complete,
307 three_way_match_passed,
308 payment_timing,
309 }
310 }
311
312 pub fn generate_purchase_order(
314 &mut self,
315 company_code: &str,
316 vendor: &Vendor,
317 materials: &[&Material],
318 po_date: NaiveDate,
319 fiscal_year: u16,
320 fiscal_period: u8,
321 created_by: &str,
322 ) -> PurchaseOrder {
323 self.po_counter += 1;
324
325 let po_id = format!("PO-{}-{:010}", company_code, self.po_counter);
326
327 let mut po = PurchaseOrder::new(
328 po_id,
329 company_code,
330 &vendor.vendor_id,
331 fiscal_year,
332 fiscal_period,
333 po_date,
334 created_by,
335 )
336 .with_payment_terms(vendor.payment_terms.code());
337
338 for (idx, material) in materials.iter().enumerate() {
340 let quantity = Decimal::from(self.rng.gen_range(1..100));
341 let unit_price = material.standard_cost;
342
343 let item = PurchaseOrderItem::new(
344 (idx + 1) as u16 * 10,
345 &material.description,
346 quantity,
347 unit_price,
348 )
349 .with_material(&material.material_id);
350
351 po.add_item(item);
352 }
353
354 po.release(created_by);
356
357 po
358 }
359
360 fn generate_goods_receipts(
362 &mut self,
363 po: &PurchaseOrder,
364 company_code: &str,
365 gr_date: NaiveDate,
366 fiscal_year: u16,
367 fiscal_period: u8,
368 created_by: &str,
369 ) -> Vec<GoodsReceipt> {
370 let mut receipts = Vec::new();
371
372 let is_partial = self.rng.gen::<f64>() < self.config.partial_delivery_rate;
374
375 if is_partial {
376 let first_pct = 0.6 + self.rng.gen::<f64>() * 0.2;
378 let gr1 = self.create_goods_receipt(
379 po,
380 company_code,
381 gr_date,
382 fiscal_year,
383 fiscal_period,
384 created_by,
385 first_pct,
386 );
387 receipts.push(gr1);
388
389 let second_date = gr_date + chrono::Duration::days(self.rng.gen_range(3..10) as i64);
391 let second_period = self.get_fiscal_period(second_date);
392 let gr2 = self.create_goods_receipt(
393 po,
394 company_code,
395 second_date,
396 fiscal_year,
397 second_period,
398 created_by,
399 1.0 - first_pct,
400 );
401 receipts.push(gr2);
402 } else {
403 let delivery_pct = if self.rng.gen::<f64>() < self.config.over_delivery_rate {
405 1.0 + self.rng.gen::<f64>() * 0.1 } else {
407 1.0
408 };
409
410 let gr = self.create_goods_receipt(
411 po,
412 company_code,
413 gr_date,
414 fiscal_year,
415 fiscal_period,
416 created_by,
417 delivery_pct,
418 );
419 receipts.push(gr);
420 }
421
422 receipts
423 }
424
425 fn create_goods_receipt(
427 &mut self,
428 po: &PurchaseOrder,
429 company_code: &str,
430 gr_date: NaiveDate,
431 fiscal_year: u16,
432 fiscal_period: u8,
433 created_by: &str,
434 quantity_pct: f64,
435 ) -> GoodsReceipt {
436 self.gr_counter += 1;
437
438 let gr_id = format!("GR-{}-{:010}", company_code, self.gr_counter);
439
440 let mut gr = GoodsReceipt::from_purchase_order(
441 gr_id,
442 company_code,
443 &po.header.document_id,
444 &po.vendor_id,
445 format!("P{}", company_code),
446 "0001",
447 fiscal_year,
448 fiscal_period,
449 gr_date,
450 created_by,
451 );
452
453 for po_item in &po.items {
455 let received_qty = (po_item.base.quantity
456 * Decimal::from_f64_retain(quantity_pct).unwrap_or(Decimal::ONE))
457 .round_dp(0);
458
459 if received_qty > Decimal::ZERO {
460 let gr_item = GoodsReceiptItem::from_po(
461 po_item.base.line_number,
462 &po_item.base.description,
463 received_qty,
464 po_item.base.unit_price,
465 &po.header.document_id,
466 po_item.base.line_number,
467 )
468 .with_movement_type(MovementType::GrForPo);
469
470 gr.add_item(gr_item);
471 }
472 }
473
474 gr.post(created_by, gr_date);
476
477 gr
478 }
479
480 fn generate_vendor_invoice(
482 &mut self,
483 po: &PurchaseOrder,
484 goods_receipts: &[GoodsReceipt],
485 company_code: &str,
486 vendor: &Vendor,
487 invoice_date: NaiveDate,
488 fiscal_year: u16,
489 fiscal_period: u8,
490 created_by: &str,
491 three_way_match_passed: bool,
492 ) -> Option<VendorInvoice> {
493 if goods_receipts.is_empty() {
494 return None;
495 }
496
497 self.vi_counter += 1;
498
499 let invoice_id = format!("VI-{}-{:010}", company_code, self.vi_counter);
500 let vendor_invoice_number = format!("INV-{:08}", self.rng.gen_range(10000000..99999999));
501
502 let _due_date = self.calculate_due_date(invoice_date, &vendor.payment_terms);
504
505 let net_days = vendor.payment_terms.net_days() as i64;
506
507 let mut invoice = VendorInvoice::new(
508 invoice_id,
509 company_code,
510 &vendor.vendor_id,
511 vendor_invoice_number,
512 fiscal_year,
513 fiscal_period,
514 invoice_date,
515 created_by,
516 )
517 .with_payment_terms(vendor.payment_terms.code(), net_days);
518
519 if let (Some(discount_days), Some(discount_percent)) = (
521 vendor.payment_terms.discount_days(),
522 vendor.payment_terms.discount_percent(),
523 ) {
524 invoice = invoice.with_cash_discount(discount_percent, discount_days as i64);
525 }
526
527 let mut received_quantities: std::collections::HashMap<u16, Decimal> =
529 std::collections::HashMap::new();
530
531 for gr in goods_receipts {
532 for gr_item in &gr.items {
533 *received_quantities
534 .entry(gr_item.base.line_number)
535 .or_insert(Decimal::ZERO) += gr_item.base.quantity;
536 }
537 }
538
539 for po_item in &po.items {
541 if let Some(&qty) = received_quantities.get(&po_item.base.line_number) {
542 let unit_price = if !three_way_match_passed
544 && self.rng.gen::<f64>() < self.config.price_variance_rate
545 {
546 let variance = Decimal::from_f64_retain(
547 1.0 + (self.rng.gen::<f64>() - 0.5)
548 * 2.0
549 * self.config.max_price_variance_percent,
550 )
551 .unwrap_or(Decimal::ONE);
552 (po_item.base.unit_price * variance).round_dp(2)
553 } else {
554 po_item.base.unit_price
555 };
556
557 let item = VendorInvoiceItem::from_po_gr(
558 po_item.base.line_number,
559 &po_item.base.description,
560 qty,
561 unit_price,
562 &po.header.document_id,
563 po_item.base.line_number,
564 goods_receipts
565 .first()
566 .map(|gr| gr.header.document_id.clone()),
567 Some(po_item.base.line_number),
568 );
569
570 invoice.add_item(item);
571 }
572 }
573
574 invoice.header.add_reference(DocumentReference::new(
576 DocumentType::PurchaseOrder,
577 &po.header.document_id,
578 DocumentType::VendorInvoice,
579 &invoice.header.document_id,
580 ReferenceType::FollowOn,
581 company_code,
582 invoice_date,
583 ));
584
585 for gr in goods_receipts {
587 invoice.header.add_reference(DocumentReference::new(
588 DocumentType::GoodsReceipt,
589 &gr.header.document_id,
590 DocumentType::VendorInvoice,
591 &invoice.header.document_id,
592 ReferenceType::FollowOn,
593 company_code,
594 invoice_date,
595 ));
596 }
597
598 if three_way_match_passed {
600 invoice.verify(true);
601 }
602
603 invoice.post(created_by, invoice_date);
605
606 Some(invoice)
607 }
608
609 fn generate_payment(
611 &mut self,
612 invoice: &VendorInvoice,
613 company_code: &str,
614 vendor: &Vendor,
615 payment_date: NaiveDate,
616 fiscal_year: u16,
617 fiscal_period: u8,
618 created_by: &str,
619 ) -> Payment {
620 self.pay_counter += 1;
621
622 let payment_id = format!("PAY-{}-{:010}", company_code, self.pay_counter);
623
624 let take_discount = invoice.discount_due_date.is_some_and(|disc_date| {
626 payment_date <= disc_date
627 && self.rng.gen::<f64>() < self.config.early_payment_discount_rate
628 });
629
630 let discount_amount = if take_discount {
631 invoice.cash_discount_amount
632 } else {
633 Decimal::ZERO
634 };
635
636 let payment_amount = invoice.payable_amount - discount_amount;
637
638 let mut payment = Payment::new_ap_payment(
639 payment_id,
640 company_code,
641 &vendor.vendor_id,
642 payment_amount,
643 fiscal_year,
644 fiscal_period,
645 payment_date,
646 created_by,
647 )
648 .with_payment_method(self.select_payment_method())
649 .with_value_date(payment_date + chrono::Duration::days(1));
650
651 payment.allocate_to_invoice(
653 &invoice.header.document_id,
654 DocumentType::VendorInvoice,
655 payment_amount,
656 discount_amount,
657 );
658
659 payment.header.add_reference(DocumentReference::new(
661 DocumentType::ApPayment,
662 &payment.header.document_id,
663 DocumentType::VendorInvoice,
664 &invoice.header.document_id,
665 ReferenceType::Payment,
666 &payment.header.company_code,
667 payment_date,
668 ));
669
670 payment.approve(created_by);
672 payment.send_to_bank(created_by);
673
674 payment.post(created_by, payment_date);
676
677 payment
678 }
679
680 pub fn generate_chains(
682 &mut self,
683 count: usize,
684 company_code: &str,
685 vendors: &VendorPool,
686 materials: &MaterialPool,
687 date_range: (NaiveDate, NaiveDate),
688 fiscal_year: u16,
689 created_by: &str,
690 ) -> Vec<P2PDocumentChain> {
691 let mut chains = Vec::new();
692
693 let (start_date, end_date) = date_range;
694 let days_range = (end_date - start_date).num_days() as u64;
695
696 for _ in 0..count {
697 let vendor_idx = self.rng.gen_range(0..vendors.vendors.len());
699 let vendor = &vendors.vendors[vendor_idx];
700
701 let num_items = self.rng.gen_range(1..=5).min(materials.materials.len());
703 let selected_materials: Vec<&Material> = materials
704 .materials
705 .iter()
706 .choose_multiple(&mut self.rng, num_items)
707 .into_iter()
708 .collect();
709
710 let po_date =
712 start_date + chrono::Duration::days(self.rng.gen_range(0..=days_range) as i64);
713 let fiscal_period = self.get_fiscal_period(po_date);
714
715 let chain = self.generate_chain(
716 company_code,
717 vendor,
718 &selected_materials,
719 po_date,
720 fiscal_year,
721 fiscal_period,
722 created_by,
723 );
724
725 chains.push(chain);
726 }
727
728 chains
729 }
730
731 fn calculate_gr_date(&mut self, po_date: NaiveDate) -> NaiveDate {
733 let variance = self.rng.gen_range(0..5) as i64;
734 po_date + chrono::Duration::days(self.config.avg_days_po_to_gr as i64 + variance)
735 }
736
737 fn calculate_invoice_date(&mut self, gr_date: NaiveDate) -> NaiveDate {
739 let variance = self.rng.gen_range(0..3) as i64;
740 gr_date + chrono::Duration::days(self.config.avg_days_gr_to_invoice as i64 + variance)
741 }
742
743 fn calculate_payment_date(
745 &mut self,
746 invoice_date: NaiveDate,
747 payment_terms: &PaymentTerms,
748 ) -> NaiveDate {
749 let due_days = payment_terms.net_days() as i64;
750 let due_date = invoice_date + chrono::Duration::days(due_days);
751
752 if self.rng.gen::<f64>() < self.config.payment_behavior.late_payment_rate {
754 let late_days = self.calculate_late_days();
756 due_date + chrono::Duration::days(late_days as i64)
757 } else {
758 let variance = self.rng.gen_range(-5..=5) as i64;
760 due_date + chrono::Duration::days(variance)
761 }
762 }
763
764 fn calculate_late_days(&mut self) -> u32 {
766 let roll: f64 = self.rng.gen();
767 let dist = &self.config.payment_behavior.late_payment_distribution;
768
769 let mut cumulative = 0.0;
770
771 cumulative += dist.slightly_late_1_to_7;
772 if roll < cumulative {
773 return self.rng.gen_range(1..=7);
774 }
775
776 cumulative += dist.late_8_to_14;
777 if roll < cumulative {
778 return self.rng.gen_range(8..=14);
779 }
780
781 cumulative += dist.very_late_15_to_30;
782 if roll < cumulative {
783 return self.rng.gen_range(15..=30);
784 }
785
786 cumulative += dist.severely_late_31_to_60;
787 if roll < cumulative {
788 return self.rng.gen_range(31..=60);
789 }
790
791 self.rng.gen_range(61..=120)
793 }
794
795 fn calculate_due_date(
797 &self,
798 invoice_date: NaiveDate,
799 payment_terms: &PaymentTerms,
800 ) -> NaiveDate {
801 invoice_date + chrono::Duration::days(payment_terms.net_days() as i64)
802 }
803
804 fn select_payment_method(&mut self) -> PaymentMethod {
806 let roll: f64 = self.rng.gen();
807 let mut cumulative = 0.0;
808
809 for (method, prob) in &self.config.payment_method_distribution {
810 cumulative += prob;
811 if roll < cumulative {
812 return *method;
813 }
814 }
815
816 PaymentMethod::BankTransfer
817 }
818
819 fn get_fiscal_period(&self, date: NaiveDate) -> u8 {
821 date.month() as u8
822 }
823
824 pub fn reset(&mut self) {
826 self.rng = ChaCha8Rng::seed_from_u64(self.seed);
827 self.po_counter = 0;
828 self.gr_counter = 0;
829 self.vi_counter = 0;
830 self.pay_counter = 0;
831 }
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837 use datasynth_core::models::documents::DocumentStatus;
838 use datasynth_core::models::MaterialType;
839
840 fn create_test_vendor() -> Vendor {
841 Vendor::new(
842 "V-000001",
843 "Test Vendor Inc.",
844 datasynth_core::models::VendorType::Supplier,
845 )
846 }
847
848 fn create_test_materials() -> Vec<Material> {
849 vec![
850 Material::new("MAT-001", "Test Material 1", MaterialType::RawMaterial)
851 .with_standard_cost(Decimal::from(100)),
852 Material::new("MAT-002", "Test Material 2", MaterialType::RawMaterial)
853 .with_standard_cost(Decimal::from(50)),
854 ]
855 }
856
857 #[test]
858 fn test_p2p_chain_generation() {
859 let mut gen = P2PGenerator::new(42);
860 let vendor = create_test_vendor();
861 let materials = create_test_materials();
862 let material_refs: Vec<&Material> = materials.iter().collect();
863
864 let chain = gen.generate_chain(
865 "1000",
866 &vendor,
867 &material_refs,
868 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
869 2024,
870 1,
871 "JSMITH",
872 );
873
874 assert!(!chain.purchase_order.items.is_empty());
875 assert!(!chain.goods_receipts.is_empty());
876 assert!(chain.vendor_invoice.is_some());
877 assert!(chain.payment.is_some());
878 assert!(chain.is_complete);
879 }
880
881 #[test]
882 fn test_purchase_order_generation() {
883 let mut gen = P2PGenerator::new(42);
884 let vendor = create_test_vendor();
885 let materials = create_test_materials();
886 let material_refs: Vec<&Material> = materials.iter().collect();
887
888 let po = gen.generate_purchase_order(
889 "1000",
890 &vendor,
891 &material_refs,
892 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
893 2024,
894 1,
895 "JSMITH",
896 );
897
898 assert_eq!(po.vendor_id, "V-000001");
899 assert_eq!(po.items.len(), 2);
900 assert!(po.total_net_amount > Decimal::ZERO);
901 assert_eq!(po.header.status, DocumentStatus::Released);
902 }
903
904 #[test]
905 fn test_document_references() {
906 let mut gen = P2PGenerator::new(42);
907 let vendor = create_test_vendor();
908 let materials = create_test_materials();
909 let material_refs: Vec<&Material> = materials.iter().collect();
910
911 let chain = gen.generate_chain(
912 "1000",
913 &vendor,
914 &material_refs,
915 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
916 2024,
917 1,
918 "JSMITH",
919 );
920
921 let gr = &chain.goods_receipts[0];
923 assert!(!gr.header.document_references.is_empty());
924
925 if let Some(invoice) = &chain.vendor_invoice {
927 assert!(invoice.header.document_references.len() >= 2);
928 }
929 }
930
931 #[test]
932 fn test_deterministic_generation() {
933 let vendor = create_test_vendor();
934 let materials = create_test_materials();
935 let material_refs: Vec<&Material> = materials.iter().collect();
936
937 let mut gen1 = P2PGenerator::new(42);
938 let mut gen2 = P2PGenerator::new(42);
939
940 let chain1 = gen1.generate_chain(
941 "1000",
942 &vendor,
943 &material_refs,
944 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
945 2024,
946 1,
947 "JSMITH",
948 );
949 let chain2 = gen2.generate_chain(
950 "1000",
951 &vendor,
952 &material_refs,
953 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
954 2024,
955 1,
956 "JSMITH",
957 );
958
959 assert_eq!(
960 chain1.purchase_order.header.document_id,
961 chain2.purchase_order.header.document_id
962 );
963 assert_eq!(
964 chain1.purchase_order.total_net_amount,
965 chain2.purchase_order.total_net_amount
966 );
967 }
968
969 #[test]
970 fn test_partial_delivery_config() {
971 let config = P2PGeneratorConfig {
972 partial_delivery_rate: 1.0, ..Default::default()
974 };
975
976 let mut gen = P2PGenerator::with_config(42, config);
977 let vendor = create_test_vendor();
978 let materials = create_test_materials();
979 let material_refs: Vec<&Material> = materials.iter().collect();
980
981 let chain = gen.generate_chain(
982 "1000",
983 &vendor,
984 &material_refs,
985 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
986 2024,
987 1,
988 "JSMITH",
989 );
990
991 assert!(chain.goods_receipts.len() >= 2);
993 }
994}