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.gen_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.gen::<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.gen::<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.gen::<f64>()
416 < self.config.payment_behavior.payment_correction_rate
417 {
418 let correction_date = payment_date
419 + chrono::Duration::days(self.rng.gen_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.gen_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.gen::<f64>() < self.config.partial_shipment_rate;
526
527 if is_partial {
528 let first_pct = 0.6 + self.rng.gen::<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.gen_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.gen::<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.gen_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 && self.rng.gen::<f64>() < self.config.cash_discount_take_rate
769 });
770
771 let discount_amount = if take_discount {
772 invoice.cash_discount_available(payment_date)
773 } else {
774 Decimal::ZERO
775 };
776
777 let payment_amount = invoice.amount_open - discount_amount;
778
779 let mut receipt = Payment::new_ar_receipt(
780 receipt_id,
781 company_code,
782 &customer.customer_id,
783 payment_amount,
784 fiscal_year,
785 fiscal_period,
786 payment_date,
787 created_by,
788 )
789 .with_payment_method(self.select_payment_method())
790 .with_value_date(payment_date);
791
792 receipt.allocate_to_invoice(
794 &invoice.header.document_id,
795 DocumentType::CustomerInvoice,
796 payment_amount,
797 discount_amount,
798 );
799
800 receipt.header.add_reference(DocumentReference::new(
802 DocumentType::CustomerReceipt,
803 &receipt.header.document_id,
804 DocumentType::CustomerInvoice,
805 &invoice.header.document_id,
806 ReferenceType::Payment,
807 &receipt.header.company_code,
808 payment_date,
809 ));
810
811 receipt.post(created_by, payment_date);
813
814 receipt
815 }
816
817 pub fn generate_chains(
819 &mut self,
820 count: usize,
821 company_code: &str,
822 customers: &CustomerPool,
823 materials: &MaterialPool,
824 date_range: (NaiveDate, NaiveDate),
825 fiscal_year: u16,
826 created_by: &str,
827 ) -> Vec<O2CDocumentChain> {
828 tracing::debug!(count, company_code, "Generating O2C document chains");
829 let mut chains = Vec::new();
830
831 let (start_date, end_date) = date_range;
832 let days_range = (end_date - start_date).num_days() as u64;
833
834 for _ in 0..count {
835 let customer_idx = self.rng.gen_range(0..customers.customers.len());
837 let customer = &customers.customers[customer_idx];
838
839 let num_items = self.rng.gen_range(1..=5).min(materials.materials.len());
841 let selected_materials: Vec<&Material> = materials
842 .materials
843 .iter()
844 .choose_multiple(&mut self.rng, num_items)
845 .into_iter()
846 .collect();
847
848 let so_date =
850 start_date + chrono::Duration::days(self.rng.gen_range(0..=days_range) as i64);
851 let fiscal_period = self.get_fiscal_period(so_date);
852
853 let chain = self.generate_chain(
854 company_code,
855 customer,
856 &selected_materials,
857 so_date,
858 fiscal_year,
859 fiscal_period,
860 created_by,
861 );
862
863 chains.push(chain);
864 }
865
866 chains
867 }
868
869 fn perform_credit_check(&mut self, customer: &Customer, order_amount: Decimal) -> bool {
871 if !customer.can_place_order(order_amount) {
873 return false;
874 }
875
876 let fail_roll = self.rng.gen::<f64>();
878 if fail_roll < self.config.credit_check_failure_rate {
879 return false;
880 }
881
882 let additional_fail_rate = match customer.credit_rating {
884 CreditRating::CCC | CreditRating::D => 0.20,
885 CreditRating::B | CreditRating::BB => 0.05,
886 _ => 0.0,
887 };
888
889 self.rng.gen::<f64>() >= additional_fail_rate
890 }
891
892 fn calculate_delivery_date(&mut self, so_date: NaiveDate) -> NaiveDate {
894 let variance = self.rng.gen_range(0..3) as i64;
895 so_date + chrono::Duration::days(self.config.avg_days_so_to_delivery as i64 + variance)
896 }
897
898 fn calculate_invoice_date(&mut self, delivery_date: NaiveDate) -> NaiveDate {
900 let variance = self.rng.gen_range(0..2) as i64;
901 delivery_date
902 + chrono::Duration::days(self.config.avg_days_delivery_to_invoice as i64 + variance)
903 }
904
905 fn calculate_payment_date(
907 &mut self,
908 invoice_date: NaiveDate,
909 payment_terms: &PaymentTerms,
910 customer: &Customer,
911 ) -> NaiveDate {
912 let base_days = payment_terms.net_days() as i64;
913
914 let behavior_adjustment = match customer.payment_behavior {
916 datasynth_core::models::CustomerPaymentBehavior::Excellent
917 | datasynth_core::models::CustomerPaymentBehavior::EarlyPayer => {
918 -self.rng.gen_range(5..15) as i64
919 }
920 datasynth_core::models::CustomerPaymentBehavior::Good
921 | datasynth_core::models::CustomerPaymentBehavior::OnTime => {
922 self.rng.gen_range(-2..3) as i64
923 }
924 datasynth_core::models::CustomerPaymentBehavior::Fair
925 | datasynth_core::models::CustomerPaymentBehavior::SlightlyLate => {
926 self.rng.gen_range(5..15) as i64
927 }
928 datasynth_core::models::CustomerPaymentBehavior::Poor
929 | datasynth_core::models::CustomerPaymentBehavior::OftenLate => {
930 self.rng.gen_range(15..45) as i64
931 }
932 datasynth_core::models::CustomerPaymentBehavior::VeryPoor
933 | datasynth_core::models::CustomerPaymentBehavior::HighRisk => {
934 self.rng.gen_range(30..90) as i64
935 }
936 };
937
938 let late_adjustment = if self.rng.gen::<f64>() < self.config.late_payment_rate {
940 self.rng.gen_range(10..30) as i64
941 } else {
942 0
943 };
944
945 invoice_date + chrono::Duration::days(base_days + behavior_adjustment + late_adjustment)
946 }
947
948 fn calculate_due_date(
950 &self,
951 invoice_date: NaiveDate,
952 payment_terms: &PaymentTerms,
953 ) -> NaiveDate {
954 invoice_date + chrono::Duration::days(payment_terms.net_days() as i64)
955 }
956
957 fn select_payment_method(&mut self) -> PaymentMethod {
959 let roll: f64 = self.rng.gen();
960 let mut cumulative = 0.0;
961
962 for (method, prob) in &self.config.payment_method_distribution {
963 cumulative += prob;
964 if roll < cumulative {
965 return *method;
966 }
967 }
968
969 PaymentMethod::BankTransfer
970 }
971
972 fn get_fiscal_period(&self, date: NaiveDate) -> u8 {
974 date.month() as u8
975 }
976
977 pub fn reset(&mut self) {
979 self.rng = seeded_rng(self.seed, 0);
980 self.so_counter = 0;
981 self.dlv_counter = 0;
982 self.ci_counter = 0;
983 self.rec_counter = 0;
984 self.short_payment_counter = 0;
985 self.on_account_counter = 0;
986 self.correction_counter = 0;
987 }
988
989 pub fn generate_partial_payment(
991 &mut self,
992 invoice: &CustomerInvoice,
993 company_code: &str,
994 customer: &Customer,
995 payment_date: NaiveDate,
996 fiscal_year: u16,
997 fiscal_period: u8,
998 created_by: &str,
999 payment_percent: f64,
1000 ) -> (Payment, Decimal, Option<NaiveDate>) {
1001 self.rec_counter += 1;
1002
1003 let receipt_id =
1004 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1005
1006 let full_amount = invoice.amount_open;
1007 let payment_amount = (full_amount
1008 * Decimal::from_f64_retain(payment_percent).unwrap_or(Decimal::ONE))
1009 .round_dp(2);
1010 let remaining_amount = full_amount - payment_amount;
1011
1012 let mut receipt = Payment::new_ar_receipt(
1013 receipt_id,
1014 company_code,
1015 &customer.customer_id,
1016 payment_amount,
1017 fiscal_year,
1018 fiscal_period,
1019 payment_date,
1020 created_by,
1021 )
1022 .with_payment_method(self.select_payment_method())
1023 .with_value_date(payment_date);
1024
1025 receipt.allocate_to_invoice(
1027 &invoice.header.document_id,
1028 DocumentType::CustomerInvoice,
1029 payment_amount,
1030 Decimal::ZERO, );
1032
1033 receipt.header.add_reference(DocumentReference::new(
1035 DocumentType::CustomerReceipt,
1036 &receipt.header.document_id,
1037 DocumentType::CustomerInvoice,
1038 &invoice.header.document_id,
1039 ReferenceType::Payment,
1040 &receipt.header.company_code,
1041 payment_date,
1042 ));
1043
1044 receipt.post(created_by, payment_date);
1045
1046 let expected_remainder_date = Some(
1048 payment_date
1049 + chrono::Duration::days(
1050 self.config.payment_behavior.avg_days_until_remainder as i64,
1051 )
1052 + chrono::Duration::days(self.rng.gen_range(-7..7) as i64),
1053 );
1054
1055 (receipt, remaining_amount, expected_remainder_date)
1056 }
1057
1058 pub fn generate_short_payment(
1060 &mut self,
1061 invoice: &CustomerInvoice,
1062 company_code: &str,
1063 customer: &Customer,
1064 payment_date: NaiveDate,
1065 fiscal_year: u16,
1066 fiscal_period: u8,
1067 created_by: &str,
1068 ) -> (Payment, ShortPayment) {
1069 self.rec_counter += 1;
1070 self.short_payment_counter += 1;
1071
1072 let receipt_id =
1073 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1074 let short_id = format!("SHORT-{}-{:06}", company_code, self.short_payment_counter);
1075
1076 let full_amount = invoice.amount_open;
1077
1078 let short_percent = self.rng.gen::<f64>() * self.config.payment_behavior.max_short_percent;
1080 let short_amount = (full_amount
1081 * Decimal::from_f64_retain(short_percent).unwrap_or(Decimal::ZERO))
1082 .round_dp(2)
1083 .max(Decimal::ONE); let payment_amount = full_amount - short_amount;
1086
1087 let mut receipt = Payment::new_ar_receipt(
1088 receipt_id.clone(),
1089 company_code,
1090 &customer.customer_id,
1091 payment_amount,
1092 fiscal_year,
1093 fiscal_period,
1094 payment_date,
1095 created_by,
1096 )
1097 .with_payment_method(self.select_payment_method())
1098 .with_value_date(payment_date);
1099
1100 receipt.allocate_to_invoice(
1102 &invoice.header.document_id,
1103 DocumentType::CustomerInvoice,
1104 payment_amount,
1105 Decimal::ZERO,
1106 );
1107
1108 receipt.header.add_reference(DocumentReference::new(
1109 DocumentType::CustomerReceipt,
1110 &receipt.header.document_id,
1111 DocumentType::CustomerInvoice,
1112 &invoice.header.document_id,
1113 ReferenceType::Payment,
1114 &receipt.header.company_code,
1115 payment_date,
1116 ));
1117
1118 receipt.post(created_by, payment_date);
1119
1120 let reason_code = self.select_short_payment_reason();
1122 let short_payment = ShortPayment::new(
1123 short_id,
1124 company_code.to_string(),
1125 customer.customer_id.clone(),
1126 receipt_id,
1127 invoice.header.document_id.clone(),
1128 full_amount,
1129 payment_amount,
1130 invoice.header.currency.clone(),
1131 payment_date,
1132 reason_code,
1133 );
1134
1135 (receipt, short_payment)
1136 }
1137
1138 pub fn generate_on_account_payment(
1140 &mut self,
1141 company_code: &str,
1142 customer: &Customer,
1143 payment_date: NaiveDate,
1144 fiscal_year: u16,
1145 fiscal_period: u8,
1146 created_by: &str,
1147 currency: &str,
1148 amount: Decimal,
1149 ) -> (Payment, OnAccountPayment) {
1150 self.rec_counter += 1;
1151 self.on_account_counter += 1;
1152
1153 let receipt_id =
1154 self.make_doc_id("REC", "customer_receipt", company_code, self.rec_counter);
1155 let on_account_id = format!("OA-{}-{:06}", company_code, self.on_account_counter);
1156
1157 let mut receipt = Payment::new_ar_receipt(
1158 receipt_id.clone(),
1159 company_code,
1160 &customer.customer_id,
1161 amount,
1162 fiscal_year,
1163 fiscal_period,
1164 payment_date,
1165 created_by,
1166 )
1167 .with_payment_method(self.select_payment_method())
1168 .with_value_date(payment_date);
1169
1170 receipt.post(created_by, payment_date);
1172
1173 let reason = self.select_on_account_reason();
1175 let on_account = OnAccountPayment::new(
1176 on_account_id,
1177 company_code.to_string(),
1178 customer.customer_id.clone(),
1179 receipt_id,
1180 amount,
1181 currency.to_string(),
1182 payment_date,
1183 )
1184 .with_reason(reason);
1185
1186 (receipt, on_account)
1187 }
1188
1189 pub fn generate_payment_correction(
1191 &mut self,
1192 original_payment: &Payment,
1193 company_code: &str,
1194 customer_id: &str,
1195 correction_date: NaiveDate,
1196 currency: &str,
1197 ) -> PaymentCorrection {
1198 self.correction_counter += 1;
1199
1200 let correction_id = format!("CORR-{}-{:06}", company_code, self.correction_counter);
1201
1202 let correction_type = if self.rng.gen::<f64>() < 0.6 {
1203 PaymentCorrectionType::NSF
1204 } else {
1205 PaymentCorrectionType::Chargeback
1206 };
1207
1208 let mut correction = PaymentCorrection::new(
1209 correction_id,
1210 company_code.to_string(),
1211 customer_id.to_string(),
1212 original_payment.header.document_id.clone(),
1213 correction_type,
1214 original_payment.amount,
1215 original_payment.amount, currency.to_string(),
1217 correction_date,
1218 );
1219
1220 match correction_type {
1222 PaymentCorrectionType::NSF => {
1223 correction.bank_reference = Some(format!("NSF-{}", self.rng.gen::<u32>()));
1224 correction.fee_amount = Decimal::from(35); correction.reason = Some("Payment returned - Insufficient funds".to_string());
1226 }
1227 PaymentCorrectionType::Chargeback => {
1228 correction.chargeback_code =
1229 Some(format!("CB{:04}", self.rng.gen_range(1000..9999)));
1230 correction.reason = Some("Credit card chargeback".to_string());
1231 }
1232 _ => {}
1233 }
1234
1235 if let Some(allocation) = original_payment.allocations.first() {
1237 correction.add_affected_invoice(allocation.invoice_id.clone());
1238 }
1239
1240 correction
1241 }
1242
1243 fn select_short_payment_reason(&mut self) -> ShortPaymentReasonCode {
1245 let roll: f64 = self.rng.gen();
1246 if roll < 0.30 {
1247 ShortPaymentReasonCode::PricingDispute
1248 } else if roll < 0.50 {
1249 ShortPaymentReasonCode::QualityIssue
1250 } else if roll < 0.70 {
1251 ShortPaymentReasonCode::QuantityDiscrepancy
1252 } else if roll < 0.85 {
1253 ShortPaymentReasonCode::UnauthorizedDeduction
1254 } else {
1255 ShortPaymentReasonCode::IncorrectDiscount
1256 }
1257 }
1258
1259 fn select_on_account_reason(&mut self) -> OnAccountReason {
1261 let roll: f64 = self.rng.gen();
1262 if roll < 0.40 {
1263 OnAccountReason::NoInvoiceReference
1264 } else if roll < 0.60 {
1265 OnAccountReason::Overpayment
1266 } else if roll < 0.75 {
1267 OnAccountReason::Prepayment
1268 } else if roll < 0.90 {
1269 OnAccountReason::UnclearRemittance
1270 } else {
1271 OnAccountReason::Other
1272 }
1273 }
1274
1275 fn determine_payment_type(&mut self) -> PaymentType {
1277 let roll: f64 = self.rng.gen();
1278 let pb = &self.config.payment_behavior;
1279
1280 let mut cumulative = 0.0;
1281
1282 cumulative += pb.partial_payment_rate;
1283 if roll < cumulative {
1284 return PaymentType::Partial;
1285 }
1286
1287 cumulative += pb.short_payment_rate;
1288 if roll < cumulative {
1289 return PaymentType::Short;
1290 }
1291
1292 cumulative += pb.on_account_rate;
1293 if roll < cumulative {
1294 return PaymentType::OnAccount;
1295 }
1296
1297 PaymentType::Full
1298 }
1299
1300 fn determine_partial_payment_percent(&mut self) -> f64 {
1302 let roll: f64 = self.rng.gen();
1303 if roll < 0.15 {
1304 0.25
1305 } else if roll < 0.65 {
1306 0.50
1307 } else if roll < 0.90 {
1308 0.75
1309 } else {
1310 0.30 + self.rng.gen::<f64>() * 0.50
1312 }
1313 }
1314}
1315
1316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1318enum PaymentType {
1319 Full,
1320 Partial,
1321 Short,
1322 OnAccount,
1323}
1324
1325#[cfg(test)]
1326#[allow(clippy::unwrap_used)]
1327mod tests {
1328 use super::*;
1329 use datasynth_core::models::{CustomerPaymentBehavior, MaterialType};
1330
1331 fn create_test_customer() -> Customer {
1332 let mut customer = Customer::new(
1333 "C-000001",
1334 "Test Customer Inc.",
1335 datasynth_core::models::CustomerType::Corporate,
1336 );
1337 customer.credit_rating = CreditRating::A;
1338 customer.credit_limit = Decimal::from(1_000_000);
1339 customer.payment_behavior = CustomerPaymentBehavior::OnTime;
1340 customer
1341 }
1342
1343 fn create_test_materials() -> Vec<Material> {
1344 let mut mat1 = Material::new("MAT-001", "Test Product 1", MaterialType::FinishedGood);
1345 mat1.list_price = Decimal::from(100);
1346 mat1.standard_cost = Decimal::from(60);
1347
1348 let mut mat2 = Material::new("MAT-002", "Test Product 2", MaterialType::FinishedGood);
1349 mat2.list_price = Decimal::from(200);
1350 mat2.standard_cost = Decimal::from(120);
1351
1352 vec![mat1, mat2]
1353 }
1354
1355 #[test]
1356 fn test_o2c_chain_generation() {
1357 let mut gen = O2CGenerator::new(42);
1358 let customer = create_test_customer();
1359 let materials = create_test_materials();
1360 let material_refs: Vec<&Material> = materials.iter().collect();
1361
1362 let chain = gen.generate_chain(
1363 "1000",
1364 &customer,
1365 &material_refs,
1366 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1367 2024,
1368 1,
1369 "JSMITH",
1370 );
1371
1372 assert!(!chain.sales_order.items.is_empty());
1373 assert!(chain.credit_check_passed);
1374 assert!(!chain.deliveries.is_empty());
1375 assert!(chain.customer_invoice.is_some());
1376 }
1377
1378 #[test]
1379 fn test_sales_order_generation() {
1380 let mut gen = O2CGenerator::new(42);
1381 let customer = create_test_customer();
1382 let materials = create_test_materials();
1383 let material_refs: Vec<&Material> = materials.iter().collect();
1384
1385 let so = gen.generate_sales_order(
1386 "1000",
1387 &customer,
1388 &material_refs,
1389 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1390 2024,
1391 1,
1392 "JSMITH",
1393 );
1394
1395 assert_eq!(so.customer_id, "C-000001");
1396 assert_eq!(so.items.len(), 2);
1397 assert!(so.total_net_amount > Decimal::ZERO);
1398 }
1399
1400 #[test]
1401 fn test_credit_check_failure() {
1402 let config = O2CGeneratorConfig {
1403 credit_check_failure_rate: 1.0, ..Default::default()
1405 };
1406
1407 let mut gen = O2CGenerator::with_config(42, config);
1408 let customer = create_test_customer();
1409 let materials = create_test_materials();
1410 let material_refs: Vec<&Material> = materials.iter().collect();
1411
1412 let chain = gen.generate_chain(
1413 "1000",
1414 &customer,
1415 &material_refs,
1416 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1417 2024,
1418 1,
1419 "JSMITH",
1420 );
1421
1422 assert!(!chain.credit_check_passed);
1423 assert!(chain.deliveries.is_empty());
1424 assert!(chain.customer_invoice.is_none());
1425 }
1426
1427 #[test]
1428 fn test_document_references() {
1429 let mut gen = O2CGenerator::new(42);
1430 let customer = create_test_customer();
1431 let materials = create_test_materials();
1432 let material_refs: Vec<&Material> = materials.iter().collect();
1433
1434 let chain = gen.generate_chain(
1435 "1000",
1436 &customer,
1437 &material_refs,
1438 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1439 2024,
1440 1,
1441 "JSMITH",
1442 );
1443
1444 if let Some(dlv) = chain.deliveries.first() {
1446 assert!(!dlv.header.document_references.is_empty());
1447 }
1448
1449 if let Some(invoice) = &chain.customer_invoice {
1451 assert!(invoice.header.document_references.len() >= 2);
1452 }
1453 }
1454
1455 #[test]
1456 fn test_deterministic_generation() {
1457 let customer = create_test_customer();
1458 let materials = create_test_materials();
1459 let material_refs: Vec<&Material> = materials.iter().collect();
1460
1461 let mut gen1 = O2CGenerator::new(42);
1462 let mut gen2 = O2CGenerator::new(42);
1463
1464 let chain1 = gen1.generate_chain(
1465 "1000",
1466 &customer,
1467 &material_refs,
1468 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1469 2024,
1470 1,
1471 "JSMITH",
1472 );
1473 let chain2 = gen2.generate_chain(
1474 "1000",
1475 &customer,
1476 &material_refs,
1477 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1478 2024,
1479 1,
1480 "JSMITH",
1481 );
1482
1483 assert_eq!(
1484 chain1.sales_order.header.document_id,
1485 chain2.sales_order.header.document_id
1486 );
1487 assert_eq!(
1488 chain1.sales_order.total_net_amount,
1489 chain2.sales_order.total_net_amount
1490 );
1491 }
1492
1493 #[test]
1494 fn test_partial_shipment_config() {
1495 let config = O2CGeneratorConfig {
1496 partial_shipment_rate: 1.0, ..Default::default()
1498 };
1499
1500 let mut gen = O2CGenerator::with_config(42, config);
1501 let customer = create_test_customer();
1502 let materials = create_test_materials();
1503 let material_refs: Vec<&Material> = materials.iter().collect();
1504
1505 let chain = gen.generate_chain(
1506 "1000",
1507 &customer,
1508 &material_refs,
1509 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1510 2024,
1511 1,
1512 "JSMITH",
1513 );
1514
1515 assert!(chain.deliveries.len() >= 2);
1517 }
1518
1519 #[test]
1520 fn test_gross_margin() {
1521 let mut gen = O2CGenerator::new(42);
1522 let customer = create_test_customer();
1523 let materials = create_test_materials();
1524 let material_refs: Vec<&Material> = materials.iter().collect();
1525
1526 let chain = gen.generate_chain(
1527 "1000",
1528 &customer,
1529 &material_refs,
1530 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1531 2024,
1532 1,
1533 "JSMITH",
1534 );
1535
1536 if let Some(invoice) = &chain.customer_invoice {
1537 let margin = invoice.gross_margin();
1539 assert!(margin > Decimal::ZERO, "Gross margin should be positive");
1540 }
1541 }
1542}