1use std::collections::HashMap;
17
18use chrono::NaiveDate;
19use rust_decimal::Decimal;
20
21use datasynth_core::accounts::{
22 cash_accounts, control_accounts, expense_accounts, revenue_accounts, tax_accounts,
23};
24use datasynth_core::models::{
25 documents::{CustomerInvoice, Delivery, GoodsReceipt, Payment, VendorInvoice},
26 BusinessProcess, DocumentRef, JournalEntry, JournalEntryHeader, JournalEntryLine,
27 TransactionSource,
28};
29use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
30
31use super::{O2CDocumentChain, P2PDocumentChain};
32
33#[derive(Debug, Clone)]
35pub struct DocumentFlowJeConfig {
36 pub inventory_account: String,
38 pub gr_ir_clearing_account: String,
40 pub ap_account: String,
42 pub cash_account: String,
44 pub ar_account: String,
46 pub revenue_account: String,
48 pub cogs_account: String,
50 pub vat_output_account: String,
52 pub vat_input_account: String,
54 pub populate_fec_fields: bool,
57}
58
59impl Default for DocumentFlowJeConfig {
60 fn default() -> Self {
61 Self {
62 inventory_account: control_accounts::INVENTORY.to_string(),
63 gr_ir_clearing_account: control_accounts::GR_IR_CLEARING.to_string(),
64 ap_account: control_accounts::AP_CONTROL.to_string(),
65 cash_account: cash_accounts::OPERATING_CASH.to_string(),
66 ar_account: control_accounts::AR_CONTROL.to_string(),
67 revenue_account: revenue_accounts::PRODUCT_REVENUE.to_string(),
68 cogs_account: expense_accounts::COGS.to_string(),
69 vat_output_account: tax_accounts::VAT_PAYABLE.to_string(),
70 vat_input_account: tax_accounts::INPUT_VAT.to_string(),
71 populate_fec_fields: false,
72 }
73 }
74}
75
76impl DocumentFlowJeConfig {
77 pub fn french_gaap() -> Self {
79 use datasynth_core::pcg;
80 Self {
81 inventory_account: pcg::control_accounts::INVENTORY.to_string(),
82 gr_ir_clearing_account: pcg::control_accounts::GR_IR_CLEARING.to_string(),
83 ap_account: pcg::control_accounts::AP_CONTROL.to_string(),
84 cash_account: pcg::cash_accounts::BANK_ACCOUNT.to_string(),
85 ar_account: pcg::control_accounts::AR_CONTROL.to_string(),
86 revenue_account: pcg::revenue_accounts::PRODUCT_REVENUE.to_string(),
87 cogs_account: pcg::expense_accounts::COGS.to_string(),
88 vat_output_account: pcg::tax_accounts::OUTPUT_VAT.to_string(),
89 vat_input_account: pcg::tax_accounts::INPUT_VAT.to_string(),
90 populate_fec_fields: true,
91 }
92 }
93}
94
95impl From<&datasynth_core::FrameworkAccounts> for DocumentFlowJeConfig {
96 fn from(fa: &datasynth_core::FrameworkAccounts) -> Self {
97 Self {
98 inventory_account: fa.inventory.clone(),
99 gr_ir_clearing_account: fa.gr_ir_clearing.clone(),
100 ap_account: fa.ap_control.clone(),
101 cash_account: fa.bank_account.clone(),
102 ar_account: fa.ar_control.clone(),
103 revenue_account: fa.product_revenue.clone(),
104 cogs_account: fa.cogs.clone(),
105 vat_output_account: fa.vat_payable.clone(),
106 vat_input_account: fa.input_vat.clone(),
107 populate_fec_fields: fa.audit_export.fec_enabled,
108 }
109 }
110}
111
112pub struct DocumentFlowJeGenerator {
114 config: DocumentFlowJeConfig,
115 uuid_factory: DeterministicUuidFactory,
116 auxiliary_account_lookup: HashMap<String, String>,
121}
122
123impl DocumentFlowJeGenerator {
124 pub fn new() -> Self {
126 Self::with_config_and_seed(DocumentFlowJeConfig::default(), 0)
127 }
128
129 pub fn with_config_and_seed(config: DocumentFlowJeConfig, seed: u64) -> Self {
131 Self {
132 config,
133 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::DocumentFlow),
134 auxiliary_account_lookup: HashMap::new(),
135 }
136 }
137
138 pub fn set_auxiliary_account_lookup(&mut self, lookup: HashMap<String, String>) {
144 self.auxiliary_account_lookup = lookup;
145 }
146
147 fn account_description_map(&self) -> HashMap<String, String> {
149 let mut map = HashMap::new();
150 map.insert(
151 self.config.inventory_account.clone(),
152 "Inventory".to_string(),
153 );
154 map.insert(
155 self.config.gr_ir_clearing_account.clone(),
156 "GR/IR Clearing".to_string(),
157 );
158 map.insert(
159 self.config.ap_account.clone(),
160 "Accounts Payable".to_string(),
161 );
162 map.insert(
163 self.config.cash_account.clone(),
164 "Cash and Cash Equivalents".to_string(),
165 );
166 map.insert(
167 self.config.ar_account.clone(),
168 "Accounts Receivable".to_string(),
169 );
170 map.insert(
171 self.config.revenue_account.clone(),
172 "Product Revenue".to_string(),
173 );
174 map.insert(
175 self.config.cogs_account.clone(),
176 "Cost of Goods Sold".to_string(),
177 );
178 map.insert(
179 self.config.vat_output_account.clone(),
180 "VAT Payable".to_string(),
181 );
182 map.insert(
183 self.config.vat_input_account.clone(),
184 "Input VAT".to_string(),
185 );
186 map
187 }
188
189 const COST_CENTER_POOL: &'static [&'static str] =
191 &["CC1000", "CC2000", "CC3000", "CC4000", "CC5000"];
192
193 fn enrich_line_items(&self, entry: &mut JournalEntry) {
199 let desc_map = self.account_description_map();
200 let posting_date = entry.header.posting_date;
201 let company_code = &entry.header.company_code;
202 let header_text = entry.header.header_text.clone();
203 let business_process = entry.header.business_process;
204
205 let doc_id_bytes = entry.header.document_id.as_bytes();
207 let mut cc_seed: usize = 0;
208 for &b in doc_id_bytes {
209 cc_seed = cc_seed.wrapping_add(b as usize);
210 }
211
212 for (i, line) in entry.lines.iter_mut().enumerate() {
213 if line.account_description.is_none() {
215 line.account_description = desc_map.get(&line.gl_account).cloned();
216 }
217
218 if line.cost_center.is_none() {
220 let first_char = line.gl_account.chars().next().unwrap_or('0');
221 if first_char == '5' || first_char == '6' {
222 let idx = cc_seed.wrapping_add(i) % Self::COST_CENTER_POOL.len();
223 line.cost_center = Some(Self::COST_CENTER_POOL[idx].to_string());
224 }
225 }
226
227 if line.profit_center.is_none() {
229 let suffix = match business_process {
230 Some(BusinessProcess::P2P) => "-P2P",
231 Some(BusinessProcess::O2C) => "-O2C",
232 _ => "",
233 };
234 line.profit_center = Some(format!("PC-{company_code}{suffix}"));
235 }
236
237 if line.line_text.is_none() {
239 line.line_text = header_text.clone();
240 }
241
242 if line.value_date.is_none()
244 && (line.gl_account == self.config.ar_account
245 || line.gl_account == self.config.ap_account)
246 {
247 line.value_date = Some(posting_date);
248 }
249
250 if line.assignment.is_none()
252 && (line.gl_account == self.config.ap_account
253 || line.gl_account == self.config.ar_account)
254 {
255 if let Some(ref ht) = header_text {
256 if let Some(partner_part) = ht.rsplit(" - ").next() {
257 line.assignment = Some(partner_part.to_string());
258 }
259 }
260 }
261 }
262 }
263
264 fn set_auxiliary_fields(
273 &self,
274 line: &mut JournalEntryLine,
275 partner_id: &str,
276 partner_label: &str,
277 ) {
278 if !self.config.populate_fec_fields {
279 return;
280 }
281 if line.gl_account == self.config.ap_account || line.gl_account == self.config.ar_account {
282 let aux_account = self
285 .auxiliary_account_lookup
286 .get(partner_id)
287 .cloned()
288 .unwrap_or_else(|| partner_id.to_string());
289 line.auxiliary_account_number = Some(aux_account);
290 line.auxiliary_account_label = Some(partner_label.to_string());
291 }
292 }
293
294 fn apply_lettrage(
300 &self,
301 entries: &mut [JournalEntry],
302 chain_id: &str,
303 lettrage_date: NaiveDate,
304 ) {
305 if !self.config.populate_fec_fields {
306 return;
307 }
308 let code = format!("LTR-{}", &chain_id[..chain_id.len().min(8)]);
309 for entry in entries.iter_mut() {
310 for line in entry.lines.iter_mut() {
311 if line.gl_account == self.config.ap_account
312 || line.gl_account == self.config.ar_account
313 {
314 line.lettrage = Some(code.clone());
315 line.lettrage_date = Some(lettrage_date);
316 }
317 }
318 }
319 }
320
321 fn wire_predecessor_chain(entries: &mut [JournalEntry]) {
347 if entries.len() < 2 {
348 return;
349 }
350 for i in 1..entries.len() {
351 let prev_lines: Vec<(String, String)> = entries[i - 1]
354 .lines
355 .iter()
356 .map(|l| {
357 let tx_id = l.transaction_id.clone().unwrap_or_else(|| {
358 datasynth_core::models::JournalEntryLine::derive_transaction_id(
359 l.document_id,
360 l.line_number,
361 )
362 });
363 (l.gl_account.clone(), tx_id)
364 })
365 .collect();
366
367 for line in entries[i].lines.iter_mut() {
368 if line.predecessor_line_id.is_some() {
369 continue;
370 }
371 if let Some((_, tx_id)) =
372 prev_lines.iter().find(|(acct, _)| acct == &line.gl_account)
373 {
374 line.predecessor_line_id = Some(tx_id.clone());
375 }
376 }
377 }
378 }
379
380 pub fn generate_from_p2p_chain(&mut self, chain: &P2PDocumentChain) -> Vec<JournalEntry> {
382 let mut entries = Vec::new();
383
384 for gr in &chain.goods_receipts {
386 if let Some(je) = self.generate_from_goods_receipt(gr) {
387 entries.push(je);
388 }
389 }
390
391 if let Some(ref invoice) = chain.vendor_invoice {
393 if let Some(je) = self.generate_from_vendor_invoice(invoice) {
394 entries.push(je);
395 }
396 }
397
398 if let Some(ref payment) = chain.payment {
400 if let Some(je) = self.generate_from_ap_payment(payment) {
401 entries.push(je);
402 }
403 }
404
405 for payment in &chain.remainder_payments {
407 if let Some(je) = self.generate_from_ap_payment(payment) {
408 entries.push(je);
409 }
410 }
411
412 if self.config.populate_fec_fields && chain.is_complete {
414 if let Some(ref payment) = chain.payment {
415 let posting_date = payment
416 .header
417 .posting_date
418 .unwrap_or(payment.header.document_date);
419 self.apply_lettrage(
420 &mut entries,
421 &chain.purchase_order.header.document_id,
422 posting_date,
423 );
424 }
425 }
426
427 Self::wire_predecessor_chain(&mut entries);
430
431 entries
432 }
433
434 pub fn generate_from_o2c_chain(&mut self, chain: &O2CDocumentChain) -> Vec<JournalEntry> {
436 let mut entries = Vec::new();
437
438 for delivery in &chain.deliveries {
440 if let Some(je) = self.generate_from_delivery(delivery) {
441 entries.push(je);
442 }
443 }
444
445 if let Some(ref invoice) = chain.customer_invoice {
447 if let Some(je) = self.generate_from_customer_invoice(invoice) {
448 entries.push(je);
449 }
450 }
451
452 if let Some(ref receipt) = chain.customer_receipt {
454 if let Some(je) = self.generate_from_ar_receipt(receipt) {
455 entries.push(je);
456 }
457 }
458
459 for receipt in &chain.remainder_receipts {
461 if let Some(je) = self.generate_from_ar_receipt(receipt) {
462 entries.push(je);
463 }
464 }
465
466 if self.config.populate_fec_fields && chain.customer_receipt.is_some() {
468 if let Some(ref receipt) = chain.customer_receipt {
469 let posting_date = receipt
470 .header
471 .posting_date
472 .unwrap_or(receipt.header.document_date);
473 self.apply_lettrage(
474 &mut entries,
475 &chain.sales_order.header.document_id,
476 posting_date,
477 );
478 }
479 }
480
481 Self::wire_predecessor_chain(&mut entries);
483
484 entries
485 }
486
487 pub fn generate_from_goods_receipt(&mut self, gr: &GoodsReceipt) -> Option<JournalEntry> {
490 if gr.items.is_empty() {
491 return None;
492 }
493
494 let document_id = self.uuid_factory.next();
495
496 let total_amount = if gr.total_value > Decimal::ZERO {
498 gr.total_value
499 } else {
500 gr.items
501 .iter()
502 .map(|item| item.base.net_amount)
503 .sum::<Decimal>()
504 };
505
506 if total_amount == Decimal::ZERO {
507 return None;
508 }
509
510 let posting_date = gr.header.posting_date.unwrap_or(gr.header.document_date);
512
513 let mut header = JournalEntryHeader::with_deterministic_id(
514 gr.header.company_code.clone(),
515 posting_date,
516 document_id,
517 );
518 header.source = TransactionSource::Automated;
519 header.business_process = Some(BusinessProcess::P2P);
520 header.document_type = "WE".to_string();
521 header.reference = Some(format!("GR:{}", gr.header.document_id));
522 header.source_document = Some(DocumentRef::GoodsReceipt(gr.header.document_id.clone()));
523 header.header_text = Some(format!(
524 "Goods Receipt {} - {}",
525 gr.header.document_id,
526 gr.vendor_id.as_deref().unwrap_or("Unknown")
527 ));
528
529 let mut entry = JournalEntry::new(header);
530
531 let debit_line = JournalEntryLine::debit(
533 entry.header.document_id,
534 1,
535 self.config.inventory_account.clone(),
536 total_amount,
537 );
538 entry.add_line(debit_line);
539
540 let credit_line = JournalEntryLine::credit(
542 entry.header.document_id,
543 2,
544 self.config.gr_ir_clearing_account.clone(),
545 total_amount,
546 );
547 entry.add_line(credit_line);
548
549 self.enrich_line_items(&mut entry);
550 Some(entry)
551 }
552
553 pub fn generate_from_vendor_invoice(
564 &mut self,
565 invoice: &VendorInvoice,
566 ) -> Option<JournalEntry> {
567 if invoice.payable_amount == Decimal::ZERO {
568 return None;
569 }
570
571 let document_id = self.uuid_factory.next();
572
573 let posting_date = invoice
575 .header
576 .posting_date
577 .unwrap_or(invoice.header.document_date);
578
579 let mut header = JournalEntryHeader::with_deterministic_id(
580 invoice.header.company_code.clone(),
581 posting_date,
582 document_id,
583 );
584 header.source = TransactionSource::Automated;
585 header.business_process = Some(BusinessProcess::P2P);
586 header.document_type = "KR".to_string();
587 header.reference = Some(format!("VI:{}", invoice.header.document_id));
588 header.source_document = Some(DocumentRef::VendorInvoice(
589 invoice.header.document_id.clone(),
590 ));
591 header.header_text = Some(format!(
592 "Vendor Invoice {} - {}",
593 invoice.vendor_invoice_number, invoice.vendor_id
594 ));
595
596 let mut entry = JournalEntry::new(header);
597
598 let has_vat = invoice.tax_amount > Decimal::ZERO;
599 let clearing_amount = if has_vat {
600 invoice.net_amount
601 } else {
602 invoice.payable_amount
603 };
604
605 let debit_line = JournalEntryLine::debit(
607 entry.header.document_id,
608 1,
609 self.config.gr_ir_clearing_account.clone(),
610 clearing_amount,
611 );
612 entry.add_line(debit_line);
613
614 if has_vat {
616 let vat_line = JournalEntryLine::debit(
617 entry.header.document_id,
618 2,
619 self.config.vat_input_account.clone(),
620 invoice.tax_amount,
621 );
622 entry.add_line(vat_line);
623 }
624
625 let mut credit_line = JournalEntryLine::credit(
627 entry.header.document_id,
628 if has_vat { 3 } else { 2 },
629 self.config.ap_account.clone(),
630 invoice.payable_amount,
631 );
632 self.set_auxiliary_fields(&mut credit_line, &invoice.vendor_id, &invoice.vendor_id);
633 entry.add_line(credit_line);
634
635 self.enrich_line_items(&mut entry);
636 Some(entry)
637 }
638
639 pub fn generate_from_ap_payment(&mut self, payment: &Payment) -> Option<JournalEntry> {
642 if payment.amount == Decimal::ZERO {
643 return None;
644 }
645
646 let document_id = self.uuid_factory.next();
647
648 let posting_date = payment
650 .header
651 .posting_date
652 .unwrap_or(payment.header.document_date);
653
654 let mut header = JournalEntryHeader::with_deterministic_id(
655 payment.header.company_code.clone(),
656 posting_date,
657 document_id,
658 );
659 header.source = TransactionSource::Automated;
660 header.business_process = Some(BusinessProcess::P2P);
661 header.document_type = "KZ".to_string();
662 header.reference = Some(format!("PAY:{}", payment.header.document_id));
663 header.source_document = Some(DocumentRef::Payment(payment.header.document_id.clone()));
664 header.header_text = Some(format!(
665 "Payment {} - {}",
666 payment.header.document_id, payment.business_partner_id
667 ));
668
669 let mut entry = JournalEntry::new(header);
670
671 let mut debit_line = JournalEntryLine::debit(
673 entry.header.document_id,
674 1,
675 self.config.ap_account.clone(),
676 payment.amount,
677 );
678 self.set_auxiliary_fields(
679 &mut debit_line,
680 &payment.business_partner_id,
681 &payment.business_partner_id,
682 );
683 entry.add_line(debit_line);
684
685 let credit_line = JournalEntryLine::credit(
687 entry.header.document_id,
688 2,
689 self.config.cash_account.clone(),
690 payment.amount,
691 );
692 entry.add_line(credit_line);
693
694 self.enrich_line_items(&mut entry);
695 Some(entry)
696 }
697
698 pub fn generate_from_delivery(&mut self, delivery: &Delivery) -> Option<JournalEntry> {
701 if delivery.items.is_empty() {
702 return None;
703 }
704
705 let document_id = self.uuid_factory.next();
706
707 let total_cost = delivery
709 .items
710 .iter()
711 .map(|item| item.base.net_amount)
712 .sum::<Decimal>();
713
714 if total_cost == Decimal::ZERO {
715 return None;
716 }
717
718 let posting_date = delivery
720 .header
721 .posting_date
722 .unwrap_or(delivery.header.document_date);
723
724 let mut header = JournalEntryHeader::with_deterministic_id(
725 delivery.header.company_code.clone(),
726 posting_date,
727 document_id,
728 );
729 header.source = TransactionSource::Automated;
730 header.business_process = Some(BusinessProcess::O2C);
731 header.document_type = "WL".to_string();
732 header.reference = Some(format!("DEL:{}", delivery.header.document_id));
733 header.source_document = Some(DocumentRef::Delivery(delivery.header.document_id.clone()));
734 header.header_text = Some(format!(
735 "Delivery {} - {}",
736 delivery.header.document_id, delivery.customer_id
737 ));
738
739 let mut entry = JournalEntry::new(header);
740
741 let debit_line = JournalEntryLine::debit(
743 entry.header.document_id,
744 1,
745 self.config.cogs_account.clone(),
746 total_cost,
747 );
748 entry.add_line(debit_line);
749
750 let credit_line = JournalEntryLine::credit(
752 entry.header.document_id,
753 2,
754 self.config.inventory_account.clone(),
755 total_cost,
756 );
757 entry.add_line(credit_line);
758
759 self.enrich_line_items(&mut entry);
760 Some(entry)
761 }
762
763 pub fn generate_from_customer_invoice(
774 &mut self,
775 invoice: &CustomerInvoice,
776 ) -> Option<JournalEntry> {
777 if invoice.total_gross_amount == Decimal::ZERO {
778 return None;
779 }
780
781 let document_id = self.uuid_factory.next();
782
783 let posting_date = invoice
785 .header
786 .posting_date
787 .unwrap_or(invoice.header.document_date);
788
789 let mut header = JournalEntryHeader::with_deterministic_id(
790 invoice.header.company_code.clone(),
791 posting_date,
792 document_id,
793 );
794 header.source = TransactionSource::Automated;
795 header.business_process = Some(BusinessProcess::O2C);
796 header.document_type = "DR".to_string();
797 header.reference = Some(format!("CI:{}", invoice.header.document_id));
798 header.source_document = Some(DocumentRef::CustomerInvoice(
799 invoice.header.document_id.clone(),
800 ));
801 header.header_text = Some(format!(
802 "Customer Invoice {} - {}",
803 invoice.header.document_id, invoice.customer_id
804 ));
805
806 let mut entry = JournalEntry::new(header);
807
808 let mut debit_line = JournalEntryLine::debit(
810 entry.header.document_id,
811 1,
812 self.config.ar_account.clone(),
813 invoice.total_gross_amount,
814 );
815 self.set_auxiliary_fields(&mut debit_line, &invoice.customer_id, &invoice.customer_id);
816 entry.add_line(debit_line);
817
818 let revenue_amount = if invoice.total_tax_amount > Decimal::ZERO {
820 invoice.total_net_amount
821 } else {
822 invoice.total_gross_amount
823 };
824 let credit_line = JournalEntryLine::credit(
825 entry.header.document_id,
826 2,
827 self.config.revenue_account.clone(),
828 revenue_amount,
829 );
830 entry.add_line(credit_line);
831
832 if invoice.total_tax_amount > Decimal::ZERO {
834 let vat_line = JournalEntryLine::credit(
835 entry.header.document_id,
836 3,
837 self.config.vat_output_account.clone(),
838 invoice.total_tax_amount,
839 );
840 entry.add_line(vat_line);
841 }
842
843 self.enrich_line_items(&mut entry);
844 Some(entry)
845 }
846
847 pub fn generate_from_ar_receipt(&mut self, payment: &Payment) -> Option<JournalEntry> {
850 if payment.amount == Decimal::ZERO {
851 return None;
852 }
853
854 let document_id = self.uuid_factory.next();
855
856 let posting_date = payment
858 .header
859 .posting_date
860 .unwrap_or(payment.header.document_date);
861
862 let mut header = JournalEntryHeader::with_deterministic_id(
863 payment.header.company_code.clone(),
864 posting_date,
865 document_id,
866 );
867 header.source = TransactionSource::Automated;
868 header.business_process = Some(BusinessProcess::O2C);
869 header.document_type = "DZ".to_string();
870 header.reference = Some(format!("RCP:{}", payment.header.document_id));
871 header.source_document = Some(DocumentRef::Receipt(payment.header.document_id.clone()));
872 header.header_text = Some(format!(
873 "Customer Receipt {} - {}",
874 payment.header.document_id, payment.business_partner_id
875 ));
876
877 let mut entry = JournalEntry::new(header);
878
879 let debit_line = JournalEntryLine::debit(
881 entry.header.document_id,
882 1,
883 self.config.cash_account.clone(),
884 payment.amount,
885 );
886 entry.add_line(debit_line);
887
888 let mut credit_line = JournalEntryLine::credit(
890 entry.header.document_id,
891 2,
892 self.config.ar_account.clone(),
893 payment.amount,
894 );
895 self.set_auxiliary_fields(
896 &mut credit_line,
897 &payment.business_partner_id,
898 &payment.business_partner_id,
899 );
900 entry.add_line(credit_line);
901
902 self.enrich_line_items(&mut entry);
903 Some(entry)
904 }
905}
906
907impl Default for DocumentFlowJeGenerator {
908 fn default() -> Self {
909 Self::new()
910 }
911}
912
913#[cfg(test)]
914#[allow(clippy::unwrap_used)]
915mod tests {
916 use super::*;
917 use chrono::NaiveDate;
918 use datasynth_core::models::documents::{GoodsReceiptItem, MovementType};
919
920 fn create_test_gr() -> GoodsReceipt {
921 let mut gr = GoodsReceipt::from_purchase_order(
922 "GR-001".to_string(),
923 "1000",
924 "PO-001",
925 "V-001",
926 "P1000",
927 "0001",
928 2024,
929 1,
930 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
931 "JSMITH",
932 );
933
934 let item = GoodsReceiptItem::from_po(
935 10,
936 "Test Material",
937 Decimal::from(100),
938 Decimal::from(50),
939 "PO-001",
940 10,
941 )
942 .with_movement_type(MovementType::GrForPo);
943
944 gr.add_item(item);
945 gr.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
946
947 gr
948 }
949
950 fn create_test_vendor_invoice() -> VendorInvoice {
951 use datasynth_core::models::documents::VendorInvoiceItem;
952
953 let mut invoice = VendorInvoice::new(
954 "VI-001".to_string(),
955 "1000",
956 "V-001",
957 "INV-12345".to_string(),
958 2024,
959 1,
960 NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
961 "JSMITH",
962 );
963
964 let item = VendorInvoiceItem::from_po_gr(
965 10,
966 "Test Material",
967 Decimal::from(100),
968 Decimal::from(50),
969 "PO-001",
970 10,
971 Some("GR-001".to_string()),
972 Some(10),
973 );
974
975 invoice.add_item(item);
976 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
977
978 invoice
979 }
980
981 fn create_test_payment() -> Payment {
982 let mut payment = Payment::new_ap_payment(
983 "PAY-001".to_string(),
984 "1000",
985 "V-001",
986 Decimal::from(5000),
987 2024,
988 2,
989 NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
990 "JSMITH",
991 );
992
993 payment.post("JSMITH", NaiveDate::from_ymd_opt(2024, 2, 15).unwrap());
994
995 payment
996 }
997
998 #[test]
999 fn test_generate_from_goods_receipt() {
1000 let mut generator = DocumentFlowJeGenerator::new();
1001 let gr = create_test_gr();
1002
1003 let je = generator.generate_from_goods_receipt(&gr);
1004
1005 assert!(je.is_some());
1006 let je = je.unwrap();
1007
1008 assert!(je.is_balanced());
1010
1011 assert_eq!(je.line_count(), 2);
1013
1014 assert!(je.total_debit() > Decimal::ZERO);
1016 assert_eq!(je.total_debit(), je.total_credit());
1017
1018 assert!(je.header.reference.is_some());
1020 assert!(je.header.reference.as_ref().unwrap().contains("GR:"));
1021 }
1022
1023 #[test]
1024 fn test_generate_from_vendor_invoice() {
1025 let mut generator = DocumentFlowJeGenerator::new();
1026 let invoice = create_test_vendor_invoice();
1027
1028 let je = generator.generate_from_vendor_invoice(&invoice);
1029
1030 assert!(je.is_some());
1031 let je = je.unwrap();
1032
1033 assert!(je.is_balanced());
1034 assert_eq!(je.line_count(), 2);
1035 assert!(je.header.reference.as_ref().unwrap().contains("VI:"));
1036 }
1037
1038 #[test]
1039 fn test_generate_from_ap_payment() {
1040 let mut generator = DocumentFlowJeGenerator::new();
1041 let payment = create_test_payment();
1042
1043 let je = generator.generate_from_ap_payment(&payment);
1044
1045 assert!(je.is_some());
1046 let je = je.unwrap();
1047
1048 assert!(je.is_balanced());
1049 assert_eq!(je.line_count(), 2);
1050 assert!(je.header.reference.as_ref().unwrap().contains("PAY:"));
1051 }
1052
1053 #[test]
1054 fn test_all_entries_are_balanced() {
1055 let mut generator = DocumentFlowJeGenerator::new();
1056
1057 let gr = create_test_gr();
1058 let invoice = create_test_vendor_invoice();
1059 let payment = create_test_payment();
1060
1061 let entries = vec![
1062 generator.generate_from_goods_receipt(&gr),
1063 generator.generate_from_vendor_invoice(&invoice),
1064 generator.generate_from_ap_payment(&payment),
1065 ];
1066
1067 for entry in entries.into_iter().flatten() {
1068 assert!(
1069 entry.is_balanced(),
1070 "Entry {} is not balanced",
1071 entry.header.document_id
1072 );
1073 }
1074 }
1075
1076 #[test]
1081 fn test_french_gaap_auxiliary_on_ap_ar_lines_only() {
1082 let mut generator =
1084 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1085
1086 let invoice = create_test_vendor_invoice();
1088 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1089
1090 assert!(
1092 je.lines[0].auxiliary_account_number.is_none(),
1093 "GR/IR clearing line should not have auxiliary"
1094 );
1095
1096 assert_eq!(
1098 je.lines[1].auxiliary_account_number.as_deref(),
1099 Some("V-001"),
1100 "AP line should have vendor ID as auxiliary"
1101 );
1102 assert_eq!(
1103 je.lines[1].auxiliary_account_label.as_deref(),
1104 Some("V-001"),
1105 );
1106 }
1107
1108 #[test]
1109 fn test_french_gaap_lettrage_on_complete_p2p_chain() {
1110 use datasynth_core::models::documents::PurchaseOrder;
1111
1112 let mut generator =
1113 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1114
1115 let po = PurchaseOrder::new(
1116 "PO-001",
1117 "1000",
1118 "V-001",
1119 2024,
1120 1,
1121 NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(),
1122 "JSMITH",
1123 );
1124
1125 let chain = P2PDocumentChain {
1126 purchase_order: po,
1127 goods_receipts: vec![create_test_gr()],
1128 vendor_invoice: Some(create_test_vendor_invoice()),
1129 payment: Some(create_test_payment()),
1130 remainder_payments: Vec::new(),
1131 is_complete: true,
1132 three_way_match_passed: true,
1133 payment_timing: None,
1134 };
1135
1136 let entries = generator.generate_from_p2p_chain(&chain);
1137 assert!(!entries.is_empty());
1138
1139 let ap_account = &generator.config.ap_account;
1141 let mut lettrage_codes: Vec<&str> = Vec::new();
1142 for entry in &entries {
1143 for line in &entry.lines {
1144 if &line.gl_account == ap_account {
1145 assert!(
1146 line.lettrage.is_some(),
1147 "AP line should have lettrage on complete chain"
1148 );
1149 assert!(line.lettrage_date.is_some());
1150 lettrage_codes.push(line.lettrage.as_deref().unwrap());
1151 } else {
1152 assert!(
1153 line.lettrage.is_none(),
1154 "Non-AP line should not have lettrage"
1155 );
1156 }
1157 }
1158 }
1159
1160 assert!(!lettrage_codes.is_empty());
1162 assert!(
1163 lettrage_codes.iter().all(|c| *c == lettrage_codes[0]),
1164 "All AP lines should share the same lettrage code"
1165 );
1166 assert!(lettrage_codes[0].starts_with("LTR-"));
1167 }
1168
1169 #[test]
1170 fn test_incomplete_chain_has_no_lettrage() {
1171 use datasynth_core::models::documents::PurchaseOrder;
1172
1173 let mut generator =
1174 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1175
1176 let po = PurchaseOrder::new(
1177 "PO-002",
1178 "1000",
1179 "V-001",
1180 2024,
1181 1,
1182 NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(),
1183 "JSMITH",
1184 );
1185
1186 let chain = P2PDocumentChain {
1188 purchase_order: po,
1189 goods_receipts: vec![create_test_gr()],
1190 vendor_invoice: Some(create_test_vendor_invoice()),
1191 payment: None,
1192 remainder_payments: Vec::new(),
1193 is_complete: false,
1194 three_way_match_passed: false,
1195 payment_timing: None,
1196 };
1197
1198 let entries = generator.generate_from_p2p_chain(&chain);
1199
1200 for entry in &entries {
1201 for line in &entry.lines {
1202 assert!(
1203 line.lettrage.is_none(),
1204 "Incomplete chain should have no lettrage"
1205 );
1206 }
1207 }
1208 }
1209
1210 #[test]
1211 fn test_default_config_no_fec_fields() {
1212 let mut generator = DocumentFlowJeGenerator::new();
1214
1215 let invoice = create_test_vendor_invoice();
1216 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1217
1218 for line in &je.lines {
1219 assert!(line.auxiliary_account_number.is_none());
1220 assert!(line.auxiliary_account_label.is_none());
1221 assert!(line.lettrage.is_none());
1222 assert!(line.lettrage_date.is_none());
1223 }
1224 }
1225
1226 #[test]
1227 fn test_auxiliary_lookup_uses_gl_account_instead_of_partner_id() {
1228 let mut generator =
1231 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1232
1233 let mut lookup = HashMap::new();
1234 lookup.insert("V-001".to_string(), "4010001".to_string());
1235 generator.set_auxiliary_account_lookup(lookup);
1236
1237 let invoice = create_test_vendor_invoice();
1238 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1239
1240 assert_eq!(
1242 je.lines[1].auxiliary_account_number.as_deref(),
1243 Some("4010001"),
1244 "AP line should use auxiliary GL account from lookup"
1245 );
1246 assert_eq!(
1248 je.lines[1].auxiliary_account_label.as_deref(),
1249 Some("V-001"),
1250 );
1251 }
1252
1253 #[test]
1254 fn test_auxiliary_lookup_fallback_to_partner_id() {
1255 let mut generator =
1258 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1259
1260 let mut lookup = HashMap::new();
1262 lookup.insert("V-999".to_string(), "4019999".to_string());
1263 generator.set_auxiliary_account_lookup(lookup);
1264
1265 let invoice = create_test_vendor_invoice();
1266 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1267
1268 assert_eq!(
1270 je.lines[1].auxiliary_account_number.as_deref(),
1271 Some("V-001"),
1272 "Should fall back to partner ID when not in lookup"
1273 );
1274 }
1275
1276 #[test]
1277 fn test_auxiliary_lookup_works_for_customer_receipt() {
1278 let mut generator =
1280 DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1281
1282 let mut lookup = HashMap::new();
1283 lookup.insert("C-001".to_string(), "4110001".to_string());
1284 generator.set_auxiliary_account_lookup(lookup);
1285
1286 let mut receipt = Payment::new_ar_receipt(
1287 "RCP-001".to_string(),
1288 "1000",
1289 "C-001",
1290 Decimal::from(3000),
1291 2024,
1292 3,
1293 NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
1294 "JSMITH",
1295 );
1296 receipt.post("JSMITH", NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
1297
1298 let je = generator.generate_from_ar_receipt(&receipt).unwrap();
1299
1300 assert_eq!(
1302 je.lines[1].auxiliary_account_number.as_deref(),
1303 Some("4110001"),
1304 "AR line should use auxiliary GL account from lookup"
1305 );
1306 }
1307
1308 fn create_test_customer_invoice_with_tax() -> CustomerInvoice {
1314 use datasynth_core::models::documents::CustomerInvoiceItem;
1315
1316 let mut invoice = CustomerInvoice::new(
1317 "CI-001",
1318 "1000",
1319 "C-001",
1320 2024,
1321 1,
1322 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1323 NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
1324 "JSMITH",
1325 );
1326
1327 let mut item =
1329 CustomerInvoiceItem::new(1, "Product A", Decimal::from(10), Decimal::from(100));
1330 item.base.tax_amount = Decimal::from(100);
1331 invoice.add_item(item);
1332 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1333
1334 invoice
1335 }
1336
1337 fn create_test_customer_invoice_no_tax() -> CustomerInvoice {
1339 use datasynth_core::models::documents::CustomerInvoiceItem;
1340
1341 let mut invoice = CustomerInvoice::new(
1342 "CI-002",
1343 "1000",
1344 "C-002",
1345 2024,
1346 1,
1347 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1348 NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
1349 "JSMITH",
1350 );
1351
1352 let item = CustomerInvoiceItem::new(1, "Product B", Decimal::from(10), Decimal::from(100));
1353 invoice.add_item(item);
1354 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1355
1356 invoice
1357 }
1358
1359 fn create_test_vendor_invoice_with_tax() -> VendorInvoice {
1361 use datasynth_core::models::documents::VendorInvoiceItem;
1362
1363 let mut invoice = VendorInvoice::new(
1364 "VI-002".to_string(),
1365 "1000",
1366 "V-001",
1367 "INV-TAX-001".to_string(),
1368 2024,
1369 1,
1370 NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
1371 "JSMITH",
1372 );
1373
1374 let item = VendorInvoiceItem::from_po_gr(
1376 10,
1377 "Test Material",
1378 Decimal::from(100),
1379 Decimal::from(50),
1380 "PO-001",
1381 10,
1382 Some("GR-001".to_string()),
1383 Some(10),
1384 )
1385 .with_tax("VAT10", Decimal::from(500));
1386
1387 invoice.add_item(item);
1388 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
1389
1390 invoice
1391 }
1392
1393 #[test]
1394 fn test_customer_invoice_with_tax_produces_three_lines() {
1395 let mut generator = DocumentFlowJeGenerator::new();
1396 let invoice = create_test_customer_invoice_with_tax();
1397
1398 assert_eq!(invoice.total_net_amount, Decimal::from(1000));
1399 assert_eq!(invoice.total_tax_amount, Decimal::from(100));
1400 assert_eq!(invoice.total_gross_amount, Decimal::from(1100));
1401
1402 let je = generator.generate_from_customer_invoice(&invoice).unwrap();
1403
1404 assert_eq!(
1406 je.line_count(),
1407 3,
1408 "Expected 3 JE lines for invoice with tax"
1409 );
1410 assert!(je.is_balanced(), "Entry must be balanced");
1411
1412 assert_eq!(je.lines[0].gl_account, control_accounts::AR_CONTROL);
1414 assert_eq!(je.lines[0].debit_amount, Decimal::from(1100));
1415 assert_eq!(je.lines[0].credit_amount, Decimal::ZERO);
1416
1417 assert_eq!(je.lines[1].gl_account, revenue_accounts::PRODUCT_REVENUE);
1419 assert_eq!(je.lines[1].credit_amount, Decimal::from(1000));
1420 assert_eq!(je.lines[1].debit_amount, Decimal::ZERO);
1421
1422 assert_eq!(je.lines[2].gl_account, tax_accounts::VAT_PAYABLE);
1424 assert_eq!(je.lines[2].credit_amount, Decimal::from(100));
1425 assert_eq!(je.lines[2].debit_amount, Decimal::ZERO);
1426 }
1427
1428 #[test]
1429 fn test_customer_invoice_no_tax_produces_two_lines() {
1430 let mut generator = DocumentFlowJeGenerator::new();
1431 let invoice = create_test_customer_invoice_no_tax();
1432
1433 assert_eq!(invoice.total_tax_amount, Decimal::ZERO);
1434 assert_eq!(invoice.total_net_amount, Decimal::from(1000));
1435 assert_eq!(invoice.total_gross_amount, Decimal::from(1000));
1436
1437 let je = generator.generate_from_customer_invoice(&invoice).unwrap();
1438
1439 assert_eq!(
1441 je.line_count(),
1442 2,
1443 "Expected 2 JE lines for invoice without tax"
1444 );
1445 assert!(je.is_balanced(), "Entry must be balanced");
1446
1447 assert_eq!(je.lines[0].gl_account, control_accounts::AR_CONTROL);
1449 assert_eq!(je.lines[0].debit_amount, Decimal::from(1000));
1450
1451 assert_eq!(je.lines[1].gl_account, revenue_accounts::PRODUCT_REVENUE);
1453 assert_eq!(je.lines[1].credit_amount, Decimal::from(1000));
1454 }
1455
1456 #[test]
1457 fn test_vendor_invoice_with_tax_produces_three_lines() {
1458 let mut generator = DocumentFlowJeGenerator::new();
1459 let invoice = create_test_vendor_invoice_with_tax();
1460
1461 assert_eq!(invoice.net_amount, Decimal::from(5000));
1462 assert_eq!(invoice.tax_amount, Decimal::from(500));
1463 assert_eq!(invoice.gross_amount, Decimal::from(5500));
1464 assert_eq!(invoice.payable_amount, Decimal::from(5500));
1465
1466 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1467
1468 assert_eq!(
1470 je.line_count(),
1471 3,
1472 "Expected 3 JE lines for vendor invoice with tax"
1473 );
1474 assert!(je.is_balanced(), "Entry must be balanced");
1475
1476 assert_eq!(je.lines[0].gl_account, control_accounts::GR_IR_CLEARING);
1478 assert_eq!(je.lines[0].debit_amount, Decimal::from(5000));
1479 assert_eq!(je.lines[0].credit_amount, Decimal::ZERO);
1480
1481 assert_eq!(je.lines[1].gl_account, tax_accounts::INPUT_VAT);
1483 assert_eq!(je.lines[1].debit_amount, Decimal::from(500));
1484 assert_eq!(je.lines[1].credit_amount, Decimal::ZERO);
1485
1486 assert_eq!(je.lines[2].gl_account, control_accounts::AP_CONTROL);
1488 assert_eq!(je.lines[2].credit_amount, Decimal::from(5500));
1489 assert_eq!(je.lines[2].debit_amount, Decimal::ZERO);
1490 }
1491
1492 #[test]
1493 fn test_vendor_invoice_no_tax_produces_two_lines() {
1494 let mut generator = DocumentFlowJeGenerator::new();
1496 let invoice = create_test_vendor_invoice();
1497
1498 assert_eq!(invoice.tax_amount, Decimal::ZERO);
1499
1500 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1501
1502 assert_eq!(
1504 je.line_count(),
1505 2,
1506 "Expected 2 JE lines for vendor invoice without tax"
1507 );
1508 assert!(je.is_balanced(), "Entry must be balanced");
1509
1510 assert_eq!(je.lines[0].gl_account, control_accounts::GR_IR_CLEARING);
1512 assert_eq!(je.lines[0].debit_amount, invoice.payable_amount);
1513
1514 assert_eq!(je.lines[1].gl_account, control_accounts::AP_CONTROL);
1516 assert_eq!(je.lines[1].credit_amount, invoice.payable_amount);
1517 }
1518
1519 #[test]
1520 fn test_vat_accounts_configurable() {
1521 let config = DocumentFlowJeConfig {
1523 vat_output_account: "2999".to_string(),
1524 vat_input_account: "1999".to_string(),
1525 ..Default::default()
1526 };
1527
1528 let mut generator = DocumentFlowJeGenerator::with_config_and_seed(config, 42);
1529
1530 let ci = create_test_customer_invoice_with_tax();
1532 let je = generator.generate_from_customer_invoice(&ci).unwrap();
1533 assert_eq!(
1534 je.lines[2].gl_account, "2999",
1535 "VAT output account should be configurable"
1536 );
1537
1538 let vi = create_test_vendor_invoice_with_tax();
1540 let je = generator.generate_from_vendor_invoice(&vi).unwrap();
1541 assert_eq!(
1542 je.lines[1].gl_account, "1999",
1543 "VAT input account should be configurable"
1544 );
1545 }
1546
1547 #[test]
1548 fn test_vat_entries_from_framework_accounts() {
1549 let fa = datasynth_core::FrameworkAccounts::us_gaap();
1551 let config = DocumentFlowJeConfig::from(&fa);
1552
1553 assert_eq!(config.vat_output_account, tax_accounts::VAT_PAYABLE);
1554 assert_eq!(config.vat_input_account, tax_accounts::INPUT_VAT);
1555
1556 let fa_fr = datasynth_core::FrameworkAccounts::french_gaap();
1557 let config_fr = DocumentFlowJeConfig::from(&fa_fr);
1558
1559 assert_eq!(config_fr.vat_output_account, "445710");
1560 assert_eq!(config_fr.vat_input_account, "445660");
1561 }
1562
1563 #[test]
1564 fn test_french_gaap_vat_accounts() {
1565 let config = DocumentFlowJeConfig::french_gaap();
1566 assert_eq!(config.vat_output_account, "445710"); assert_eq!(config.vat_input_account, "445660"); }
1569
1570 #[test]
1571 fn test_vat_balanced_with_multiple_items() {
1572 use datasynth_core::models::documents::CustomerInvoiceItem;
1574
1575 let mut invoice = CustomerInvoice::new(
1576 "CI-003",
1577 "1000",
1578 "C-003",
1579 2024,
1580 1,
1581 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1582 NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
1583 "JSMITH",
1584 );
1585
1586 let mut item1 = CustomerInvoiceItem::new(1, "A", Decimal::from(5), Decimal::from(100));
1588 item1.base.tax_amount = Decimal::from(50);
1589 invoice.add_item(item1);
1590
1591 let mut item2 = CustomerInvoiceItem::new(2, "B", Decimal::from(3), Decimal::from(100));
1593 item2.base.tax_amount = Decimal::from(30);
1594 invoice.add_item(item2);
1595
1596 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1597
1598 assert_eq!(invoice.total_net_amount, Decimal::from(800));
1600 assert_eq!(invoice.total_tax_amount, Decimal::from(80));
1601 assert_eq!(invoice.total_gross_amount, Decimal::from(880));
1602
1603 let mut generator = DocumentFlowJeGenerator::new();
1604 let je = generator.generate_from_customer_invoice(&invoice).unwrap();
1605
1606 assert_eq!(je.line_count(), 3);
1607 assert!(je.is_balanced());
1608 assert_eq!(je.total_debit(), Decimal::from(880));
1609 assert_eq!(je.total_credit(), Decimal::from(880));
1610 }
1611
1612 #[test]
1613 fn test_document_types_per_source_document() {
1614 let mut generator = DocumentFlowJeGenerator::new();
1615
1616 let gr = create_test_gr();
1617 let invoice = create_test_vendor_invoice();
1618 let payment = create_test_payment();
1619
1620 let gr_je = generator.generate_from_goods_receipt(&gr).unwrap();
1621 assert_eq!(
1622 gr_je.header.document_type, "WE",
1623 "Goods receipt should be WE"
1624 );
1625
1626 let vi_je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1627 assert_eq!(
1628 vi_je.header.document_type, "KR",
1629 "Vendor invoice should be KR"
1630 );
1631
1632 let pay_je = generator.generate_from_ap_payment(&payment).unwrap();
1633 assert_eq!(pay_je.header.document_type, "KZ", "AP payment should be KZ");
1634
1635 let types: std::collections::HashSet<&str> = [
1637 gr_je.header.document_type.as_str(),
1638 vi_je.header.document_type.as_str(),
1639 pay_je.header.document_type.as_str(),
1640 ]
1641 .into_iter()
1642 .collect();
1643
1644 assert!(
1645 types.len() >= 3,
1646 "Expected at least 3 distinct document types from P2P flow, got {:?}",
1647 types,
1648 );
1649 }
1650
1651 #[test]
1652 fn test_enrichment_account_descriptions_populated() {
1653 let mut generator = DocumentFlowJeGenerator::new();
1654 let gr = create_test_gr();
1655 let invoice = create_test_vendor_invoice();
1656 let payment = create_test_payment();
1657
1658 let gr_je = generator.generate_from_goods_receipt(&gr).unwrap();
1659 let vi_je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1660 let pay_je = generator.generate_from_ap_payment(&payment).unwrap();
1661
1662 for je in [&gr_je, &vi_je, &pay_je] {
1664 for line in &je.lines {
1665 assert!(
1666 line.account_description.is_some(),
1667 "Line for account {} should have description, entry doc {}",
1668 line.gl_account,
1669 je.header.document_id,
1670 );
1671 }
1672 }
1673
1674 assert_eq!(
1676 gr_je.lines[0].account_description.as_deref(),
1677 Some("Inventory"),
1678 );
1679 assert_eq!(
1680 gr_je.lines[1].account_description.as_deref(),
1681 Some("GR/IR Clearing"),
1682 );
1683 }
1684
1685 #[test]
1686 fn test_enrichment_profit_center_and_line_text() {
1687 let mut generator = DocumentFlowJeGenerator::new();
1688 let gr = create_test_gr();
1689
1690 let je = generator.generate_from_goods_receipt(&gr).unwrap();
1691
1692 for line in &je.lines {
1693 assert!(
1695 line.profit_center.is_some(),
1696 "Line {} should have profit_center",
1697 line.gl_account,
1698 );
1699 let pc = line.profit_center.as_ref().unwrap();
1700 assert!(
1701 pc.starts_with("PC-"),
1702 "Profit center should start with PC-, got {}",
1703 pc,
1704 );
1705
1706 assert!(
1708 line.line_text.is_some(),
1709 "Line {} should have line_text",
1710 line.gl_account,
1711 );
1712 }
1713 }
1714
1715 #[test]
1716 fn test_enrichment_cost_center_for_expense_accounts() {
1717 let mut generator = DocumentFlowJeGenerator::new();
1718
1719 use datasynth_core::models::documents::{Delivery, DeliveryItem};
1721 let mut delivery = Delivery::new(
1722 "DEL-001".to_string(),
1723 "1000",
1724 "SO-001",
1725 "C-001",
1726 2024,
1727 1,
1728 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1729 "JSMITH",
1730 );
1731 let item = DeliveryItem::from_sales_order(
1732 10,
1733 "Test Material",
1734 Decimal::from(100),
1735 Decimal::from(50),
1736 "SO-001",
1737 10,
1738 );
1739 delivery.add_item(item);
1740 delivery.post_goods_issue("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1741
1742 let je = generator.generate_from_delivery(&delivery).unwrap();
1743
1744 let cogs_line = je.lines.iter().find(|l| l.gl_account == "5000").unwrap();
1746 assert!(
1747 cogs_line.cost_center.is_some(),
1748 "COGS line should have cost_center assigned",
1749 );
1750 let cc = cogs_line.cost_center.as_ref().unwrap();
1751 assert!(
1752 cc.starts_with("CC"),
1753 "Cost center should start with CC, got {}",
1754 cc,
1755 );
1756
1757 let inv_line = je.lines.iter().find(|l| l.gl_account == "1200").unwrap();
1759 assert!(
1760 inv_line.cost_center.is_none(),
1761 "Non-expense line should not have cost_center",
1762 );
1763 }
1764
1765 #[test]
1766 fn test_enrichment_value_date_for_ap_ar() {
1767 let mut generator = DocumentFlowJeGenerator::new();
1768
1769 let invoice = create_test_vendor_invoice();
1770 let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1771
1772 let ap_line = je.lines.iter().find(|l| l.gl_account == "2000").unwrap();
1774 assert!(
1775 ap_line.value_date.is_some(),
1776 "AP line should have value_date set",
1777 );
1778 assert_eq!(ap_line.value_date, Some(je.header.posting_date));
1779
1780 let clearing_line = je.lines.iter().find(|l| l.gl_account == "2900").unwrap();
1782 assert!(
1783 clearing_line.value_date.is_none(),
1784 "Non-AP/AR line should not have value_date",
1785 );
1786 }
1787}