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