1use chrono::{Datelike, NaiveDate};
7use datasynth_core::models::{
8 documents::{
9 CustomerInvoice, CustomerInvoiceItem, Delivery, DeliveryItem, DocumentReference,
10 DocumentType, Payment, PaymentMethod, ReferenceType, SalesOrder, SalesOrderItem,
11 },
12 subledger::ar::{
13 OnAccountPayment, OnAccountReason, PaymentCorrection, PaymentCorrectionType, ShortPayment,
14 ShortPaymentReasonCode,
15 },
16 CreditRating, Customer, CustomerPool, Material, MaterialPool, PaymentTerms,
17};
18use datasynth_core::utils::seeded_rng;
19use datasynth_core::CountryPack;
20use rand::prelude::*;
21use rand_chacha::ChaCha8Rng;
22use rust_decimal::Decimal;
23
24#[derive(Debug, Clone)]
26pub struct O2CGeneratorConfig {
27 pub credit_check_failure_rate: f64,
29 pub partial_shipment_rate: f64,
31 pub avg_days_so_to_delivery: u32,
33 pub avg_days_delivery_to_invoice: u32,
35 pub avg_days_invoice_to_payment: u32,
37 pub late_payment_rate: f64,
39 pub bad_debt_rate: f64,
41 pub returns_rate: f64,
43 pub cash_discount_take_rate: f64,
45 pub payment_method_distribution: Vec<(PaymentMethod, f64)>,
47 pub payment_behavior: O2CPaymentBehavior,
49}
50
51#[derive(Debug, Clone)]
53pub struct O2CPaymentBehavior {
54 pub partial_payment_rate: f64,
56 pub short_payment_rate: f64,
58 pub max_short_percent: f64,
60 pub on_account_rate: f64,
62 pub payment_correction_rate: f64,
64 pub avg_days_until_remainder: u32,
66}
67
68impl Default for O2CPaymentBehavior {
69 fn default() -> Self {
70 Self {
71 partial_payment_rate: 0.08,
72 short_payment_rate: 0.03,
73 max_short_percent: 0.10,
74 on_account_rate: 0.02,
75 payment_correction_rate: 0.02,
76 avg_days_until_remainder: 30,
77 }
78 }
79}
80
81impl Default for O2CGeneratorConfig {
82 fn default() -> Self {
83 Self {
84 credit_check_failure_rate: 0.02,
85 partial_shipment_rate: 0.08,
86 avg_days_so_to_delivery: 5,
87 avg_days_delivery_to_invoice: 1,
88 avg_days_invoice_to_payment: 30,
89 late_payment_rate: 0.15,
90 bad_debt_rate: 0.02,
91 returns_rate: 0.03,
92 cash_discount_take_rate: 0.25,
93 payment_method_distribution: vec![
94 (PaymentMethod::BankTransfer, 0.50),
95 (PaymentMethod::Check, 0.30),
96 (PaymentMethod::Wire, 0.15),
97 (PaymentMethod::CreditCard, 0.05),
98 ],
99 payment_behavior: O2CPaymentBehavior::default(),
100 }
101 }
102}
103
104#[derive(Debug, Clone)]
106pub struct O2CDocumentChain {
107 pub sales_order: SalesOrder,
109 pub deliveries: Vec<Delivery>,
111 pub customer_invoice: Option<CustomerInvoice>,
113 pub customer_receipt: Option<Payment>,
115 pub is_complete: bool,
117 pub credit_check_passed: bool,
119 pub is_return: bool,
121 pub payment_events: Vec<PaymentEvent>,
123}
124
125#[derive(Debug, Clone)]
127pub enum PaymentEvent {
128 FullPayment(Payment),
130 PartialPayment {
132 payment: Payment,
133 remaining_amount: Decimal,
134 expected_remainder_date: Option<NaiveDate>,
135 },
136 ShortPayment {
138 payment: Payment,
139 short_payment: ShortPayment,
140 },
141 OnAccountPayment(OnAccountPayment),
143 PaymentCorrection {
145 original_payment: Payment,
146 correction: PaymentCorrection,
147 },
148 RemainderPayment(Payment),
150}
151
152pub struct O2CGenerator {
154 rng: ChaCha8Rng,
155 seed: u64,
156 config: O2CGeneratorConfig,
157 so_counter: usize,
158 dlv_counter: usize,
159 ci_counter: usize,
160 rec_counter: usize,
161 short_payment_counter: usize,
162 on_account_counter: usize,
163 correction_counter: usize,
164 country_pack: Option<CountryPack>,
165}
166
167impl O2CGenerator {
168 pub fn new(seed: u64) -> Self {
170 Self::with_config(seed, O2CGeneratorConfig::default())
171 }
172
173 pub fn with_config(seed: u64, config: O2CGeneratorConfig) -> Self {
175 Self {
176 rng: seeded_rng(seed, 0),
177 seed,
178 config,
179 so_counter: 0,
180 dlv_counter: 0,
181 ci_counter: 0,
182 rec_counter: 0,
183 short_payment_counter: 0,
184 on_account_counter: 0,
185 correction_counter: 0,
186 country_pack: None,
187 }
188 }
189
190 pub fn set_country_pack(&mut self, pack: CountryPack) {
192 self.country_pack = Some(pack);
193 }
194
195 fn make_doc_id(
197 &self,
198 default_prefix: &str,
199 pack_key: &str,
200 company_code: &str,
201 counter: usize,
202 ) -> String {
203 let prefix = self
204 .country_pack
205 .as_ref()
206 .map(|p| {
207 let grp = match pack_key {
208 "sales_order" => &p.document_texts.sales_order,
209 "delivery" => &p.document_texts.delivery,
210 "customer_invoice" => &p.document_texts.customer_invoice,
211 "customer_receipt" => &p.document_texts.customer_receipt,
212 _ => return default_prefix.to_string(),
213 };
214 if grp.reference_prefix.is_empty() {
215 default_prefix.to_string()
216 } else {
217 grp.reference_prefix.clone()
218 }
219 })
220 .unwrap_or_else(|| default_prefix.to_string());
221 format!("{}-{}-{:010}", prefix, company_code, counter)
222 }
223
224 fn pick_line_description(&mut self, pack_key: &str, default: &str) -> String {
227 if let Some(pack) = &self.country_pack {
228 let descriptions = match pack_key {
229 "sales_order" => &pack.document_texts.sales_order.line_descriptions,
230 "delivery" => &pack.document_texts.delivery.line_descriptions,
231 "customer_invoice" => &pack.document_texts.customer_invoice.line_descriptions,
232 "customer_receipt" => &pack.document_texts.customer_receipt.line_descriptions,
233 _ => return default.to_string(),
234 };
235 if !descriptions.is_empty() {
236 let idx = self.rng.random_range(0..descriptions.len());
237 return descriptions[idx].clone();
238 }
239 }
240 default.to_string()
241 }
242
243 pub fn generate_chain(
245 &mut self,
246 company_code: &str,
247 customer: &Customer,
248 materials: &[&Material],
249 so_date: NaiveDate,
250 fiscal_year: u16,
251 fiscal_period: u8,
252 created_by: &str,
253 ) -> O2CDocumentChain {
254 let mut so = self.generate_sales_order(
256 company_code,
257 customer,
258 materials,
259 so_date,
260 fiscal_year,
261 fiscal_period,
262 created_by,
263 );
264
265 let credit_check_passed = self.perform_credit_check(customer, so.total_gross_amount);
267 so.check_credit(
268 credit_check_passed,
269 if !credit_check_passed {
270 Some("Credit limit exceeded".to_string())
271 } else {
272 None
273 },
274 );
275
276 if !credit_check_passed {
278 return O2CDocumentChain {
279 sales_order: so,
280 deliveries: Vec::new(),
281 customer_invoice: None,
282 customer_receipt: None,
283 is_complete: false,
284 credit_check_passed: false,
285 is_return: false,
286 payment_events: Vec::new(),
287 };
288 }
289
290 so.release_for_delivery();
292
293 let delivery_date = self.calculate_delivery_date(so_date);
295 let delivery_fiscal_period = self.get_fiscal_period(delivery_date);
296
297 let deliveries = self.generate_deliveries(
299 &so,
300 company_code,
301 customer,
302 delivery_date,
303 fiscal_year,
304 delivery_fiscal_period,
305 created_by,
306 );
307
308 let invoice_date = self.calculate_invoice_date(delivery_date);
310 let invoice_fiscal_period = self.get_fiscal_period(invoice_date);
311
312 so.release_for_billing();
314
315 let customer_invoice = if !deliveries.is_empty() {
317 Some(self.generate_customer_invoice(
318 &so,
319 &deliveries,
320 company_code,
321 customer,
322 invoice_date,
323 fiscal_year,
324 invoice_fiscal_period,
325 created_by,
326 ))
327 } else {
328 None
329 };
330
331 let will_pay = self.rng.random::<f64>() >= self.config.bad_debt_rate;
333
334 let mut payment_events = Vec::new();
336 let mut customer_receipt = None;
337
338 if will_pay {
339 if let Some(ref invoice) = customer_invoice {
340 let payment_date =
341 self.calculate_payment_date(invoice_date, &customer.payment_terms, customer);
342 let payment_fiscal_period = self.get_fiscal_period(payment_date);
343
344 let payment_type = self.determine_payment_type();
345
346 match payment_type {
347 PaymentType::Partial => {
348 let payment_percent = self.determine_partial_payment_percent();
349 let (payment, remaining, expected_date) = self.generate_partial_payment(
350 invoice,
351 company_code,
352 customer,
353 payment_date,
354 fiscal_year,
355 payment_fiscal_period,
356 created_by,
357 payment_percent,
358 );
359
360 payment_events.push(PaymentEvent::PartialPayment {
361 payment: payment.clone(),
362 remaining_amount: remaining,
363 expected_remainder_date: expected_date,
364 });
365 customer_receipt = Some(payment);
366 }
367 PaymentType::Short => {
368 let (payment, short) = self.generate_short_payment(
369 invoice,
370 company_code,
371 customer,
372 payment_date,
373 fiscal_year,
374 payment_fiscal_period,
375 created_by,
376 );
377
378 payment_events.push(PaymentEvent::ShortPayment {
379 payment: payment.clone(),
380 short_payment: short,
381 });
382 customer_receipt = Some(payment);
383 }
384 PaymentType::OnAccount => {
385 let amount = invoice.total_gross_amount
387 * Decimal::from_f64_retain(0.8 + self.rng.random::<f64>() * 0.4)
388 .unwrap_or(Decimal::ONE);
389 let (payment, on_account) = self.generate_on_account_payment(
390 company_code,
391 customer,
392 payment_date,
393 fiscal_year,
394 payment_fiscal_period,
395 created_by,
396 &invoice.header.currency,
397 amount.round_dp(2),
398 );
399
400 payment_events.push(PaymentEvent::OnAccountPayment(on_account));
401 customer_receipt = Some(payment);
402 }
403 PaymentType::Full => {
404 let payment = self.generate_customer_receipt(
405 invoice,
406 company_code,
407 customer,
408 payment_date,
409 fiscal_year,
410 payment_fiscal_period,
411 created_by,
412 );
413
414 if self.rng.random::<f64>()
416 < self.config.payment_behavior.payment_correction_rate
417 {
418 let correction_date = payment_date
419 + chrono::Duration::days(self.rng.random_range(3..14) as i64);
420
421 let correction = self.generate_payment_correction(
422 &payment,
423 company_code,
424 &customer.customer_id,
425 correction_date,
426 &invoice.header.currency,
427 );
428
429 payment_events.push(PaymentEvent::PaymentCorrection {
430 original_payment: payment.clone(),
431 correction,
432 });
433 } else {
434 payment_events.push(PaymentEvent::FullPayment(payment.clone()));
435 }
436
437 customer_receipt = Some(payment);
438 }
439 }
440 }
441 }
442
443 let is_complete = customer_receipt.is_some()
444 && payment_events.iter().all(|e| {
445 !matches!(
446 e,
447 PaymentEvent::PartialPayment { .. } | PaymentEvent::PaymentCorrection { .. }
448 )
449 });
450
451 O2CDocumentChain {
452 sales_order: so,
453 deliveries,
454 customer_invoice,
455 customer_receipt,
456 is_complete,
457 credit_check_passed: true,
458 is_return: false,
459 payment_events,
460 }
461 }
462
463 pub fn generate_sales_order(
465 &mut self,
466 company_code: &str,
467 customer: &Customer,
468 materials: &[&Material],
469 so_date: NaiveDate,
470 fiscal_year: u16,
471 fiscal_period: u8,
472 created_by: &str,
473 ) -> SalesOrder {
474 self.so_counter += 1;
475
476 let so_id = self.make_doc_id("SO", "sales_order", company_code, self.so_counter);
477
478 let requested_delivery =
479 so_date + chrono::Duration::days(self.config.avg_days_so_to_delivery as i64);
480
481 let mut so = SalesOrder::new(
482 so_id,
483 company_code,
484 &customer.customer_id,
485 fiscal_year,
486 fiscal_period,
487 so_date,
488 created_by,
489 )
490 .with_requested_delivery_date(requested_delivery);
491
492 for (idx, material) in materials.iter().enumerate() {
494 let quantity = Decimal::from(self.rng.random_range(1..50));
495 let unit_price = material.list_price;
496
497 let description = self.pick_line_description("sales_order", &material.description);
498 let mut item =
499 SalesOrderItem::new((idx + 1) as u16 * 10, &description, quantity, unit_price)
500 .with_material(&material.material_id);
501
502 item.add_schedule_line(requested_delivery, quantity);
504
505 so.add_item(item);
506 }
507
508 so
509 }
510
511 fn generate_deliveries(
513 &mut self,
514 so: &SalesOrder,
515 company_code: &str,
516 customer: &Customer,
517 delivery_date: NaiveDate,
518 fiscal_year: u16,
519 fiscal_period: u8,
520 created_by: &str,
521 ) -> Vec<Delivery> {
522 let mut deliveries = Vec::new();
523
524 let is_partial = self.rng.random::<f64>() < self.config.partial_shipment_rate;
526
527 if is_partial {
528 let first_pct = 0.6 + self.rng.random::<f64>() * 0.2;
530 let dlv1 = self.create_delivery(
531 so,
532 company_code,
533 customer,
534 delivery_date,
535 fiscal_year,
536 fiscal_period,
537 created_by,
538 first_pct,
539 );
540 deliveries.push(dlv1);
541
542 let second_date =
544 delivery_date + chrono::Duration::days(self.rng.random_range(3..7) as i64);
545 let second_period = self.get_fiscal_period(second_date);
546 let dlv2 = self.create_delivery(
547 so,
548 company_code,
549 customer,
550 second_date,
551 fiscal_year,
552 second_period,
553 created_by,
554 1.0 - first_pct,
555 );
556 deliveries.push(dlv2);
557 } else {
558 let dlv = self.create_delivery(
560 so,
561 company_code,
562 customer,
563 delivery_date,
564 fiscal_year,
565 fiscal_period,
566 created_by,
567 1.0,
568 );
569 deliveries.push(dlv);
570 }
571
572 deliveries
573 }
574
575 fn create_delivery(
577 &mut self,
578 so: &SalesOrder,
579 company_code: &str,
580 customer: &Customer,
581 delivery_date: NaiveDate,
582 fiscal_year: u16,
583 fiscal_period: u8,
584 created_by: &str,
585 quantity_pct: f64,
586 ) -> Delivery {
587 self.dlv_counter += 1;
588
589 let dlv_id = self.make_doc_id("DLV", "delivery", company_code, self.dlv_counter);
590
591 let mut delivery = Delivery::from_sales_order(
592 dlv_id,
593 company_code,
594 &so.header.document_id,
595 &customer.customer_id,
596 format!("SP{}", company_code),
597 fiscal_year,
598 fiscal_period,
599 delivery_date,
600 created_by,
601 );
602
603 for so_item in &so.items {
605 let ship_qty = (so_item.base.quantity
606 * Decimal::from_f64_retain(quantity_pct).unwrap_or(Decimal::ONE))
607 .round_dp(0);
608
609 if ship_qty > Decimal::ZERO {
610 let cogs_pct = 0.60 + self.rng.random::<f64>() * 0.10;
612 let cogs = (so_item.base.unit_price
613 * ship_qty
614 * Decimal::from_f64_retain(cogs_pct)
615 .unwrap_or(Decimal::from_f64_retain(0.65).expect("valid decimal literal")))
616 .round_dp(2);
617
618 let dlv_description =
619 self.pick_line_description("delivery", &so_item.base.description);
620 let mut item = DeliveryItem::from_sales_order(
621 so_item.base.line_number,
622 &dlv_description,
623 ship_qty,
624 so_item.base.unit_price,
625 &so.header.document_id,
626 so_item.base.line_number,
627 )
628 .with_cogs(cogs);
629
630 if let Some(material_id) = &so_item.base.material_id {
631 item = item.with_material(material_id);
632 }
633
634 item.record_pick(ship_qty);
636
637 delivery.add_item(item);
638 }
639 }
640
641 delivery.release_for_picking(created_by);
643 delivery.confirm_pick();
644 delivery.confirm_pack(self.rng.random_range(1..10));
645 delivery.post_goods_issue(created_by, delivery_date);
646
647 delivery
648 }
649
650 fn generate_customer_invoice(
652 &mut self,
653 so: &SalesOrder,
654 deliveries: &[Delivery],
655 company_code: &str,
656 customer: &Customer,
657 invoice_date: NaiveDate,
658 fiscal_year: u16,
659 fiscal_period: u8,
660 created_by: &str,
661 ) -> CustomerInvoice {
662 self.ci_counter += 1;
663
664 let invoice_id = self.make_doc_id("CI", "customer_invoice", company_code, self.ci_counter);
665
666 let due_date = self.calculate_due_date(invoice_date, &customer.payment_terms);
668
669 let mut invoice = CustomerInvoice::from_delivery(
670 invoice_id,
671 company_code,
672 &deliveries[0].header.document_id,
673 &customer.customer_id,
674 fiscal_year,
675 fiscal_period,
676 invoice_date,
677 due_date,
678 created_by,
679 )
680 .with_payment_terms(
681 customer.payment_terms.code(),
682 customer.payment_terms.discount_days(),
683 customer.payment_terms.discount_percent(),
684 );
685
686 let mut delivered_quantities: std::collections::HashMap<u16, (Decimal, Decimal)> =
688 std::collections::HashMap::new();
689
690 for dlv in deliveries {
691 for dlv_item in &dlv.items {
692 let entry = delivered_quantities
693 .entry(dlv_item.base.line_number)
694 .or_insert((Decimal::ZERO, Decimal::ZERO));
695 entry.0 += dlv_item.base.quantity;
696 entry.1 += dlv_item.cogs_amount;
697 }
698 }
699
700 for so_item in &so.items {
702 if let Some(&(qty, cogs)) = delivered_quantities.get(&so_item.base.line_number) {
703 let ci_description =
704 self.pick_line_description("customer_invoice", &so_item.base.description);
705 let item = CustomerInvoiceItem::from_delivery(
706 so_item.base.line_number,
707 &ci_description,
708 qty,
709 so_item.base.unit_price,
710 &deliveries[0].header.document_id,
711 so_item.base.line_number,
712 )
713 .with_cogs(cogs)
714 .with_sales_order(&so.header.document_id, so_item.base.line_number);
715
716 invoice.add_item(item);
717 }
718 }
719
720 invoice.header.add_reference(DocumentReference::new(
722 DocumentType::SalesOrder,
723 &so.header.document_id,
724 DocumentType::CustomerInvoice,
725 &invoice.header.document_id,
726 ReferenceType::FollowOn,
727 company_code,
728 invoice_date,
729 ));
730
731 for dlv in deliveries {
733 invoice.header.add_reference(DocumentReference::new(
734 DocumentType::Delivery,
735 &dlv.header.document_id,
736 DocumentType::CustomerInvoice,
737 &invoice.header.document_id,
738 ReferenceType::FollowOn,
739 company_code,
740 invoice_date,
741 ));
742 }
743
744 invoice.post(created_by, invoice_date);
746
747 invoice
748 }
749
750 fn generate_customer_receipt(
752 &mut self,
753 invoice: &CustomerInvoice,
754 company_code: &str,
755 customer: &Customer,
756 payment_date: NaiveDate,
757 fiscal_year: u16,
758 fiscal_period: u8,
759 created_by: &str,
760 ) -> Payment {
761 self.rec_counter += 1;
762
763 let receipt_id =
764 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
765
766 let take_discount = invoice.discount_date_1.is_some_and(|disc_date| {
768 payment_date <= disc_date
769 && self.rng.random::<f64>() < self.config.cash_discount_take_rate
770 });
771
772 let discount_amount = if take_discount {
773 invoice.cash_discount_available(payment_date)
774 } else {
775 Decimal::ZERO
776 };
777
778 let payment_amount = invoice.amount_open - discount_amount;
779
780 let mut receipt = Payment::new_ar_receipt(
781 receipt_id,
782 company_code,
783 &customer.customer_id,
784 payment_amount,
785 fiscal_year,
786 fiscal_period,
787 payment_date,
788 created_by,
789 )
790 .with_payment_method(self.select_payment_method())
791 .with_value_date(payment_date);
792
793 receipt.allocate_to_invoice(
795 &invoice.header.document_id,
796 DocumentType::CustomerInvoice,
797 payment_amount,
798 discount_amount,
799 );
800
801 receipt.header.add_reference(DocumentReference::new(
803 DocumentType::CustomerReceipt,
804 &receipt.header.document_id,
805 DocumentType::CustomerInvoice,
806 &invoice.header.document_id,
807 ReferenceType::Payment,
808 &receipt.header.company_code,
809 payment_date,
810 ));
811
812 receipt.post(created_by, payment_date);
814
815 receipt
816 }
817
818 pub fn generate_chains(
820 &mut self,
821 count: usize,
822 company_code: &str,
823 customers: &CustomerPool,
824 materials: &MaterialPool,
825 date_range: (NaiveDate, NaiveDate),
826 fiscal_year: u16,
827 created_by: &str,
828 ) -> Vec<O2CDocumentChain> {
829 tracing::debug!(count, company_code, "Generating O2C document chains");
830 let mut chains = Vec::new();
831
832 let (start_date, end_date) = date_range;
833 let days_range = (end_date - start_date).num_days() as u64;
834
835 for _ in 0..count {
836 let customer_idx = self.rng.random_range(0..customers.customers.len());
838 let customer = &customers.customers[customer_idx];
839
840 let num_items = self.rng.random_range(1..=5).min(materials.materials.len());
842 let selected_materials: Vec<&Material> = materials
843 .materials
844 .iter()
845 .choose_multiple(&mut self.rng, num_items)
846 .into_iter()
847 .collect();
848
849 let so_date =
851 start_date + chrono::Duration::days(self.rng.random_range(0..=days_range) as i64);
852 let fiscal_period = self.get_fiscal_period(so_date);
853
854 let chain = self.generate_chain(
855 company_code,
856 customer,
857 &selected_materials,
858 so_date,
859 fiscal_year,
860 fiscal_period,
861 created_by,
862 );
863
864 chains.push(chain);
865 }
866
867 chains
868 }
869
870 fn perform_credit_check(&mut self, customer: &Customer, order_amount: Decimal) -> bool {
872 if !customer.can_place_order(order_amount) {
874 return false;
875 }
876
877 let fail_roll = self.rng.random::<f64>();
879 if fail_roll < self.config.credit_check_failure_rate {
880 return false;
881 }
882
883 let additional_fail_rate = match customer.credit_rating {
885 CreditRating::CCC | CreditRating::D => 0.20,
886 CreditRating::B | CreditRating::BB => 0.05,
887 _ => 0.0,
888 };
889
890 self.rng.random::<f64>() >= additional_fail_rate
891 }
892
893 fn calculate_delivery_date(&mut self, so_date: NaiveDate) -> NaiveDate {
895 let variance = self.rng.random_range(0..3) as i64;
896 so_date + chrono::Duration::days(self.config.avg_days_so_to_delivery as i64 + variance)
897 }
898
899 fn calculate_invoice_date(&mut self, delivery_date: NaiveDate) -> NaiveDate {
901 let variance = self.rng.random_range(0..2) as i64;
902 delivery_date
903 + chrono::Duration::days(self.config.avg_days_delivery_to_invoice as i64 + variance)
904 }
905
906 fn calculate_payment_date(
908 &mut self,
909 invoice_date: NaiveDate,
910 payment_terms: &PaymentTerms,
911 customer: &Customer,
912 ) -> NaiveDate {
913 let base_days = payment_terms.net_days() as i64;
914
915 let behavior_adjustment = match customer.payment_behavior {
917 datasynth_core::models::CustomerPaymentBehavior::Excellent
918 | datasynth_core::models::CustomerPaymentBehavior::EarlyPayer => {
919 -self.rng.random_range(5..15) as i64
920 }
921 datasynth_core::models::CustomerPaymentBehavior::Good
922 | datasynth_core::models::CustomerPaymentBehavior::OnTime => {
923 self.rng.random_range(-2..3) as i64
924 }
925 datasynth_core::models::CustomerPaymentBehavior::Fair
926 | datasynth_core::models::CustomerPaymentBehavior::SlightlyLate => {
927 self.rng.random_range(5..15) as i64
928 }
929 datasynth_core::models::CustomerPaymentBehavior::Poor
930 | datasynth_core::models::CustomerPaymentBehavior::OftenLate => {
931 self.rng.random_range(15..45) as i64
932 }
933 datasynth_core::models::CustomerPaymentBehavior::VeryPoor
934 | datasynth_core::models::CustomerPaymentBehavior::HighRisk => {
935 self.rng.random_range(30..90) as i64
936 }
937 };
938
939 let late_adjustment = if self.rng.random::<f64>() < self.config.late_payment_rate {
941 self.rng.random_range(10..30) as i64
942 } else {
943 0
944 };
945
946 invoice_date + chrono::Duration::days(base_days + behavior_adjustment + late_adjustment)
947 }
948
949 fn calculate_due_date(
951 &self,
952 invoice_date: NaiveDate,
953 payment_terms: &PaymentTerms,
954 ) -> NaiveDate {
955 invoice_date + chrono::Duration::days(payment_terms.net_days() as i64)
956 }
957
958 fn select_payment_method(&mut self) -> PaymentMethod {
960 let roll: f64 = self.rng.random();
961 let mut cumulative = 0.0;
962
963 for (method, prob) in &self.config.payment_method_distribution {
964 cumulative += prob;
965 if roll < cumulative {
966 return *method;
967 }
968 }
969
970 PaymentMethod::BankTransfer
971 }
972
973 fn get_fiscal_period(&self, date: NaiveDate) -> u8 {
975 date.month() as u8
976 }
977
978 pub fn reset(&mut self) {
980 self.rng = seeded_rng(self.seed, 0);
981 self.so_counter = 0;
982 self.dlv_counter = 0;
983 self.ci_counter = 0;
984 self.rec_counter = 0;
985 self.short_payment_counter = 0;
986 self.on_account_counter = 0;
987 self.correction_counter = 0;
988 }
989
990 pub fn generate_partial_payment(
992 &mut self,
993 invoice: &CustomerInvoice,
994 company_code: &str,
995 customer: &Customer,
996 payment_date: NaiveDate,
997 fiscal_year: u16,
998 fiscal_period: u8,
999 created_by: &str,
1000 payment_percent: f64,
1001 ) -> (Payment, Decimal, Option<NaiveDate>) {
1002 self.rec_counter += 1;
1003
1004 let receipt_id =
1005 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1006
1007 let full_amount = invoice.amount_open;
1008 let payment_amount = (full_amount
1009 * Decimal::from_f64_retain(payment_percent).unwrap_or(Decimal::ONE))
1010 .round_dp(2);
1011 let remaining_amount = full_amount - payment_amount;
1012
1013 let mut receipt = Payment::new_ar_receipt(
1014 receipt_id,
1015 company_code,
1016 &customer.customer_id,
1017 payment_amount,
1018 fiscal_year,
1019 fiscal_period,
1020 payment_date,
1021 created_by,
1022 )
1023 .with_payment_method(self.select_payment_method())
1024 .with_value_date(payment_date);
1025
1026 receipt.allocate_to_invoice(
1028 &invoice.header.document_id,
1029 DocumentType::CustomerInvoice,
1030 payment_amount,
1031 Decimal::ZERO, );
1033
1034 receipt.header.add_reference(DocumentReference::new(
1036 DocumentType::CustomerReceipt,
1037 &receipt.header.document_id,
1038 DocumentType::CustomerInvoice,
1039 &invoice.header.document_id,
1040 ReferenceType::Payment,
1041 &receipt.header.company_code,
1042 payment_date,
1043 ));
1044
1045 receipt.post(created_by, payment_date);
1046
1047 let expected_remainder_date = Some(
1049 payment_date
1050 + chrono::Duration::days(
1051 self.config.payment_behavior.avg_days_until_remainder as i64,
1052 )
1053 + chrono::Duration::days(self.rng.random_range(-7..7) as i64),
1054 );
1055
1056 (receipt, remaining_amount, expected_remainder_date)
1057 }
1058
1059 pub fn generate_short_payment(
1061 &mut self,
1062 invoice: &CustomerInvoice,
1063 company_code: &str,
1064 customer: &Customer,
1065 payment_date: NaiveDate,
1066 fiscal_year: u16,
1067 fiscal_period: u8,
1068 created_by: &str,
1069 ) -> (Payment, ShortPayment) {
1070 self.rec_counter += 1;
1071 self.short_payment_counter += 1;
1072
1073 let receipt_id =
1074 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1075 let short_id = format!("SHORT-{}-{:06}", company_code, self.short_payment_counter);
1076
1077 let full_amount = invoice.amount_open;
1078
1079 let short_percent =
1081 self.rng.random::<f64>() * self.config.payment_behavior.max_short_percent;
1082 let short_amount = (full_amount
1083 * Decimal::from_f64_retain(short_percent).unwrap_or(Decimal::ZERO))
1084 .round_dp(2)
1085 .max(Decimal::ONE); let payment_amount = full_amount - short_amount;
1088
1089 let mut receipt = Payment::new_ar_receipt(
1090 receipt_id.clone(),
1091 company_code,
1092 &customer.customer_id,
1093 payment_amount,
1094 fiscal_year,
1095 fiscal_period,
1096 payment_date,
1097 created_by,
1098 )
1099 .with_payment_method(self.select_payment_method())
1100 .with_value_date(payment_date);
1101
1102 receipt.allocate_to_invoice(
1104 &invoice.header.document_id,
1105 DocumentType::CustomerInvoice,
1106 payment_amount,
1107 Decimal::ZERO,
1108 );
1109
1110 receipt.header.add_reference(DocumentReference::new(
1111 DocumentType::CustomerReceipt,
1112 &receipt.header.document_id,
1113 DocumentType::CustomerInvoice,
1114 &invoice.header.document_id,
1115 ReferenceType::Payment,
1116 &receipt.header.company_code,
1117 payment_date,
1118 ));
1119
1120 receipt.post(created_by, payment_date);
1121
1122 let reason_code = self.select_short_payment_reason();
1124 let short_payment = ShortPayment::new(
1125 short_id,
1126 company_code.to_string(),
1127 customer.customer_id.clone(),
1128 receipt_id,
1129 invoice.header.document_id.clone(),
1130 full_amount,
1131 payment_amount,
1132 invoice.header.currency.clone(),
1133 payment_date,
1134 reason_code,
1135 );
1136
1137 (receipt, short_payment)
1138 }
1139
1140 pub fn generate_on_account_payment(
1142 &mut self,
1143 company_code: &str,
1144 customer: &Customer,
1145 payment_date: NaiveDate,
1146 fiscal_year: u16,
1147 fiscal_period: u8,
1148 created_by: &str,
1149 currency: &str,
1150 amount: Decimal,
1151 ) -> (Payment, OnAccountPayment) {
1152 self.rec_counter += 1;
1153 self.on_account_counter += 1;
1154
1155 let receipt_id =
1156 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1157 let on_account_id = format!("OA-{}-{:06}", company_code, self.on_account_counter);
1158
1159 let mut receipt = Payment::new_ar_receipt(
1160 receipt_id.clone(),
1161 company_code,
1162 &customer.customer_id,
1163 amount,
1164 fiscal_year,
1165 fiscal_period,
1166 payment_date,
1167 created_by,
1168 )
1169 .with_payment_method(self.select_payment_method())
1170 .with_value_date(payment_date);
1171
1172 receipt.post(created_by, payment_date);
1174
1175 let reason = self.select_on_account_reason();
1177 let on_account = OnAccountPayment::new(
1178 on_account_id,
1179 company_code.to_string(),
1180 customer.customer_id.clone(),
1181 receipt_id,
1182 amount,
1183 currency.to_string(),
1184 payment_date,
1185 )
1186 .with_reason(reason);
1187
1188 (receipt, on_account)
1189 }
1190
1191 pub fn generate_payment_correction(
1193 &mut self,
1194 original_payment: &Payment,
1195 company_code: &str,
1196 customer_id: &str,
1197 correction_date: NaiveDate,
1198 currency: &str,
1199 ) -> PaymentCorrection {
1200 self.correction_counter += 1;
1201
1202 let correction_id = format!("CORR-{}-{:06}", company_code, self.correction_counter);
1203
1204 let correction_type = if self.rng.random::<f64>() < 0.6 {
1205 PaymentCorrectionType::NSF
1206 } else {
1207 PaymentCorrectionType::Chargeback
1208 };
1209
1210 let mut correction = PaymentCorrection::new(
1211 correction_id,
1212 company_code.to_string(),
1213 customer_id.to_string(),
1214 original_payment.header.document_id.clone(),
1215 correction_type,
1216 original_payment.amount,
1217 original_payment.amount, currency.to_string(),
1219 correction_date,
1220 );
1221
1222 match correction_type {
1224 PaymentCorrectionType::NSF => {
1225 correction.bank_reference = Some(format!("NSF-{}", self.rng.random::<u32>()));
1226 correction.fee_amount = Decimal::from(35); correction.reason = Some("Payment returned - Insufficient funds".to_string());
1228 }
1229 PaymentCorrectionType::Chargeback => {
1230 correction.chargeback_code =
1231 Some(format!("CB{:04}", self.rng.random_range(1000..9999)));
1232 correction.reason = Some("Credit card chargeback".to_string());
1233 }
1234 _ => {}
1235 }
1236
1237 if let Some(allocation) = original_payment.allocations.first() {
1239 correction.add_affected_invoice(allocation.invoice_id.clone());
1240 }
1241
1242 correction
1243 }
1244
1245 fn select_short_payment_reason(&mut self) -> ShortPaymentReasonCode {
1247 let roll: f64 = self.rng.random();
1248 if roll < 0.30 {
1249 ShortPaymentReasonCode::PricingDispute
1250 } else if roll < 0.50 {
1251 ShortPaymentReasonCode::QualityIssue
1252 } else if roll < 0.70 {
1253 ShortPaymentReasonCode::QuantityDiscrepancy
1254 } else if roll < 0.85 {
1255 ShortPaymentReasonCode::UnauthorizedDeduction
1256 } else {
1257 ShortPaymentReasonCode::IncorrectDiscount
1258 }
1259 }
1260
1261 fn select_on_account_reason(&mut self) -> OnAccountReason {
1263 let roll: f64 = self.rng.random();
1264 if roll < 0.40 {
1265 OnAccountReason::NoInvoiceReference
1266 } else if roll < 0.60 {
1267 OnAccountReason::Overpayment
1268 } else if roll < 0.75 {
1269 OnAccountReason::Prepayment
1270 } else if roll < 0.90 {
1271 OnAccountReason::UnclearRemittance
1272 } else {
1273 OnAccountReason::Other
1274 }
1275 }
1276
1277 fn determine_payment_type(&mut self) -> PaymentType {
1279 let roll: f64 = self.rng.random();
1280 let pb = &self.config.payment_behavior;
1281
1282 let mut cumulative = 0.0;
1283
1284 cumulative += pb.partial_payment_rate;
1285 if roll < cumulative {
1286 return PaymentType::Partial;
1287 }
1288
1289 cumulative += pb.short_payment_rate;
1290 if roll < cumulative {
1291 return PaymentType::Short;
1292 }
1293
1294 cumulative += pb.on_account_rate;
1295 if roll < cumulative {
1296 return PaymentType::OnAccount;
1297 }
1298
1299 PaymentType::Full
1300 }
1301
1302 fn determine_partial_payment_percent(&mut self) -> f64 {
1304 let roll: f64 = self.rng.random();
1305 if roll < 0.15 {
1306 0.25
1307 } else if roll < 0.65 {
1308 0.50
1309 } else if roll < 0.90 {
1310 0.75
1311 } else {
1312 0.30 + self.rng.random::<f64>() * 0.50
1314 }
1315 }
1316}
1317
1318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1320enum PaymentType {
1321 Full,
1322 Partial,
1323 Short,
1324 OnAccount,
1325}
1326
1327#[cfg(test)]
1328#[allow(clippy::unwrap_used)]
1329mod tests {
1330 use super::*;
1331 use datasynth_core::models::{CustomerPaymentBehavior, MaterialType};
1332
1333 fn create_test_customer() -> Customer {
1334 let mut customer = Customer::new(
1335 "C-000001",
1336 "Test Customer Inc.",
1337 datasynth_core::models::CustomerType::Corporate,
1338 );
1339 customer.credit_rating = CreditRating::A;
1340 customer.credit_limit = Decimal::from(1_000_000);
1341 customer.payment_behavior = CustomerPaymentBehavior::OnTime;
1342 customer
1343 }
1344
1345 fn create_test_materials() -> Vec<Material> {
1346 let mut mat1 = Material::new("MAT-001", "Test Product 1", MaterialType::FinishedGood);
1347 mat1.list_price = Decimal::from(100);
1348 mat1.standard_cost = Decimal::from(60);
1349
1350 let mut mat2 = Material::new("MAT-002", "Test Product 2", MaterialType::FinishedGood);
1351 mat2.list_price = Decimal::from(200);
1352 mat2.standard_cost = Decimal::from(120);
1353
1354 vec![mat1, mat2]
1355 }
1356
1357 #[test]
1358 fn test_o2c_chain_generation() {
1359 let mut gen = O2CGenerator::new(42);
1360 let customer = create_test_customer();
1361 let materials = create_test_materials();
1362 let material_refs: Vec<&Material> = materials.iter().collect();
1363
1364 let chain = gen.generate_chain(
1365 "1000",
1366 &customer,
1367 &material_refs,
1368 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1369 2024,
1370 1,
1371 "JSMITH",
1372 );
1373
1374 assert!(!chain.sales_order.items.is_empty());
1375 assert!(chain.credit_check_passed);
1376 assert!(!chain.deliveries.is_empty());
1377 assert!(chain.customer_invoice.is_some());
1378 }
1379
1380 #[test]
1381 fn test_sales_order_generation() {
1382 let mut gen = O2CGenerator::new(42);
1383 let customer = create_test_customer();
1384 let materials = create_test_materials();
1385 let material_refs: Vec<&Material> = materials.iter().collect();
1386
1387 let so = gen.generate_sales_order(
1388 "1000",
1389 &customer,
1390 &material_refs,
1391 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1392 2024,
1393 1,
1394 "JSMITH",
1395 );
1396
1397 assert_eq!(so.customer_id, "C-000001");
1398 assert_eq!(so.items.len(), 2);
1399 assert!(so.total_net_amount > Decimal::ZERO);
1400 }
1401
1402 #[test]
1403 fn test_credit_check_failure() {
1404 let config = O2CGeneratorConfig {
1405 credit_check_failure_rate: 1.0, ..Default::default()
1407 };
1408
1409 let mut gen = O2CGenerator::with_config(42, config);
1410 let customer = create_test_customer();
1411 let materials = create_test_materials();
1412 let material_refs: Vec<&Material> = materials.iter().collect();
1413
1414 let chain = gen.generate_chain(
1415 "1000",
1416 &customer,
1417 &material_refs,
1418 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1419 2024,
1420 1,
1421 "JSMITH",
1422 );
1423
1424 assert!(!chain.credit_check_passed);
1425 assert!(chain.deliveries.is_empty());
1426 assert!(chain.customer_invoice.is_none());
1427 }
1428
1429 #[test]
1430 fn test_document_references() {
1431 let mut gen = O2CGenerator::new(42);
1432 let customer = create_test_customer();
1433 let materials = create_test_materials();
1434 let material_refs: Vec<&Material> = materials.iter().collect();
1435
1436 let chain = gen.generate_chain(
1437 "1000",
1438 &customer,
1439 &material_refs,
1440 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1441 2024,
1442 1,
1443 "JSMITH",
1444 );
1445
1446 if let Some(dlv) = chain.deliveries.first() {
1448 assert!(!dlv.header.document_references.is_empty());
1449 }
1450
1451 if let Some(invoice) = &chain.customer_invoice {
1453 assert!(invoice.header.document_references.len() >= 2);
1454 }
1455 }
1456
1457 #[test]
1458 fn test_deterministic_generation() {
1459 let customer = create_test_customer();
1460 let materials = create_test_materials();
1461 let material_refs: Vec<&Material> = materials.iter().collect();
1462
1463 let mut gen1 = O2CGenerator::new(42);
1464 let mut gen2 = O2CGenerator::new(42);
1465
1466 let chain1 = gen1.generate_chain(
1467 "1000",
1468 &customer,
1469 &material_refs,
1470 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1471 2024,
1472 1,
1473 "JSMITH",
1474 );
1475 let chain2 = gen2.generate_chain(
1476 "1000",
1477 &customer,
1478 &material_refs,
1479 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1480 2024,
1481 1,
1482 "JSMITH",
1483 );
1484
1485 assert_eq!(
1486 chain1.sales_order.header.document_id,
1487 chain2.sales_order.header.document_id
1488 );
1489 assert_eq!(
1490 chain1.sales_order.total_net_amount,
1491 chain2.sales_order.total_net_amount
1492 );
1493 }
1494
1495 #[test]
1496 fn test_partial_shipment_config() {
1497 let config = O2CGeneratorConfig {
1498 partial_shipment_rate: 1.0, ..Default::default()
1500 };
1501
1502 let mut gen = O2CGenerator::with_config(42, config);
1503 let customer = create_test_customer();
1504 let materials = create_test_materials();
1505 let material_refs: Vec<&Material> = materials.iter().collect();
1506
1507 let chain = gen.generate_chain(
1508 "1000",
1509 &customer,
1510 &material_refs,
1511 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1512 2024,
1513 1,
1514 "JSMITH",
1515 );
1516
1517 assert!(chain.deliveries.len() >= 2);
1519 }
1520
1521 #[test]
1522 fn test_gross_margin() {
1523 let mut gen = O2CGenerator::new(42);
1524 let customer = create_test_customer();
1525 let materials = create_test_materials();
1526 let material_refs: Vec<&Material> = materials.iter().collect();
1527
1528 let chain = gen.generate_chain(
1529 "1000",
1530 &customer,
1531 &material_refs,
1532 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1533 2024,
1534 1,
1535 "JSMITH",
1536 );
1537
1538 if let Some(invoice) = &chain.customer_invoice {
1539 let margin = invoice.gross_margin();
1541 assert!(margin > Decimal::ZERO, "Gross margin should be positive");
1542 }
1543 }
1544}