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