1use std::collections::{HashMap, HashSet};
17use std::sync::Arc;
18
19use chrono::NaiveDate;
20use rand::prelude::*;
21use rand::SeedableRng;
22use rand_chacha::ChaCha8Rng;
23use rust_decimal::Decimal;
24
25use datasynth_core::accounts::{
26 cash_accounts, control_accounts, expense_accounts, revenue_accounts, suspense_accounts,
27 tax_accounts,
28};
29use datasynth_core::models::{
30 documents::{CustomerInvoice, Delivery, GoodsReceipt, Payment, VendorInvoice},
31 BusinessProcess, DocumentRef, JournalEntry, JournalEntryHeader, JournalEntryLine,
32 TransactionSource,
33};
34use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
35
36use super::{O2CDocumentChain, P2PDocumentChain};
37
38#[derive(Debug, Clone)]
40pub struct DocumentFlowJeConfig {
41 pub inventory_account: String,
43 pub gr_ir_clearing_account: String,
45 pub ap_account: String,
47 pub cash_account: String,
49 pub ar_account: String,
51 pub revenue_account: String,
53 pub cogs_account: String,
55 pub vat_output_account: String,
57 pub vat_input_account: String,
59 pub populate_fec_fields: bool,
62 pub direct_expense_share: f64,
74}
75
76impl Default for DocumentFlowJeConfig {
77 fn default() -> Self {
78 Self {
79 inventory_account: control_accounts::INVENTORY.to_string(),
80 gr_ir_clearing_account: control_accounts::GR_IR_CLEARING.to_string(),
81 ap_account: control_accounts::AP_CONTROL.to_string(),
82 cash_account: cash_accounts::OPERATING_CASH.to_string(),
83 ar_account: control_accounts::AR_CONTROL.to_string(),
84 revenue_account: revenue_accounts::PRODUCT_REVENUE.to_string(),
85 cogs_account: expense_accounts::COGS.to_string(),
86 vat_output_account: tax_accounts::VAT_PAYABLE.to_string(),
87 vat_input_account: tax_accounts::INPUT_VAT.to_string(),
88 populate_fec_fields: false,
89 direct_expense_share: 0.70,
90 }
91 }
92}
93
94impl DocumentFlowJeConfig {
95 pub fn french_gaap() -> Self {
97 use datasynth_core::pcg;
98 Self {
99 inventory_account: pcg::control_accounts::INVENTORY.to_string(),
100 gr_ir_clearing_account: pcg::control_accounts::GR_IR_CLEARING.to_string(),
101 ap_account: pcg::control_accounts::AP_CONTROL.to_string(),
102 cash_account: pcg::cash_accounts::BANK_ACCOUNT.to_string(),
103 ar_account: pcg::control_accounts::AR_CONTROL.to_string(),
104 revenue_account: pcg::revenue_accounts::PRODUCT_REVENUE.to_string(),
105 cogs_account: pcg::expense_accounts::COGS.to_string(),
106 vat_output_account: pcg::tax_accounts::OUTPUT_VAT.to_string(),
107 vat_input_account: pcg::tax_accounts::INPUT_VAT.to_string(),
108 populate_fec_fields: true,
109 direct_expense_share: 0.70,
110 }
111 }
112}
113
114impl From<&datasynth_core::FrameworkAccounts> for DocumentFlowJeConfig {
115 fn from(fa: &datasynth_core::FrameworkAccounts) -> Self {
116 Self {
117 inventory_account: fa.inventory.clone(),
118 gr_ir_clearing_account: fa.gr_ir_clearing.clone(),
119 ap_account: fa.ap_control.clone(),
120 cash_account: fa.bank_account.clone(),
121 ar_account: fa.ar_control.clone(),
122 revenue_account: fa.product_revenue.clone(),
123 cogs_account: fa.cogs.clone(),
124 vat_output_account: fa.vat_payable.clone(),
125 vat_input_account: fa.input_vat.clone(),
126 populate_fec_fields: fa.audit_export.fec_enabled,
127 direct_expense_share: 0.70,
128 }
129 }
130}
131
132const MAX_SPLIT_LINES: usize = 8;
135
136pub(crate) fn sample_within_bucket<R: rand::Rng>(lo: u32, hi: u32, rng: &mut R) -> u32 {
147 if hi == lo {
148 return lo;
149 }
150 const DECAY: f64 = 0.5;
151 let n = (hi - lo + 1) as usize;
152 let mut weights: Vec<f64> = (0..n).map(|i| DECAY.powi(i as i32)).collect();
153 let total: f64 = weights.iter().sum();
155 for w in &mut weights {
156 *w /= total;
157 }
158 let r: f64 = rng.random_range(0.0..1.0);
160 let mut cum = 0.0;
161 for (i, w) in weights.iter().enumerate() {
162 cum += w;
163 if r <= cum {
164 return lo + i as u32;
165 }
166 }
167 hi
168}
169
170fn sample_target_lines_geometric<R: rand::Rng>(
182 hist: &datasynth_core::distributions::behavioral_priors::LineCountHistogram,
183 rng: &mut R,
184) -> u32 {
185 if hist.buckets.is_empty() {
186 return 0;
187 }
188 let r: f64 = rng.random_range(0.0..1.0);
190 let mut cum = 0.0;
191 let mut chosen_idx = hist.buckets.len() - 1;
192 for (i, &p) in hist.probabilities.iter().enumerate() {
193 cum += p;
194 if r <= cum {
195 chosen_idx = i;
196 break;
197 }
198 }
199 let lo = hist.buckets[chosen_idx];
200 let hi_exclusive = hist.buckets.get(chosen_idx + 1).copied().unwrap_or(lo);
203 let hi_inclusive = if hi_exclusive > lo {
205 hi_exclusive - 1
206 } else {
207 lo
208 };
209 sample_within_bucket(lo, hi_inclusive, rng)
210}
211
212pub struct DocumentFlowJeGenerator {
214 config: DocumentFlowJeConfig,
215 uuid_factory: DeterministicUuidFactory,
216 auxiliary_account_lookup: HashMap<String, String>,
221 cost_center_pool: Vec<String>,
226 profit_center_pool: Vec<String>,
229 pub loaded_priors: Option<Arc<crate::priors_loader::LoadedPriors>>,
234 rng: ChaCha8Rng,
236}
237
238impl DocumentFlowJeGenerator {
239 pub fn new() -> Self {
241 Self::with_config_and_seed(DocumentFlowJeConfig::default(), 0)
242 }
243
244 pub fn with_config_and_seed(config: DocumentFlowJeConfig, seed: u64) -> Self {
246 Self {
247 config,
248 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::DocumentFlow),
249 auxiliary_account_lookup: HashMap::new(),
250 cost_center_pool: Vec::new(),
251 profit_center_pool: Vec::new(),
252 loaded_priors: None,
253 rng: ChaCha8Rng::seed_from_u64(seed.wrapping_add(0xDF_5312_0000)),
255 }
256 }
257
258 pub fn set_loaded_priors(&mut self, priors: Arc<crate::priors_loader::LoadedPriors>) {
261 self.loaded_priors = Some(priors);
262 }
263
264 pub fn set_auxiliary_account_lookup(&mut self, lookup: HashMap<String, String>) {
270 self.auxiliary_account_lookup = lookup;
271 }
272
273 pub fn set_cost_center_pool(&mut self, ids: Vec<String>) {
276 self.cost_center_pool = ids;
277 }
278
279 pub fn set_profit_center_pool(&mut self, ids: Vec<String>) {
281 self.profit_center_pool = ids;
282 }
283
284 fn split_je_expense_lines(&mut self, entry: &mut JournalEntry) {
311 let priors = match self.loaded_priors.as_ref() {
312 Some(p) => p.clone(),
313 None => return,
314 };
315
316 let doc_type = entry.header.document_type.clone();
323 let hist = priors
324 .lines_per_je
325 .by_source
326 .get(doc_type.as_str())
327 .unwrap_or(&priors.lines_per_je.overall);
328 let target = (sample_target_lines_geometric(hist, &mut self.rng) as usize)
329 .clamp(2, 2 + MAX_SPLIT_LINES);
330
331 let current = entry.lines.len();
332 if current >= target {
333 return; }
335
336 let n_splits = (target - current + 1).clamp(2, MAX_SPLIT_LINES);
338
339 let no_split_accounts: HashSet<&str> = [
341 self.config.ap_account.as_str(),
342 self.config.ar_account.as_str(),
343 self.config.cash_account.as_str(),
344 self.config.gr_ir_clearing_account.as_str(),
345 self.config.inventory_account.as_str(),
346 self.config.vat_input_account.as_str(),
347 self.config.vat_output_account.as_str(),
348 control_accounts::AR_CONTROL,
349 control_accounts::AP_CONTROL,
350 control_accounts::INVENTORY,
351 control_accounts::GR_IR_CLEARING,
352 cash_accounts::OPERATING_CASH,
353 cash_accounts::BANK_ACCOUNT,
354 cash_accounts::WIRE_CLEARING,
355 suspense_accounts::GENERAL_SUSPENSE,
356 ]
357 .into_iter()
358 .collect();
359
360 let splittable_idx: Option<usize> = {
363 let mut best_debit: Option<(usize, Decimal)> = None;
364 let mut best_credit: Option<(usize, Decimal)> = None;
365 for (i, line) in entry.lines.iter().enumerate() {
366 if no_split_accounts.contains(line.gl_account.as_str()) {
367 continue;
368 }
369 if line.debit_amount > Decimal::ZERO {
370 let amt = line.debit_amount;
371 if best_debit.map(|(_, a)| amt > a).unwrap_or(true) {
372 best_debit = Some((i, amt));
373 }
374 } else if line.credit_amount > Decimal::ZERO {
375 let amt = line.credit_amount;
376 if best_credit.map(|(_, a)| amt > a).unwrap_or(true) {
377 best_credit = Some((i, amt));
378 }
379 }
380 }
381 best_debit
382 .map(|(i, _)| i)
383 .or_else(|| best_credit.map(|(i, _)| i))
384 };
385
386 let idx = match splittable_idx {
387 Some(i) => i,
388 None => return, };
390
391 let split_line_role = if entry.lines[idx].debit_amount > Decimal::ZERO {
396 "DR"
397 } else {
398 "CR"
399 };
400 let mut gl_accounts: Vec<String> = Vec::with_capacity(n_splits);
401 {
402 let mut seen: HashSet<String> = HashSet::new();
403 let mut attempts = 0usize;
404 while gl_accounts.len() < n_splits && attempts < 50 {
405 attempts += 1;
406 let candidate = priors
409 .sample_gl_for_source_role(&doc_type, split_line_role, &mut self.rng)
410 .or_else(|| {
411 priors.sample_attribute_for_source(&doc_type, "gl_account", &mut self.rng)
412 });
413 if let Some(gl) = candidate {
414 if !no_split_accounts.contains(gl.as_str()) && seen.insert(gl.clone()) {
415 gl_accounts.push(gl);
416 }
417 } else {
418 break;
420 }
421 }
422 }
423
424 if gl_accounts.len() < 2 {
425 return;
427 }
428 let n_actual = gl_accounts.len().min(n_splits);
430 let gl_accounts = &gl_accounts[..n_actual];
431
432 let original_line = entry.lines[idx].clone();
435 let is_debit_split = original_line.debit_amount > Decimal::ZERO;
436 let total_amount = if is_debit_split {
437 original_line.debit_amount
438 } else {
439 original_line.credit_amount
440 };
441
442 let raw_weights: Vec<f64> = (0..n_actual)
444 .map(|_| self.rng.random_range(0.5f64..1.5f64))
445 .collect();
446 let weight_sum: f64 = raw_weights.iter().sum();
447 let total_f64 = total_amount.to_string().parse::<f64>().unwrap_or(0.0);
449 let mut amounts: Vec<Decimal> = raw_weights
450 .iter()
451 .map(|w| {
452 let proportion = w / weight_sum;
453 let raw = (proportion * total_f64 * 100.0).round() / 100.0;
454 Decimal::new((raw * 100.0) as i64, 2)
455 })
456 .collect();
457
458 let computed_sum: Decimal = amounts[..n_actual - 1].iter().sum();
460 let residual = total_amount - computed_sum;
461 if residual > Decimal::ZERO {
462 amounts[n_actual - 1] = residual;
463 } else if n_actual >= 2 {
464 let last_two_sum = amounts[n_actual - 2] + amounts[n_actual - 1] + residual;
466 if last_two_sum > Decimal::ZERO {
467 amounts[n_actual - 2] = last_two_sum / Decimal::from(2);
468 amounts[n_actual - 1] = last_two_sum - amounts[n_actual - 2];
469 }
470 }
471
472 if amounts.iter().any(|a| *a <= Decimal::ZERO) {
474 return;
475 }
476
477 let doc_id = entry.header.document_id;
479 let base_line_no = original_line.line_number;
480
481 entry.lines.remove(idx);
483
484 for (i, (gl, amount)) in gl_accounts.iter().zip(amounts.iter()).enumerate() {
485 let line_no = base_line_no + i as u32;
486 let mut split_line = if is_debit_split {
487 JournalEntryLine::debit(doc_id, line_no, gl.clone(), *amount)
488 } else {
489 JournalEntryLine::credit(doc_id, line_no, gl.clone(), *amount)
490 };
491 split_line.cost_center = original_line.cost_center.clone();
493 split_line.profit_center = original_line.profit_center.clone();
494 split_line.trading_partner = original_line.trading_partner.clone();
495 split_line.text = original_line.text.clone();
496 split_line.line_text = original_line.line_text.clone();
497 split_line.segment = original_line.segment.clone();
498 split_line.functional_area = original_line.functional_area.clone();
499 split_line.project_code = original_line.project_code.clone();
500 entry.lines.insert(idx + i, split_line);
501 }
502
503 for (i, line) in entry.lines.iter_mut().enumerate() {
505 line.line_number = (i + 1) as u32;
506 }
507 }
508
509 fn account_description_map(&self) -> HashMap<String, String> {
511 let mut map = HashMap::new();
512 map.insert(
513 self.config.inventory_account.clone(),
514 "Inventory".to_string(),
515 );
516 map.insert(
517 self.config.gr_ir_clearing_account.clone(),
518 "GR/IR Clearing".to_string(),
519 );
520 map.insert(
521 self.config.ap_account.clone(),
522 "Accounts Payable".to_string(),
523 );
524 map.insert(
525 self.config.cash_account.clone(),
526 "Cash and Cash Equivalents".to_string(),
527 );
528 map.insert(
529 self.config.ar_account.clone(),
530 "Accounts Receivable".to_string(),
531 );
532 map.insert(
533 self.config.revenue_account.clone(),
534 "Product Revenue".to_string(),
535 );
536 map.insert(
537 self.config.cogs_account.clone(),
538 "Cost of Goods Sold".to_string(),
539 );
540 map.insert(
541 self.config.vat_output_account.clone(),
542 "VAT Payable".to_string(),
543 );
544 map.insert(
545 self.config.vat_input_account.clone(),
546 "Input VAT".to_string(),
547 );
548 map
549 }
550
551 const COST_CENTER_POOL: &'static [&'static str] =
553 &["CC1000", "CC2000", "CC3000", "CC4000", "CC5000"];
554
555 fn enrich_line_items(&self, entry: &mut JournalEntry) {
561 if entry.header.sap_source_code.is_none() && !entry.header.document_type.is_empty() {
567 entry.header.sap_source_code = Some(entry.header.document_type.clone());
568 }
569 let desc_map = self.account_description_map();
570 let posting_date = entry.header.posting_date;
571 let company_code = &entry.header.company_code;
572 let header_text = entry.header.header_text.clone();
573 let business_process = entry.header.business_process;
574
575 let doc_id_bytes = entry.header.document_id.as_bytes();
577 let mut cc_seed: usize = 0;
578 for &b in doc_id_bytes {
579 cc_seed = cc_seed.wrapping_add(b as usize);
580 }
581
582 for (i, line) in entry.lines.iter_mut().enumerate() {
583 if line.account_description.is_none() {
585 line.account_description = desc_map.get(&line.gl_account).cloned();
586 }
587
588 if line.cost_center.is_none() {
594 let first_char = line.gl_account.chars().next().unwrap_or('0');
595 if first_char == '5' || first_char == '6' {
596 if !self.cost_center_pool.is_empty() {
597 let needle = format!("-{company_code}-");
598 let candidates: Vec<&String> = self
599 .cost_center_pool
600 .iter()
601 .filter(|id| id.contains(&needle))
602 .collect();
603 let pool: Vec<&String> = if candidates.is_empty() {
604 self.cost_center_pool.iter().collect()
605 } else {
606 candidates
607 };
608 let idx = cc_seed.wrapping_add(i) % pool.len();
609 line.cost_center = Some(pool[idx].clone());
610 } else {
611 let idx = cc_seed.wrapping_add(i) % Self::COST_CENTER_POOL.len();
612 line.cost_center = Some(Self::COST_CENTER_POOL[idx].to_string());
613 }
614 }
615 }
616
617 if line.profit_center.is_none() {
620 if !self.profit_center_pool.is_empty() {
621 let needle = format!("-{company_code}-");
622 let candidates: Vec<&String> = self
623 .profit_center_pool
624 .iter()
625 .filter(|id| id.contains(&needle))
626 .collect();
627 let pool: Vec<&String> = if candidates.is_empty() {
628 self.profit_center_pool.iter().collect()
629 } else {
630 candidates
631 };
632 let idx = cc_seed.wrapping_add(i) % pool.len();
633 line.profit_center = Some(pool[idx].clone());
634 } else {
635 let suffix = match business_process {
636 Some(BusinessProcess::P2P) => "-P2P",
637 Some(BusinessProcess::O2C) => "-O2C",
638 _ => "",
639 };
640 line.profit_center = Some(format!("PC-{company_code}{suffix}"));
641 }
642 }
643
644 if line.line_text.is_none() {
646 line.line_text = header_text.clone();
647 }
648
649 if line.value_date.is_none()
651 && (line.gl_account == self.config.ar_account
652 || line.gl_account == self.config.ap_account)
653 {
654 line.value_date = Some(posting_date);
655 }
656
657 if line.assignment.is_none()
659 && (line.gl_account == self.config.ap_account
660 || line.gl_account == self.config.ar_account)
661 {
662 if let Some(ref ht) = header_text {
663 if let Some(partner_part) = ht.rsplit(" - ").next() {
664 line.assignment = Some(partner_part.to_string());
665 }
666 }
667 }
668 }
669 }
670
671 fn set_auxiliary_fields(
680 &self,
681 line: &mut JournalEntryLine,
682 partner_id: &str,
683 partner_label: &str,
684 ) {
685 if !self.config.populate_fec_fields {
686 return;
687 }
688 if line.gl_account == self.config.ap_account || line.gl_account == self.config.ar_account {
689 let aux_account = self
692 .auxiliary_account_lookup
693 .get(partner_id)
694 .cloned()
695 .unwrap_or_else(|| partner_id.to_string());
696 line.auxiliary_account_number = Some(aux_account);
697 line.auxiliary_account_label = Some(partner_label.to_string());
698 }
699 }
700
701 fn apply_lettrage(
707 &self,
708 entries: &mut [JournalEntry],
709 chain_id: &str,
710 lettrage_date: NaiveDate,
711 ) {
712 if !self.config.populate_fec_fields {
713 return;
714 }
715 let code = format!("LTR-{}", &chain_id[..chain_id.len().min(8)]);
716 for entry in entries.iter_mut() {
717 for line in entry.lines.iter_mut() {
718 if line.gl_account == self.config.ap_account
719 || line.gl_account == self.config.ar_account
720 {
721 line.lettrage = Some(code.clone());
722 line.lettrage_date = Some(lettrage_date);
723 }
724 }
725 }
726 }
727
728 fn wire_predecessor_chain(entries: &mut [JournalEntry]) {
754 if entries.len() < 2 {
755 return;
756 }
757 for i in 1..entries.len() {
758 let prev_lines: Vec<(String, String)> = entries[i - 1]
761 .lines
762 .iter()
763 .map(|l| {
764 let tx_id = l.transaction_id.clone().unwrap_or_else(|| {
765 datasynth_core::models::JournalEntryLine::derive_transaction_id(
766 l.document_id,
767 l.line_number,
768 )
769 });
770 (l.gl_account.clone(), tx_id)
771 })
772 .collect();
773
774 for line in entries[i].lines.iter_mut() {
775 if line.predecessor_line_id.is_some() {
776 continue;
777 }
778 if let Some((_, tx_id)) =
779 prev_lines.iter().find(|(acct, _)| acct == &line.gl_account)
780 {
781 line.predecessor_line_id = Some(tx_id.clone());
782 }
783 }
784 }
785 }
786
787 pub fn generate_from_p2p_chain(&mut self, chain: &P2PDocumentChain) -> Vec<JournalEntry> {
789 let mut entries = Vec::new();
790
791 for gr in &chain.goods_receipts {
793 if let Some(je) = self.generate_from_goods_receipt(gr) {
794 entries.push(je);
795 }
796 }
797
798 if let Some(ref invoice) = chain.vendor_invoice {
800 if let Some(je) = self.generate_from_vendor_invoice(invoice) {
801 entries.push(je);
802 }
803 }
804
805 if let Some(ref payment) = chain.payment {
807 if let Some(je) = self.generate_from_ap_payment(payment) {
808 entries.push(je);
809 }
810 }
811
812 for payment in &chain.remainder_payments {
814 if let Some(je) = self.generate_from_ap_payment(payment) {
815 entries.push(je);
816 }
817 }
818
819 if self.config.populate_fec_fields && chain.is_complete {
821 if let Some(ref payment) = chain.payment {
822 let posting_date = payment
823 .header
824 .posting_date
825 .unwrap_or(payment.header.document_date);
826 self.apply_lettrage(
827 &mut entries,
828 &chain.purchase_order.header.document_id,
829 posting_date,
830 );
831 }
832 }
833
834 Self::wire_predecessor_chain(&mut entries);
837
838 entries
839 }
840
841 pub fn generate_from_o2c_chain(&mut self, chain: &O2CDocumentChain) -> Vec<JournalEntry> {
843 let mut entries = Vec::new();
844
845 for delivery in &chain.deliveries {
847 if let Some(je) = self.generate_from_delivery(delivery) {
848 entries.push(je);
849 }
850 }
851
852 if let Some(ref invoice) = chain.customer_invoice {
854 if let Some(je) = self.generate_from_customer_invoice(invoice) {
855 entries.push(je);
856 }
857 }
858
859 if let Some(ref receipt) = chain.customer_receipt {
861 if let Some(je) = self.generate_from_ar_receipt(receipt) {
862 entries.push(je);
863 }
864 }
865
866 for receipt in &chain.remainder_receipts {
868 if let Some(je) = self.generate_from_ar_receipt(receipt) {
869 entries.push(je);
870 }
871 }
872
873 if self.config.populate_fec_fields && chain.customer_receipt.is_some() {
875 if let Some(ref receipt) = chain.customer_receipt {
876 let posting_date = receipt
877 .header
878 .posting_date
879 .unwrap_or(receipt.header.document_date);
880 self.apply_lettrage(
881 &mut entries,
882 &chain.sales_order.header.document_id,
883 posting_date,
884 );
885 }
886 }
887
888 Self::wire_predecessor_chain(&mut entries);
890
891 entries
892 }
893
894 pub fn generate_from_goods_receipt(&mut self, gr: &GoodsReceipt) -> Option<JournalEntry> {
897 if gr.items.is_empty() {
898 return None;
899 }
900
901 let document_id = self.uuid_factory.next();
902
903 let total_amount = if gr.total_value > Decimal::ZERO {
905 gr.total_value
906 } else {
907 gr.items
908 .iter()
909 .map(|item| item.base.net_amount)
910 .sum::<Decimal>()
911 };
912
913 if total_amount == Decimal::ZERO {
914 return None;
915 }
916
917 let posting_date = gr.header.posting_date.unwrap_or(gr.header.document_date);
919
920 let mut header = JournalEntryHeader::with_deterministic_id(
921 gr.header.company_code.clone(),
922 posting_date,
923 document_id,
924 );
925 header.source = TransactionSource::Automated;
926 header.business_process = Some(BusinessProcess::P2P);
927 header.document_type = "WE".to_string();
928 header.reference = Some(format!("GR:{}", gr.header.document_id));
929 header.source_document = Some(DocumentRef::GoodsReceipt(gr.header.document_id.clone()));
930 header.header_text = Some(format!(
931 "Goods Receipt {} - {}",
932 gr.header.document_id,
933 gr.vendor_id.as_deref().unwrap_or("Unknown")
934 ));
935
936 let mut entry = JournalEntry::new(header);
937
938 let debit_line = JournalEntryLine::debit(
940 entry.header.document_id,
941 1,
942 self.config.inventory_account.clone(),
943 total_amount,
944 );
945 entry.add_line(debit_line);
946
947 let mut credit_line = JournalEntryLine::credit(
949 entry.header.document_id,
950 2,
951 self.config.gr_ir_clearing_account.clone(),
952 total_amount,
953 );
954 credit_line.trading_partner = gr.vendor_id.clone();
955 entry.add_line(credit_line);
956
957 self.enrich_line_items(&mut entry);
958 self.split_je_expense_lines(&mut entry);
959 Some(entry)
960 }
961
962 pub fn generate_from_vendor_invoice(
981 &mut self,
982 invoice: &VendorInvoice,
983 ) -> Option<JournalEntry> {
984 if invoice.payable_amount == Decimal::ZERO {
985 return None;
986 }
987
988 let document_id = self.uuid_factory.next();
989
990 let posting_date = invoice
992 .header
993 .posting_date
994 .unwrap_or(invoice.header.document_date);
995
996 let mut header = JournalEntryHeader::with_deterministic_id(
997 invoice.header.company_code.clone(),
998 posting_date,
999 document_id,
1000 );
1001 header.source = TransactionSource::Automated;
1002 header.business_process = Some(BusinessProcess::P2P);
1003 header.document_type = "KR".to_string();
1004 header.reference = Some(format!("VI:{}", invoice.header.document_id));
1005 header.source_document = Some(DocumentRef::VendorInvoice(
1006 invoice.header.document_id.clone(),
1007 ));
1008 header.header_text = Some(format!(
1009 "Vendor Invoice {} - {}",
1010 invoice.vendor_invoice_number, invoice.vendor_id
1011 ));
1012
1013 let mut entry = JournalEntry::new(header);
1014
1015 let has_vat = invoice.tax_amount > Decimal::ZERO;
1016 let debit_net_amount = if has_vat {
1019 invoice.net_amount
1020 } else {
1021 invoice.payable_amount
1022 };
1023
1024 let direct_expense_share = self.config.direct_expense_share.clamp(0.0, 1.0);
1028 let use_direct_expense = self.loaded_priors.is_some()
1029 && self.rng.random_range(0.0_f64..1.0_f64) < direct_expense_share;
1030
1031 if use_direct_expense {
1032 let expense_gl = self
1039 .loaded_priors
1040 .as_ref()
1041 .and_then(|p| p.sample_gl_for_source_role("KR", "DR", &mut self.rng))
1042 .or_else(|| {
1043 self.loaded_priors.as_ref().and_then(|p| {
1044 p.sample_attribute_for_source("KR", "gl_account", &mut self.rng)
1045 })
1046 })
1047 .unwrap_or_else(|| "6000".to_string());
1050
1051 let debit_expense =
1053 JournalEntryLine::debit(entry.header.document_id, 1, expense_gl, debit_net_amount);
1054 entry.add_line(debit_expense);
1055
1056 if has_vat {
1058 let vat_line = JournalEntryLine::debit(
1059 entry.header.document_id,
1060 2,
1061 self.config.vat_input_account.clone(),
1062 invoice.tax_amount,
1063 );
1064 entry.add_line(vat_line);
1065 }
1066
1067 let mut credit_ap = JournalEntryLine::credit(
1069 entry.header.document_id,
1070 if has_vat { 3 } else { 2 },
1071 self.config.ap_account.clone(),
1072 invoice.payable_amount,
1073 );
1074 self.set_auxiliary_fields(&mut credit_ap, &invoice.vendor_id, &invoice.vendor_id);
1075 credit_ap.trading_partner = Some(invoice.vendor_id.clone());
1076 entry.add_line(credit_ap);
1077 } else {
1078 let debit_clearing = JournalEntryLine::debit(
1081 entry.header.document_id,
1082 1,
1083 self.config.gr_ir_clearing_account.clone(),
1084 debit_net_amount,
1085 );
1086 entry.add_line(debit_clearing);
1087
1088 if has_vat {
1090 let vat_line = JournalEntryLine::debit(
1091 entry.header.document_id,
1092 2,
1093 self.config.vat_input_account.clone(),
1094 invoice.tax_amount,
1095 );
1096 entry.add_line(vat_line);
1097 }
1098
1099 let mut credit_line = JournalEntryLine::credit(
1101 entry.header.document_id,
1102 if has_vat { 3 } else { 2 },
1103 self.config.ap_account.clone(),
1104 invoice.payable_amount,
1105 );
1106 self.set_auxiliary_fields(&mut credit_line, &invoice.vendor_id, &invoice.vendor_id);
1107 credit_line.trading_partner = Some(invoice.vendor_id.clone());
1108 entry.add_line(credit_line);
1109 }
1110
1111 self.enrich_line_items(&mut entry);
1112 self.split_je_expense_lines(&mut entry);
1113 Some(entry)
1114 }
1115
1116 pub fn generate_from_ap_payment(&mut self, payment: &Payment) -> Option<JournalEntry> {
1119 if payment.amount == Decimal::ZERO {
1120 return None;
1121 }
1122
1123 let document_id = self.uuid_factory.next();
1124
1125 let posting_date = payment
1127 .header
1128 .posting_date
1129 .unwrap_or(payment.header.document_date);
1130
1131 let mut header = JournalEntryHeader::with_deterministic_id(
1132 payment.header.company_code.clone(),
1133 posting_date,
1134 document_id,
1135 );
1136 header.source = TransactionSource::Automated;
1137 header.business_process = Some(BusinessProcess::P2P);
1138 header.document_type = "KZ".to_string();
1139 header.reference = Some(format!("PAY:{}", payment.header.document_id));
1140 header.source_document = Some(DocumentRef::Payment(payment.header.document_id.clone()));
1141 header.header_text = Some(format!(
1142 "Payment {} - {}",
1143 payment.header.document_id, payment.business_partner_id
1144 ));
1145
1146 let mut entry = JournalEntry::new(header);
1147
1148 let mut debit_line = JournalEntryLine::debit(
1150 entry.header.document_id,
1151 1,
1152 self.config.ap_account.clone(),
1153 payment.amount,
1154 );
1155 self.set_auxiliary_fields(
1156 &mut debit_line,
1157 &payment.business_partner_id,
1158 &payment.business_partner_id,
1159 );
1160 debit_line.trading_partner = Some(payment.business_partner_id.clone());
1161 entry.add_line(debit_line);
1162
1163 let credit_line = JournalEntryLine::credit(
1165 entry.header.document_id,
1166 2,
1167 self.config.cash_account.clone(),
1168 payment.amount,
1169 );
1170 entry.add_line(credit_line);
1171
1172 self.enrich_line_items(&mut entry);
1173 self.split_je_expense_lines(&mut entry);
1174 Some(entry)
1175 }
1176
1177 pub fn generate_from_delivery(&mut self, delivery: &Delivery) -> Option<JournalEntry> {
1180 if delivery.items.is_empty() {
1181 return None;
1182 }
1183
1184 let document_id = self.uuid_factory.next();
1185
1186 let total_cost = delivery
1188 .items
1189 .iter()
1190 .map(|item| item.base.net_amount)
1191 .sum::<Decimal>();
1192
1193 if total_cost == Decimal::ZERO {
1194 return None;
1195 }
1196
1197 let posting_date = delivery
1199 .header
1200 .posting_date
1201 .unwrap_or(delivery.header.document_date);
1202
1203 let mut header = JournalEntryHeader::with_deterministic_id(
1204 delivery.header.company_code.clone(),
1205 posting_date,
1206 document_id,
1207 );
1208 header.source = TransactionSource::Automated;
1209 header.business_process = Some(BusinessProcess::O2C);
1210 header.document_type = "WL".to_string();
1211 header.reference = Some(format!("DEL:{}", delivery.header.document_id));
1212 header.source_document = Some(DocumentRef::Delivery(delivery.header.document_id.clone()));
1213 header.header_text = Some(format!(
1214 "Delivery {} - {}",
1215 delivery.header.document_id, delivery.customer_id
1216 ));
1217
1218 let mut entry = JournalEntry::new(header);
1219
1220 let debit_line = JournalEntryLine::debit(
1222 entry.header.document_id,
1223 1,
1224 self.config.cogs_account.clone(),
1225 total_cost,
1226 );
1227 entry.add_line(debit_line);
1228
1229 let credit_line = JournalEntryLine::credit(
1231 entry.header.document_id,
1232 2,
1233 self.config.inventory_account.clone(),
1234 total_cost,
1235 );
1236 entry.add_line(credit_line);
1237
1238 self.enrich_line_items(&mut entry);
1239 self.split_je_expense_lines(&mut entry);
1240 Some(entry)
1241 }
1242
1243 pub fn generate_from_customer_invoice(
1254 &mut self,
1255 invoice: &CustomerInvoice,
1256 ) -> Option<JournalEntry> {
1257 if invoice.total_gross_amount == Decimal::ZERO {
1258 return None;
1259 }
1260
1261 let document_id = self.uuid_factory.next();
1262
1263 let posting_date = invoice
1265 .header
1266 .posting_date
1267 .unwrap_or(invoice.header.document_date);
1268
1269 let mut header = JournalEntryHeader::with_deterministic_id(
1270 invoice.header.company_code.clone(),
1271 posting_date,
1272 document_id,
1273 );
1274 header.source = TransactionSource::Automated;
1275 header.business_process = Some(BusinessProcess::O2C);
1276 header.document_type = "DR".to_string();
1277 header.reference = Some(format!("CI:{}", invoice.header.document_id));
1278 header.source_document = Some(DocumentRef::CustomerInvoice(
1279 invoice.header.document_id.clone(),
1280 ));
1281 header.header_text = Some(format!(
1282 "Customer Invoice {} - {}",
1283 invoice.header.document_id, invoice.customer_id
1284 ));
1285
1286 let mut entry = JournalEntry::new(header);
1287
1288 let mut debit_line = JournalEntryLine::debit(
1290 entry.header.document_id,
1291 1,
1292 self.config.ar_account.clone(),
1293 invoice.total_gross_amount,
1294 );
1295 self.set_auxiliary_fields(&mut debit_line, &invoice.customer_id, &invoice.customer_id);
1296 debit_line.trading_partner = Some(invoice.customer_id.clone());
1297 entry.add_line(debit_line);
1298
1299 let revenue_amount = if invoice.total_tax_amount > Decimal::ZERO {
1301 invoice.total_net_amount
1302 } else {
1303 invoice.total_gross_amount
1304 };
1305 let credit_line = JournalEntryLine::credit(
1306 entry.header.document_id,
1307 2,
1308 self.config.revenue_account.clone(),
1309 revenue_amount,
1310 );
1311 entry.add_line(credit_line);
1312
1313 if invoice.total_tax_amount > Decimal::ZERO {
1315 let vat_line = JournalEntryLine::credit(
1316 entry.header.document_id,
1317 3,
1318 self.config.vat_output_account.clone(),
1319 invoice.total_tax_amount,
1320 );
1321 entry.add_line(vat_line);
1322 }
1323
1324 self.enrich_line_items(&mut entry);
1325 self.split_je_expense_lines(&mut entry);
1326 Some(entry)
1327 }
1328
1329 pub fn generate_from_ar_receipt(&mut self, payment: &Payment) -> Option<JournalEntry> {
1332 if payment.amount == Decimal::ZERO {
1333 return None;
1334 }
1335
1336 let document_id = self.uuid_factory.next();
1337
1338 let posting_date = payment
1340 .header
1341 .posting_date
1342 .unwrap_or(payment.header.document_date);
1343
1344 let mut header = JournalEntryHeader::with_deterministic_id(
1345 payment.header.company_code.clone(),
1346 posting_date,
1347 document_id,
1348 );
1349 header.source = TransactionSource::Automated;
1350 header.business_process = Some(BusinessProcess::O2C);
1351 header.document_type = "DZ".to_string();
1352 header.reference = Some(format!("RCP:{}", payment.header.document_id));
1353 header.source_document = Some(DocumentRef::Receipt(payment.header.document_id.clone()));
1354 header.header_text = Some(format!(
1355 "Customer Receipt {} - {}",
1356 payment.header.document_id, payment.business_partner_id
1357 ));
1358
1359 let mut entry = JournalEntry::new(header);
1360
1361 let debit_line = JournalEntryLine::debit(
1363 entry.header.document_id,
1364 1,
1365 self.config.cash_account.clone(),
1366 payment.amount,
1367 );
1368 entry.add_line(debit_line);
1369
1370 let mut credit_line = JournalEntryLine::credit(
1372 entry.header.document_id,
1373 2,
1374 self.config.ar_account.clone(),
1375 payment.amount,
1376 );
1377 self.set_auxiliary_fields(
1378 &mut credit_line,
1379 &payment.business_partner_id,
1380 &payment.business_partner_id,
1381 );
1382 credit_line.trading_partner = Some(payment.business_partner_id.clone());
1383 entry.add_line(credit_line);
1384
1385 self.enrich_line_items(&mut entry);
1386 self.split_je_expense_lines(&mut entry);
1387 Some(entry)
1388 }
1389}
1390
1391impl Default for DocumentFlowJeGenerator {
1392 fn default() -> Self {
1393 Self::new()
1394 }
1395}
1396
1397#[cfg(test)]
1398mod tests {
1399 use super::*;
1400 use chrono::NaiveDate;
1401 use datasynth_core::models::documents::{GoodsReceiptItem, MovementType};
1402
1403 fn create_test_gr() -> GoodsReceipt {
1404 let mut gr = GoodsReceipt::from_purchase_order(
1405 "GR-001".to_string(),
1406 "1000",
1407 "PO-001",
1408 "V-001",
1409 "P1000",
1410 "0001",
1411 2024,
1412 1,
1413 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1414 "JSMITH",
1415 );
1416
1417 let item = GoodsReceiptItem::from_po(
1418 10,
1419 "Test Material",
1420 Decimal::from(100),
1421 Decimal::from(50),
1422 "PO-001",
1423 10,
1424 )
1425 .with_movement_type(MovementType::GrForPo);
1426
1427 gr.add_item(item);
1428 gr.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1429
1430 gr
1431 }
1432
1433 fn create_test_vendor_invoice() -> VendorInvoice {
1434 use datasynth_core::models::documents::VendorInvoiceItem;
1435
1436 let mut invoice = VendorInvoice::new(
1437 "VI-001".to_string(),
1438 "1000",
1439 "V-001",
1440 "INV-12345".to_string(),
1441 2024,
1442 1,
1443 NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
1444 "JSMITH",
1445 );
1446
1447 let item = VendorInvoiceItem::from_po_gr(
1448 10,
1449 "Test Material",
1450 Decimal::from(100),
1451 Decimal::from(50),
1452 "PO-001",
1453 10,
1454 Some("GR-001".to_string()),
1455 Some(10),
1456 );
1457
1458 invoice.add_item(item);
1459 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
1460
1461 invoice
1462 }
1463
1464 fn create_test_payment() -> Payment {
1465 let mut payment = Payment::new_ap_payment(
1466 "PAY-001".to_string(),
1467 "1000",
1468 "V-001",
1469 Decimal::from(5000),
1470 2024,
1471 2,
1472 NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
1473 "JSMITH",
1474 );
1475
1476 payment.post("JSMITH", NaiveDate::from_ymd_opt(2024, 2, 15).unwrap());
1477
1478 payment
1479 }
1480
1481 #[test]
1482 fn test_generate_from_goods_receipt() {
1483 let mut generator = DocumentFlowJeGenerator::new();
1484 let gr = create_test_gr();
1485
1486 let je = generator.generate_from_goods_receipt(&gr);
1487
1488 assert!(je.is_some());
1489 let je = je.unwrap();
1490
1491 assert!(je.is_balanced());
1493
1494 assert_eq!(je.line_count(), 2);
1496
1497 assert!(je.total_debit() > Decimal::ZERO);
1499 assert_eq!(je.total_debit(), je.total_credit());
1500
1501 assert!(je.header.reference.is_some());
1503 assert!(je.header.reference.as_ref().unwrap().contains("GR:"));
1504
1505 assert_eq!(je.header.sap_source_code.as_deref(), Some("WE"));
1508 assert_eq!(je.header.document_type, "WE");
1509 }
1510
1511 #[test]
1512 fn test_generate_from_vendor_invoice() {
1513 let mut generator = DocumentFlowJeGenerator::new();
1514 let invoice = create_test_vendor_invoice();
1515
1516 let je = generator.generate_from_vendor_invoice(&invoice);
1517
1518 assert!(je.is_some());
1519 let je = je.unwrap();
1520
1521 assert!(je.is_balanced());
1522 assert_eq!(je.line_count(), 2);
1523 assert!(je.header.reference.as_ref().unwrap().contains("VI:"));
1524 }
1525
1526 #[test]
1527 fn test_generate_from_ap_payment() {
1528 let mut generator = DocumentFlowJeGenerator::new();
1529 let payment = create_test_payment();
1530
1531 let je = generator.generate_from_ap_payment(&payment);
1532
1533 assert!(je.is_some());
1534 let je = je.unwrap();
1535
1536 assert!(je.is_balanced());
1537 assert_eq!(je.line_count(), 2);
1538 assert!(je.header.reference.as_ref().unwrap().contains("PAY:"));
1539 }
1540
1541 #[test]
1542 fn test_all_entries_are_balanced() {
1543 let mut generator = DocumentFlowJeGenerator::new();
1544
1545 let gr = create_test_gr();
1546 let invoice = create_test_vendor_invoice();
1547 let payment = create_test_payment();
1548
1549 let entries = vec![
1550 generator.generate_from_goods_receipt(&gr),
1551 generator.generate_from_vendor_invoice(&invoice),
1552 generator.generate_from_ap_payment(&payment),
1553 ];
1554
1555 for entry in entries.into_iter().flatten() {
1556 assert!(
1557 entry.is_balanced(),
1558 "Entry {} is not balanced",
1559 entry.header.document_id
1560 );
1561 }
1562 }
1563
1564 #[test]
1569 fn test_french_gaap_auxiliary_on_ap_ar_lines_only() {
1570 let mut generator =
1572 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1573
1574 let invoice = create_test_vendor_invoice();
1576 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1577
1578 assert!(
1580 je.lines[0].auxiliary_account_number.is_none(),
1581 "GR/IR clearing line should not have auxiliary"
1582 );
1583
1584 assert_eq!(
1586 je.lines[1].auxiliary_account_number.as_deref(),
1587 Some("V-001"),
1588 "AP line should have vendor ID as auxiliary"
1589 );
1590 assert_eq!(
1591 je.lines[1].auxiliary_account_label.as_deref(),
1592 Some("V-001"),
1593 );
1594 }
1595
1596 #[test]
1597 fn test_french_gaap_lettrage_on_complete_p2p_chain() {
1598 use datasynth_core::models::documents::PurchaseOrder;
1599
1600 let mut generator =
1601 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1602
1603 let po = PurchaseOrder::new(
1604 "PO-001",
1605 "1000",
1606 "V-001",
1607 2024,
1608 1,
1609 NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(),
1610 "JSMITH",
1611 );
1612
1613 let chain = P2PDocumentChain {
1614 purchase_order: po,
1615 goods_receipts: vec![create_test_gr()],
1616 vendor_invoice: Some(create_test_vendor_invoice()),
1617 payment: Some(create_test_payment()),
1618 remainder_payments: Vec::new(),
1619 is_complete: true,
1620 three_way_match_passed: true,
1621 payment_timing: None,
1622 };
1623
1624 let entries = generator.generate_from_p2p_chain(&chain);
1625 assert!(!entries.is_empty());
1626
1627 let ap_account = &generator.config.ap_account;
1629 let mut lettrage_codes: Vec<&str> = Vec::new();
1630 for entry in &entries {
1631 for line in &entry.lines {
1632 if &line.gl_account == ap_account {
1633 assert!(
1634 line.lettrage.is_some(),
1635 "AP line should have lettrage on complete chain"
1636 );
1637 assert!(line.lettrage_date.is_some());
1638 lettrage_codes.push(line.lettrage.as_deref().unwrap());
1639 } else {
1640 assert!(
1641 line.lettrage.is_none(),
1642 "Non-AP line should not have lettrage"
1643 );
1644 }
1645 }
1646 }
1647
1648 assert!(!lettrage_codes.is_empty());
1650 assert!(
1651 lettrage_codes.iter().all(|c| *c == lettrage_codes[0]),
1652 "All AP lines should share the same lettrage code"
1653 );
1654 assert!(lettrage_codes[0].starts_with("LTR-"));
1655 }
1656
1657 #[test]
1658 fn test_incomplete_chain_has_no_lettrage() {
1659 use datasynth_core::models::documents::PurchaseOrder;
1660
1661 let mut generator =
1662 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1663
1664 let po = PurchaseOrder::new(
1665 "PO-002",
1666 "1000",
1667 "V-001",
1668 2024,
1669 1,
1670 NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(),
1671 "JSMITH",
1672 );
1673
1674 let chain = P2PDocumentChain {
1676 purchase_order: po,
1677 goods_receipts: vec![create_test_gr()],
1678 vendor_invoice: Some(create_test_vendor_invoice()),
1679 payment: None,
1680 remainder_payments: Vec::new(),
1681 is_complete: false,
1682 three_way_match_passed: false,
1683 payment_timing: None,
1684 };
1685
1686 let entries = generator.generate_from_p2p_chain(&chain);
1687
1688 for entry in &entries {
1689 for line in &entry.lines {
1690 assert!(
1691 line.lettrage.is_none(),
1692 "Incomplete chain should have no lettrage"
1693 );
1694 }
1695 }
1696 }
1697
1698 #[test]
1699 fn test_default_config_no_fec_fields() {
1700 let mut generator = DocumentFlowJeGenerator::new();
1702
1703 let invoice = create_test_vendor_invoice();
1704 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1705
1706 for line in &je.lines {
1707 assert!(line.auxiliary_account_number.is_none());
1708 assert!(line.auxiliary_account_label.is_none());
1709 assert!(line.lettrage.is_none());
1710 assert!(line.lettrage_date.is_none());
1711 }
1712 }
1713
1714 #[test]
1715 fn test_auxiliary_lookup_uses_gl_account_instead_of_partner_id() {
1716 let mut generator =
1719 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1720
1721 let mut lookup = HashMap::new();
1722 lookup.insert("V-001".to_string(), "4010001".to_string());
1723 generator.set_auxiliary_account_lookup(lookup);
1724
1725 let invoice = create_test_vendor_invoice();
1726 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1727
1728 assert_eq!(
1730 je.lines[1].auxiliary_account_number.as_deref(),
1731 Some("4010001"),
1732 "AP line should use auxiliary GL account from lookup"
1733 );
1734 assert_eq!(
1736 je.lines[1].auxiliary_account_label.as_deref(),
1737 Some("V-001"),
1738 );
1739 }
1740
1741 #[test]
1742 fn test_auxiliary_lookup_fallback_to_partner_id() {
1743 let mut generator =
1746 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1747
1748 let mut lookup = HashMap::new();
1750 lookup.insert("V-999".to_string(), "4019999".to_string());
1751 generator.set_auxiliary_account_lookup(lookup);
1752
1753 let invoice = create_test_vendor_invoice();
1754 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1755
1756 assert_eq!(
1758 je.lines[1].auxiliary_account_number.as_deref(),
1759 Some("V-001"),
1760 "Should fall back to partner ID when not in lookup"
1761 );
1762 }
1763
1764 #[test]
1765 fn test_auxiliary_lookup_works_for_customer_receipt() {
1766 let mut generator =
1768 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1769
1770 let mut lookup = HashMap::new();
1771 lookup.insert("C-001".to_string(), "4110001".to_string());
1772 generator.set_auxiliary_account_lookup(lookup);
1773
1774 let mut receipt = Payment::new_ar_receipt(
1775 "RCP-001".to_string(),
1776 "1000",
1777 "C-001",
1778 Decimal::from(3000),
1779 2024,
1780 3,
1781 NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
1782 "JSMITH",
1783 );
1784 receipt.post("JSMITH", NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
1785
1786 let je = generator.generate_from_ar_receipt(&receipt).unwrap();
1787
1788 assert_eq!(
1790 je.lines[1].auxiliary_account_number.as_deref(),
1791 Some("4110001"),
1792 "AR line should use auxiliary GL account from lookup"
1793 );
1794 }
1795
1796 fn create_test_customer_invoice_with_tax() -> CustomerInvoice {
1802 use datasynth_core::models::documents::CustomerInvoiceItem;
1803
1804 let mut invoice = CustomerInvoice::new(
1805 "CI-001",
1806 "1000",
1807 "C-001",
1808 2024,
1809 1,
1810 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1811 NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
1812 "JSMITH",
1813 );
1814
1815 let mut item =
1817 CustomerInvoiceItem::new(1, "Product A", Decimal::from(10), Decimal::from(100));
1818 item.base.tax_amount = Decimal::from(100);
1819 invoice.add_item(item);
1820 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1821
1822 invoice
1823 }
1824
1825 fn create_test_customer_invoice_no_tax() -> CustomerInvoice {
1827 use datasynth_core::models::documents::CustomerInvoiceItem;
1828
1829 let mut invoice = CustomerInvoice::new(
1830 "CI-002",
1831 "1000",
1832 "C-002",
1833 2024,
1834 1,
1835 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1836 NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
1837 "JSMITH",
1838 );
1839
1840 let item = CustomerInvoiceItem::new(1, "Product B", Decimal::from(10), Decimal::from(100));
1841 invoice.add_item(item);
1842 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1843
1844 invoice
1845 }
1846
1847 fn create_test_vendor_invoice_with_tax() -> VendorInvoice {
1849 use datasynth_core::models::documents::VendorInvoiceItem;
1850
1851 let mut invoice = VendorInvoice::new(
1852 "VI-002".to_string(),
1853 "1000",
1854 "V-001",
1855 "INV-TAX-001".to_string(),
1856 2024,
1857 1,
1858 NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
1859 "JSMITH",
1860 );
1861
1862 let item = VendorInvoiceItem::from_po_gr(
1864 10,
1865 "Test Material",
1866 Decimal::from(100),
1867 Decimal::from(50),
1868 "PO-001",
1869 10,
1870 Some("GR-001".to_string()),
1871 Some(10),
1872 )
1873 .with_tax("VAT10", Decimal::from(500));
1874
1875 invoice.add_item(item);
1876 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
1877
1878 invoice
1879 }
1880
1881 #[test]
1882 fn test_customer_invoice_with_tax_produces_three_lines() {
1883 let mut generator = DocumentFlowJeGenerator::new();
1884 let invoice = create_test_customer_invoice_with_tax();
1885
1886 assert_eq!(invoice.total_net_amount, Decimal::from(1000));
1887 assert_eq!(invoice.total_tax_amount, Decimal::from(100));
1888 assert_eq!(invoice.total_gross_amount, Decimal::from(1100));
1889
1890 let je = generator.generate_from_customer_invoice(&invoice).unwrap();
1891
1892 assert_eq!(
1894 je.line_count(),
1895 3,
1896 "Expected 3 JE lines for invoice with tax"
1897 );
1898 assert!(je.is_balanced(), "Entry must be balanced");
1899
1900 assert_eq!(je.lines[0].gl_account, control_accounts::AR_CONTROL);
1902 assert_eq!(je.lines[0].debit_amount, Decimal::from(1100));
1903 assert_eq!(je.lines[0].credit_amount, Decimal::ZERO);
1904
1905 assert_eq!(je.lines[1].gl_account, revenue_accounts::PRODUCT_REVENUE);
1907 assert_eq!(je.lines[1].credit_amount, Decimal::from(1000));
1908 assert_eq!(je.lines[1].debit_amount, Decimal::ZERO);
1909
1910 assert_eq!(je.lines[2].gl_account, tax_accounts::VAT_PAYABLE);
1912 assert_eq!(je.lines[2].credit_amount, Decimal::from(100));
1913 assert_eq!(je.lines[2].debit_amount, Decimal::ZERO);
1914 }
1915
1916 #[test]
1917 fn test_customer_invoice_no_tax_produces_two_lines() {
1918 let mut generator = DocumentFlowJeGenerator::new();
1919 let invoice = create_test_customer_invoice_no_tax();
1920
1921 assert_eq!(invoice.total_tax_amount, Decimal::ZERO);
1922 assert_eq!(invoice.total_net_amount, Decimal::from(1000));
1923 assert_eq!(invoice.total_gross_amount, Decimal::from(1000));
1924
1925 let je = generator.generate_from_customer_invoice(&invoice).unwrap();
1926
1927 assert_eq!(
1929 je.line_count(),
1930 2,
1931 "Expected 2 JE lines for invoice without tax"
1932 );
1933 assert!(je.is_balanced(), "Entry must be balanced");
1934
1935 assert_eq!(je.lines[0].gl_account, control_accounts::AR_CONTROL);
1937 assert_eq!(je.lines[0].debit_amount, Decimal::from(1000));
1938
1939 assert_eq!(je.lines[1].gl_account, revenue_accounts::PRODUCT_REVENUE);
1941 assert_eq!(je.lines[1].credit_amount, Decimal::from(1000));
1942 }
1943
1944 #[test]
1945 fn test_vendor_invoice_with_tax_produces_three_lines() {
1946 let mut generator = DocumentFlowJeGenerator::new();
1947 let invoice = create_test_vendor_invoice_with_tax();
1948
1949 assert_eq!(invoice.net_amount, Decimal::from(5000));
1950 assert_eq!(invoice.tax_amount, Decimal::from(500));
1951 assert_eq!(invoice.gross_amount, Decimal::from(5500));
1952 assert_eq!(invoice.payable_amount, Decimal::from(5500));
1953
1954 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1955
1956 assert_eq!(
1958 je.line_count(),
1959 3,
1960 "Expected 3 JE lines for vendor invoice with tax"
1961 );
1962 assert!(je.is_balanced(), "Entry must be balanced");
1963
1964 assert_eq!(je.lines[0].gl_account, control_accounts::GR_IR_CLEARING);
1966 assert_eq!(je.lines[0].debit_amount, Decimal::from(5000));
1967 assert_eq!(je.lines[0].credit_amount, Decimal::ZERO);
1968
1969 assert_eq!(je.lines[1].gl_account, tax_accounts::INPUT_VAT);
1971 assert_eq!(je.lines[1].debit_amount, Decimal::from(500));
1972 assert_eq!(je.lines[1].credit_amount, Decimal::ZERO);
1973
1974 assert_eq!(je.lines[2].gl_account, control_accounts::AP_CONTROL);
1976 assert_eq!(je.lines[2].credit_amount, Decimal::from(5500));
1977 assert_eq!(je.lines[2].debit_amount, Decimal::ZERO);
1978 }
1979
1980 #[test]
1981 fn test_vendor_invoice_no_tax_produces_two_lines() {
1982 let mut generator = DocumentFlowJeGenerator::new();
1984 let invoice = create_test_vendor_invoice();
1985
1986 assert_eq!(invoice.tax_amount, Decimal::ZERO);
1987
1988 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1989
1990 assert_eq!(
1992 je.line_count(),
1993 2,
1994 "Expected 2 JE lines for vendor invoice without tax"
1995 );
1996 assert!(je.is_balanced(), "Entry must be balanced");
1997
1998 assert_eq!(je.lines[0].gl_account, control_accounts::GR_IR_CLEARING);
2000 assert_eq!(je.lines[0].debit_amount, invoice.payable_amount);
2001
2002 assert_eq!(je.lines[1].gl_account, control_accounts::AP_CONTROL);
2004 assert_eq!(je.lines[1].credit_amount, invoice.payable_amount);
2005 }
2006
2007 #[test]
2008 fn test_vat_accounts_configurable() {
2009 let config = DocumentFlowJeConfig {
2011 vat_output_account: "2999".to_string(),
2012 vat_input_account: "1999".to_string(),
2013 ..Default::default()
2014 };
2015
2016 let mut generator = DocumentFlowJeGenerator::with_config_and_seed(config, 42);
2017
2018 let ci = create_test_customer_invoice_with_tax();
2020 let je = generator.generate_from_customer_invoice(&ci).unwrap();
2021 assert_eq!(
2022 je.lines[2].gl_account, "2999",
2023 "VAT output account should be configurable"
2024 );
2025
2026 let vi = create_test_vendor_invoice_with_tax();
2028 let je = generator.generate_from_vendor_invoice(&vi).unwrap();
2029 assert_eq!(
2030 je.lines[1].gl_account, "1999",
2031 "VAT input account should be configurable"
2032 );
2033 }
2034
2035 #[test]
2036 fn test_vat_entries_from_framework_accounts() {
2037 let fa = datasynth_core::FrameworkAccounts::us_gaap();
2039 let config = DocumentFlowJeConfig::from(&fa);
2040
2041 assert_eq!(config.vat_output_account, tax_accounts::VAT_PAYABLE);
2042 assert_eq!(config.vat_input_account, tax_accounts::INPUT_VAT);
2043
2044 let fa_fr = datasynth_core::FrameworkAccounts::french_gaap();
2045 let config_fr = DocumentFlowJeConfig::from(&fa_fr);
2046
2047 assert_eq!(config_fr.vat_output_account, "445710");
2048 assert_eq!(config_fr.vat_input_account, "445660");
2049 }
2050
2051 #[test]
2052 fn test_french_gaap_vat_accounts() {
2053 let config = DocumentFlowJeConfig::french_gaap();
2054 assert_eq!(config.vat_output_account, "445710"); assert_eq!(config.vat_input_account, "445660"); }
2057
2058 #[test]
2059 fn test_vat_balanced_with_multiple_items() {
2060 use datasynth_core::models::documents::CustomerInvoiceItem;
2062
2063 let mut invoice = CustomerInvoice::new(
2064 "CI-003",
2065 "1000",
2066 "C-003",
2067 2024,
2068 1,
2069 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2070 NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
2071 "JSMITH",
2072 );
2073
2074 let mut item1 = CustomerInvoiceItem::new(1, "A", Decimal::from(5), Decimal::from(100));
2076 item1.base.tax_amount = Decimal::from(50);
2077 invoice.add_item(item1);
2078
2079 let mut item2 = CustomerInvoiceItem::new(2, "B", Decimal::from(3), Decimal::from(100));
2081 item2.base.tax_amount = Decimal::from(30);
2082 invoice.add_item(item2);
2083
2084 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
2085
2086 assert_eq!(invoice.total_net_amount, Decimal::from(800));
2088 assert_eq!(invoice.total_tax_amount, Decimal::from(80));
2089 assert_eq!(invoice.total_gross_amount, Decimal::from(880));
2090
2091 let mut generator = DocumentFlowJeGenerator::new();
2092 let je = generator.generate_from_customer_invoice(&invoice).unwrap();
2093
2094 assert_eq!(je.line_count(), 3);
2095 assert!(je.is_balanced());
2096 assert_eq!(je.total_debit(), Decimal::from(880));
2097 assert_eq!(je.total_credit(), Decimal::from(880));
2098 }
2099
2100 #[test]
2101 fn test_document_types_per_source_document() {
2102 let mut generator = DocumentFlowJeGenerator::new();
2103
2104 let gr = create_test_gr();
2105 let invoice = create_test_vendor_invoice();
2106 let payment = create_test_payment();
2107
2108 let gr_je = generator.generate_from_goods_receipt(&gr).unwrap();
2109 assert_eq!(
2110 gr_je.header.document_type, "WE",
2111 "Goods receipt should be WE"
2112 );
2113
2114 let vi_je = generator.generate_from_vendor_invoice(&invoice).unwrap();
2115 assert_eq!(
2116 vi_je.header.document_type, "KR",
2117 "Vendor invoice should be KR"
2118 );
2119
2120 let pay_je = generator.generate_from_ap_payment(&payment).unwrap();
2121 assert_eq!(pay_je.header.document_type, "KZ", "AP payment should be KZ");
2122
2123 let types: std::collections::HashSet<&str> = [
2125 gr_je.header.document_type.as_str(),
2126 vi_je.header.document_type.as_str(),
2127 pay_je.header.document_type.as_str(),
2128 ]
2129 .into_iter()
2130 .collect();
2131
2132 assert!(
2133 types.len() >= 3,
2134 "Expected at least 3 distinct document types from P2P flow, got {:?}",
2135 types,
2136 );
2137 }
2138
2139 #[test]
2140 fn test_enrichment_account_descriptions_populated() {
2141 let mut generator = DocumentFlowJeGenerator::new();
2142 let gr = create_test_gr();
2143 let invoice = create_test_vendor_invoice();
2144 let payment = create_test_payment();
2145
2146 let gr_je = generator.generate_from_goods_receipt(&gr).unwrap();
2147 let vi_je = generator.generate_from_vendor_invoice(&invoice).unwrap();
2148 let pay_je = generator.generate_from_ap_payment(&payment).unwrap();
2149
2150 for je in [&gr_je, &vi_je, &pay_je] {
2152 for line in &je.lines {
2153 assert!(
2154 line.account_description.is_some(),
2155 "Line for account {} should have description, entry doc {}",
2156 line.gl_account,
2157 je.header.document_id,
2158 );
2159 }
2160 }
2161
2162 assert_eq!(
2164 gr_je.lines[0].account_description.as_deref(),
2165 Some("Inventory"),
2166 );
2167 assert_eq!(
2168 gr_je.lines[1].account_description.as_deref(),
2169 Some("GR/IR Clearing"),
2170 );
2171 }
2172
2173 #[test]
2174 fn test_enrichment_profit_center_and_line_text() {
2175 let mut generator = DocumentFlowJeGenerator::new();
2176 let gr = create_test_gr();
2177
2178 let je = generator.generate_from_goods_receipt(&gr).unwrap();
2179
2180 for line in &je.lines {
2181 assert!(
2183 line.profit_center.is_some(),
2184 "Line {} should have profit_center",
2185 line.gl_account,
2186 );
2187 let pc = line.profit_center.as_ref().unwrap();
2188 assert!(
2189 pc.starts_with("PC-"),
2190 "Profit center should start with PC-, got {}",
2191 pc,
2192 );
2193
2194 assert!(
2196 line.line_text.is_some(),
2197 "Line {} should have line_text",
2198 line.gl_account,
2199 );
2200 }
2201 }
2202
2203 #[test]
2204 fn test_enrichment_cost_center_for_expense_accounts() {
2205 let mut generator = DocumentFlowJeGenerator::new();
2206
2207 use datasynth_core::models::documents::{Delivery, DeliveryItem};
2209 let mut delivery = Delivery::new(
2210 "DEL-001".to_string(),
2211 "1000",
2212 "SO-001",
2213 "C-001",
2214 2024,
2215 1,
2216 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2217 "JSMITH",
2218 );
2219 let item = DeliveryItem::from_sales_order(
2220 10,
2221 "Test Material",
2222 Decimal::from(100),
2223 Decimal::from(50),
2224 "SO-001",
2225 10,
2226 );
2227 delivery.add_item(item);
2228 delivery.post_goods_issue("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
2229
2230 let je = generator.generate_from_delivery(&delivery).unwrap();
2231
2232 let cogs_line = je.lines.iter().find(|l| l.gl_account == "5000").unwrap();
2234 assert!(
2235 cogs_line.cost_center.is_some(),
2236 "COGS line should have cost_center assigned",
2237 );
2238 let cc = cogs_line.cost_center.as_ref().unwrap();
2239 assert!(
2240 cc.starts_with("CC"),
2241 "Cost center should start with CC, got {}",
2242 cc,
2243 );
2244
2245 let inv_line = je.lines.iter().find(|l| l.gl_account == "1200").unwrap();
2247 assert!(
2248 inv_line.cost_center.is_none(),
2249 "Non-expense line should not have cost_center",
2250 );
2251 }
2252
2253 #[test]
2254 fn test_enrichment_value_date_for_ap_ar() {
2255 let mut generator = DocumentFlowJeGenerator::new();
2256
2257 let invoice = create_test_vendor_invoice();
2258 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
2259
2260 let ap_line = je.lines.iter().find(|l| l.gl_account == "2000").unwrap();
2262 assert!(
2263 ap_line.value_date.is_some(),
2264 "AP line should have value_date set",
2265 );
2266 assert_eq!(ap_line.value_date, Some(je.header.posting_date));
2267
2268 let clearing_line = je.lines.iter().find(|l| l.gl_account == "2900").unwrap();
2270 assert!(
2271 clearing_line.value_date.is_none(),
2272 "Non-AP/AR line should not have value_date",
2273 );
2274 }
2275
2276 #[test]
2283 fn p2p_generator_populates_trading_partner_for_vendor_lines() {
2284 let mut generator = DocumentFlowJeGenerator::new();
2285
2286 let gr = create_test_gr();
2288 let gr_je = generator.generate_from_goods_receipt(&gr).unwrap();
2289 let gr_ir_line = gr_je
2290 .lines
2291 .iter()
2292 .find(|l| l.gl_account == control_accounts::GR_IR_CLEARING)
2293 .expect("GR/IR clearing line missing");
2294 assert_eq!(
2295 gr_ir_line.trading_partner.as_deref(),
2296 Some("V-001"),
2297 "GR/IR clearing line should carry vendor_id as trading_partner"
2298 );
2299 let inv_line = gr_je
2301 .lines
2302 .iter()
2303 .find(|l| l.gl_account == control_accounts::INVENTORY)
2304 .expect("Inventory line missing");
2305 assert!(
2306 inv_line.trading_partner.is_none(),
2307 "Inventory DR line should not carry trading_partner"
2308 );
2309
2310 let invoice = create_test_vendor_invoice();
2312 let vi_je = generator.generate_from_vendor_invoice(&invoice).unwrap();
2313 let ap_line = vi_je
2314 .lines
2315 .iter()
2316 .find(|l| l.gl_account == control_accounts::AP_CONTROL)
2317 .expect("AP line missing");
2318 assert_eq!(
2319 ap_line.trading_partner.as_deref(),
2320 Some("V-001"),
2321 "AP line should carry vendor_id as trading_partner"
2322 );
2323 let gr_ir_dr = vi_je
2324 .lines
2325 .iter()
2326 .find(|l| l.gl_account == control_accounts::GR_IR_CLEARING)
2327 .expect("GR/IR debit line missing");
2328 assert!(
2329 gr_ir_dr.trading_partner.is_none(),
2330 "GR/IR debit on vendor invoice should not carry trading_partner"
2331 );
2332
2333 let payment = create_test_payment();
2335 let pay_je = generator.generate_from_ap_payment(&payment).unwrap();
2336 let ap_dr = pay_je
2337 .lines
2338 .iter()
2339 .find(|l| l.gl_account == control_accounts::AP_CONTROL)
2340 .expect("AP debit line missing on payment");
2341 assert_eq!(
2342 ap_dr.trading_partner.as_deref(),
2343 Some("V-001"),
2344 "AP debit line on payment should carry vendor_id as trading_partner"
2345 );
2346 let cash_line = pay_je
2347 .lines
2348 .iter()
2349 .find(|l| l.gl_account == cash_accounts::OPERATING_CASH)
2350 .expect("Cash line missing on payment");
2351 assert!(
2352 cash_line.trading_partner.is_none(),
2353 "Cash CR line should not carry trading_partner"
2354 );
2355 }
2356
2357 #[test]
2364 fn o2c_generator_populates_trading_partner_for_customer_lines() {
2365 let mut generator = DocumentFlowJeGenerator::new();
2366
2367 let invoice = create_test_customer_invoice_with_tax();
2369 let ci_je = generator.generate_from_customer_invoice(&invoice).unwrap();
2370 let ar_line = ci_je
2371 .lines
2372 .iter()
2373 .find(|l| l.gl_account == control_accounts::AR_CONTROL)
2374 .expect("AR line missing");
2375 assert_eq!(
2376 ar_line.trading_partner.as_deref(),
2377 Some("C-001"),
2378 "AR line should carry customer_id as trading_partner"
2379 );
2380 let rev_line = ci_je
2381 .lines
2382 .iter()
2383 .find(|l| l.gl_account == revenue_accounts::PRODUCT_REVENUE)
2384 .expect("Revenue line missing");
2385 assert!(
2386 rev_line.trading_partner.is_none(),
2387 "Revenue CR line should not carry trading_partner"
2388 );
2389 let vat_line = ci_je
2390 .lines
2391 .iter()
2392 .find(|l| l.gl_account == tax_accounts::VAT_PAYABLE)
2393 .expect("VAT line missing");
2394 assert!(
2395 vat_line.trading_partner.is_none(),
2396 "VAT CR line should not carry trading_partner"
2397 );
2398
2399 let mut receipt = Payment::new_ar_receipt(
2401 "RCP-T9".to_string(),
2402 "1000",
2403 "C-001",
2404 Decimal::from(1100),
2405 2024,
2406 2,
2407 NaiveDate::from_ymd_opt(2024, 2, 28).unwrap(),
2408 "JSMITH",
2409 );
2410 receipt.post("JSMITH", NaiveDate::from_ymd_opt(2024, 2, 28).unwrap());
2411
2412 let rcp_je = generator.generate_from_ar_receipt(&receipt).unwrap();
2413 let ar_cr = rcp_je
2414 .lines
2415 .iter()
2416 .find(|l| l.gl_account == control_accounts::AR_CONTROL)
2417 .expect("AR CR line missing on receipt");
2418 assert_eq!(
2419 ar_cr.trading_partner.as_deref(),
2420 Some("C-001"),
2421 "AR CR line on receipt should carry customer_id as trading_partner"
2422 );
2423 let cash_dr = rcp_je
2424 .lines
2425 .iter()
2426 .find(|l| l.gl_account == cash_accounts::OPERATING_CASH)
2427 .expect("Cash DR line missing on receipt");
2428 assert!(
2429 cash_dr.trading_partner.is_none(),
2430 "Cash DR line on receipt should not carry trading_partner"
2431 );
2432
2433 use datasynth_core::models::documents::{Delivery, DeliveryItem};
2435 let mut delivery = Delivery::new(
2436 "DEL-T9".to_string(),
2437 "1000",
2438 "SO-001",
2439 "C-001",
2440 2024,
2441 1,
2442 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2443 "JSMITH",
2444 );
2445 let item = DeliveryItem::from_sales_order(
2446 10,
2447 "Material X",
2448 Decimal::from(100),
2449 Decimal::from(50),
2450 "SO-001",
2451 10,
2452 );
2453 delivery.add_item(item);
2454 delivery.post_goods_issue("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
2455
2456 let del_je = generator.generate_from_delivery(&delivery).unwrap();
2457 for line in &del_je.lines {
2458 assert!(
2459 line.trading_partner.is_none(),
2460 "Delivery JE lines (COGS/Inventory) should not carry trading_partner; \
2461 account {} has {:?}",
2462 line.gl_account,
2463 line.trading_partner,
2464 );
2465 }
2466 }
2467
2468 #[test]
2475 fn sp3_13_w1_5_bucket_tightening_concentrates_target_near_lo() {
2476 use rand::SeedableRng;
2477 use rand_chacha::ChaCha8Rng;
2478 use std::collections::HashMap;
2479
2480 let mut rng = ChaCha8Rng::seed_from_u64(42);
2481 let mut counts: HashMap<u32, i32> = HashMap::new();
2482 for _ in 0..10_000 {
2483 let v = super::sample_within_bucket(4, 9, &mut rng);
2484 *counts.entry(v).or_insert(0) += 1;
2485 }
2486
2487 let total: f64 = counts.values().sum::<i32>() as f64;
2488 let mean: f64 = counts
2489 .iter()
2490 .map(|(&k, &c)| k as f64 * (c as f64 / total))
2491 .sum();
2492 assert!(
2493 mean < 5.5,
2494 "geometric within-bucket draw should pull mean below 5.5 (uniform mean is 6.5), got {mean:.3}"
2495 );
2496
2497 let count_lo = counts.get(&4).copied().unwrap_or(0);
2498 let lo_fraction = count_lo as f64 / total;
2499 assert!(
2500 lo_fraction > 0.40,
2501 "expected ≥40 % of draws at lower bound (4), got {lo_fraction:.3} ({count_lo}/10000)"
2502 );
2503 }
2504}