1use rust_decimal::Decimal;
17
18use datasynth_core::accounts::{
19 cash_accounts, control_accounts, expense_accounts, revenue_accounts,
20};
21use datasynth_core::models::{
22 documents::{CustomerInvoice, Delivery, GoodsReceipt, Payment, VendorInvoice},
23 BusinessProcess, JournalEntry, JournalEntryHeader, JournalEntryLine, TransactionSource,
24};
25use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
26
27use super::{O2CDocumentChain, P2PDocumentChain};
28
29#[derive(Debug, Clone)]
31pub struct DocumentFlowJeConfig {
32 pub inventory_account: String,
34 pub gr_ir_clearing_account: String,
36 pub ap_account: String,
38 pub cash_account: String,
40 pub ar_account: String,
42 pub revenue_account: String,
44 pub cogs_account: String,
46}
47
48impl Default for DocumentFlowJeConfig {
49 fn default() -> Self {
50 Self {
51 inventory_account: control_accounts::INVENTORY.to_string(),
52 gr_ir_clearing_account: control_accounts::GR_IR_CLEARING.to_string(),
53 ap_account: control_accounts::AP_CONTROL.to_string(),
54 cash_account: cash_accounts::OPERATING_CASH.to_string(),
55 ar_account: control_accounts::AR_CONTROL.to_string(),
56 revenue_account: revenue_accounts::PRODUCT_REVENUE.to_string(),
57 cogs_account: expense_accounts::COGS.to_string(),
58 }
59 }
60}
61
62pub struct DocumentFlowJeGenerator {
64 config: DocumentFlowJeConfig,
65 uuid_factory: DeterministicUuidFactory,
66}
67
68impl DocumentFlowJeGenerator {
69 pub fn new() -> Self {
71 Self::with_config_and_seed(DocumentFlowJeConfig::default(), 0)
72 }
73
74 pub fn with_config_and_seed(config: DocumentFlowJeConfig, seed: u64) -> Self {
76 Self {
77 config,
78 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::DocumentFlow),
79 }
80 }
81
82 pub fn generate_from_p2p_chain(&mut self, chain: &P2PDocumentChain) -> Vec<JournalEntry> {
84 let mut entries = Vec::new();
85
86 for gr in &chain.goods_receipts {
88 if let Some(je) = self.generate_from_goods_receipt(gr) {
89 entries.push(je);
90 }
91 }
92
93 if let Some(ref invoice) = chain.vendor_invoice {
95 if let Some(je) = self.generate_from_vendor_invoice(invoice) {
96 entries.push(je);
97 }
98 }
99
100 if let Some(ref payment) = chain.payment {
102 if let Some(je) = self.generate_from_ap_payment(payment) {
103 entries.push(je);
104 }
105 }
106
107 entries
108 }
109
110 pub fn generate_from_o2c_chain(&mut self, chain: &O2CDocumentChain) -> Vec<JournalEntry> {
112 let mut entries = Vec::new();
113
114 for delivery in &chain.deliveries {
116 if let Some(je) = self.generate_from_delivery(delivery) {
117 entries.push(je);
118 }
119 }
120
121 if let Some(ref invoice) = chain.customer_invoice {
123 if let Some(je) = self.generate_from_customer_invoice(invoice) {
124 entries.push(je);
125 }
126 }
127
128 if let Some(ref receipt) = chain.customer_receipt {
130 if let Some(je) = self.generate_from_ar_receipt(receipt) {
131 entries.push(je);
132 }
133 }
134
135 entries
136 }
137
138 pub fn generate_from_goods_receipt(&mut self, gr: &GoodsReceipt) -> Option<JournalEntry> {
141 if gr.items.is_empty() {
142 return None;
143 }
144
145 let document_id = self.uuid_factory.next();
146
147 let total_amount = if gr.total_value > Decimal::ZERO {
149 gr.total_value
150 } else {
151 gr.items
152 .iter()
153 .map(|item| item.base.net_amount)
154 .sum::<Decimal>()
155 };
156
157 if total_amount == Decimal::ZERO {
158 return None;
159 }
160
161 let posting_date = gr.header.posting_date.unwrap_or(gr.header.document_date);
163
164 let mut header = JournalEntryHeader::with_deterministic_id(
165 gr.header.company_code.clone(),
166 posting_date,
167 document_id,
168 );
169 header.source = TransactionSource::Automated;
170 header.business_process = Some(BusinessProcess::P2P);
171 header.reference = Some(format!("GR:{}", gr.header.document_id));
172 header.header_text = Some(format!(
173 "Goods Receipt {} - {}",
174 gr.header.document_id,
175 gr.vendor_id.as_deref().unwrap_or("Unknown")
176 ));
177
178 let mut entry = JournalEntry::new(header);
179
180 let debit_line = JournalEntryLine::debit(
182 entry.header.document_id,
183 1,
184 self.config.inventory_account.clone(),
185 total_amount,
186 );
187 entry.add_line(debit_line);
188
189 let credit_line = JournalEntryLine::credit(
191 entry.header.document_id,
192 2,
193 self.config.gr_ir_clearing_account.clone(),
194 total_amount,
195 );
196 entry.add_line(credit_line);
197
198 Some(entry)
199 }
200
201 pub fn generate_from_vendor_invoice(
204 &mut self,
205 invoice: &VendorInvoice,
206 ) -> Option<JournalEntry> {
207 if invoice.payable_amount == Decimal::ZERO {
208 return None;
209 }
210
211 let document_id = self.uuid_factory.next();
212
213 let posting_date = invoice
215 .header
216 .posting_date
217 .unwrap_or(invoice.header.document_date);
218
219 let mut header = JournalEntryHeader::with_deterministic_id(
220 invoice.header.company_code.clone(),
221 posting_date,
222 document_id,
223 );
224 header.source = TransactionSource::Automated;
225 header.business_process = Some(BusinessProcess::P2P);
226 header.reference = Some(format!("VI:{}", invoice.header.document_id));
227 header.header_text = Some(format!(
228 "Vendor Invoice {} - {}",
229 invoice.vendor_invoice_number, invoice.vendor_id
230 ));
231
232 let mut entry = JournalEntry::new(header);
233
234 let debit_line = JournalEntryLine::debit(
236 entry.header.document_id,
237 1,
238 self.config.gr_ir_clearing_account.clone(),
239 invoice.payable_amount,
240 );
241 entry.add_line(debit_line);
242
243 let credit_line = JournalEntryLine::credit(
245 entry.header.document_id,
246 2,
247 self.config.ap_account.clone(),
248 invoice.payable_amount,
249 );
250 entry.add_line(credit_line);
251
252 Some(entry)
253 }
254
255 pub fn generate_from_ap_payment(&mut self, payment: &Payment) -> Option<JournalEntry> {
258 if payment.amount == Decimal::ZERO {
259 return None;
260 }
261
262 let document_id = self.uuid_factory.next();
263
264 let posting_date = payment
266 .header
267 .posting_date
268 .unwrap_or(payment.header.document_date);
269
270 let mut header = JournalEntryHeader::with_deterministic_id(
271 payment.header.company_code.clone(),
272 posting_date,
273 document_id,
274 );
275 header.source = TransactionSource::Automated;
276 header.business_process = Some(BusinessProcess::P2P);
277 header.reference = Some(format!("PAY:{}", payment.header.document_id));
278 header.header_text = Some(format!(
279 "Payment {} - {}",
280 payment.header.document_id, payment.business_partner_id
281 ));
282
283 let mut entry = JournalEntry::new(header);
284
285 let debit_line = JournalEntryLine::debit(
287 entry.header.document_id,
288 1,
289 self.config.ap_account.clone(),
290 payment.amount,
291 );
292 entry.add_line(debit_line);
293
294 let credit_line = JournalEntryLine::credit(
296 entry.header.document_id,
297 2,
298 self.config.cash_account.clone(),
299 payment.amount,
300 );
301 entry.add_line(credit_line);
302
303 Some(entry)
304 }
305
306 pub fn generate_from_delivery(&mut self, delivery: &Delivery) -> Option<JournalEntry> {
309 if delivery.items.is_empty() {
310 return None;
311 }
312
313 let document_id = self.uuid_factory.next();
314
315 let total_cost = delivery
317 .items
318 .iter()
319 .map(|item| item.base.net_amount)
320 .sum::<Decimal>();
321
322 if total_cost == Decimal::ZERO {
323 return None;
324 }
325
326 let posting_date = delivery
328 .header
329 .posting_date
330 .unwrap_or(delivery.header.document_date);
331
332 let mut header = JournalEntryHeader::with_deterministic_id(
333 delivery.header.company_code.clone(),
334 posting_date,
335 document_id,
336 );
337 header.source = TransactionSource::Automated;
338 header.business_process = Some(BusinessProcess::O2C);
339 header.reference = Some(format!("DEL:{}", delivery.header.document_id));
340 header.header_text = Some(format!(
341 "Delivery {} - {}",
342 delivery.header.document_id, delivery.customer_id
343 ));
344
345 let mut entry = JournalEntry::new(header);
346
347 let debit_line = JournalEntryLine::debit(
349 entry.header.document_id,
350 1,
351 self.config.cogs_account.clone(),
352 total_cost,
353 );
354 entry.add_line(debit_line);
355
356 let credit_line = JournalEntryLine::credit(
358 entry.header.document_id,
359 2,
360 self.config.inventory_account.clone(),
361 total_cost,
362 );
363 entry.add_line(credit_line);
364
365 Some(entry)
366 }
367
368 pub fn generate_from_customer_invoice(
371 &mut self,
372 invoice: &CustomerInvoice,
373 ) -> Option<JournalEntry> {
374 if invoice.total_gross_amount == Decimal::ZERO {
375 return None;
376 }
377
378 let document_id = self.uuid_factory.next();
379
380 let posting_date = invoice
382 .header
383 .posting_date
384 .unwrap_or(invoice.header.document_date);
385
386 let mut header = JournalEntryHeader::with_deterministic_id(
387 invoice.header.company_code.clone(),
388 posting_date,
389 document_id,
390 );
391 header.source = TransactionSource::Automated;
392 header.business_process = Some(BusinessProcess::O2C);
393 header.reference = Some(format!("CI:{}", invoice.header.document_id));
394 header.header_text = Some(format!(
395 "Customer Invoice {} - {}",
396 invoice.header.document_id, invoice.customer_id
397 ));
398
399 let mut entry = JournalEntry::new(header);
400
401 let debit_line = JournalEntryLine::debit(
403 entry.header.document_id,
404 1,
405 self.config.ar_account.clone(),
406 invoice.total_gross_amount,
407 );
408 entry.add_line(debit_line);
409
410 let credit_line = JournalEntryLine::credit(
412 entry.header.document_id,
413 2,
414 self.config.revenue_account.clone(),
415 invoice.total_gross_amount,
416 );
417 entry.add_line(credit_line);
418
419 Some(entry)
420 }
421
422 pub fn generate_from_ar_receipt(&mut self, payment: &Payment) -> Option<JournalEntry> {
425 if payment.amount == Decimal::ZERO {
426 return None;
427 }
428
429 let document_id = self.uuid_factory.next();
430
431 let posting_date = payment
433 .header
434 .posting_date
435 .unwrap_or(payment.header.document_date);
436
437 let mut header = JournalEntryHeader::with_deterministic_id(
438 payment.header.company_code.clone(),
439 posting_date,
440 document_id,
441 );
442 header.source = TransactionSource::Automated;
443 header.business_process = Some(BusinessProcess::O2C);
444 header.reference = Some(format!("RCP:{}", payment.header.document_id));
445 header.header_text = Some(format!(
446 "Customer Receipt {} - {}",
447 payment.header.document_id, payment.business_partner_id
448 ));
449
450 let mut entry = JournalEntry::new(header);
451
452 let debit_line = JournalEntryLine::debit(
454 entry.header.document_id,
455 1,
456 self.config.cash_account.clone(),
457 payment.amount,
458 );
459 entry.add_line(debit_line);
460
461 let credit_line = JournalEntryLine::credit(
463 entry.header.document_id,
464 2,
465 self.config.ar_account.clone(),
466 payment.amount,
467 );
468 entry.add_line(credit_line);
469
470 Some(entry)
471 }
472}
473
474impl Default for DocumentFlowJeGenerator {
475 fn default() -> Self {
476 Self::new()
477 }
478}
479
480#[cfg(test)]
481#[allow(clippy::unwrap_used)]
482mod tests {
483 use super::*;
484 use chrono::NaiveDate;
485 use datasynth_core::models::documents::{GoodsReceiptItem, MovementType};
486
487 fn create_test_gr() -> GoodsReceipt {
488 let mut gr = GoodsReceipt::from_purchase_order(
489 "GR-001".to_string(),
490 "1000",
491 "PO-001",
492 "V-001",
493 "P1000",
494 "0001",
495 2024,
496 1,
497 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
498 "JSMITH",
499 );
500
501 let item = GoodsReceiptItem::from_po(
502 10,
503 "Test Material",
504 Decimal::from(100),
505 Decimal::from(50),
506 "PO-001",
507 10,
508 )
509 .with_movement_type(MovementType::GrForPo);
510
511 gr.add_item(item);
512 gr.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
513
514 gr
515 }
516
517 fn create_test_vendor_invoice() -> VendorInvoice {
518 use datasynth_core::models::documents::VendorInvoiceItem;
519
520 let mut invoice = VendorInvoice::new(
521 "VI-001".to_string(),
522 "1000",
523 "V-001",
524 "INV-12345".to_string(),
525 2024,
526 1,
527 NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
528 "JSMITH",
529 );
530
531 let item = VendorInvoiceItem::from_po_gr(
532 10,
533 "Test Material",
534 Decimal::from(100),
535 Decimal::from(50),
536 "PO-001",
537 10,
538 Some("GR-001".to_string()),
539 Some(10),
540 );
541
542 invoice.add_item(item);
543 invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
544
545 invoice
546 }
547
548 fn create_test_payment() -> Payment {
549 let mut payment = Payment::new_ap_payment(
550 "PAY-001".to_string(),
551 "1000",
552 "V-001",
553 Decimal::from(5000),
554 2024,
555 2,
556 NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
557 "JSMITH",
558 );
559
560 payment.post("JSMITH", NaiveDate::from_ymd_opt(2024, 2, 15).unwrap());
561
562 payment
563 }
564
565 #[test]
566 fn test_generate_from_goods_receipt() {
567 let mut generator = DocumentFlowJeGenerator::new();
568 let gr = create_test_gr();
569
570 let je = generator.generate_from_goods_receipt(&gr);
571
572 assert!(je.is_some());
573 let je = je.unwrap();
574
575 assert!(je.is_balanced());
577
578 assert_eq!(je.line_count(), 2);
580
581 assert!(je.total_debit() > Decimal::ZERO);
583 assert_eq!(je.total_debit(), je.total_credit());
584
585 assert!(je.header.reference.is_some());
587 assert!(je.header.reference.as_ref().unwrap().contains("GR:"));
588 }
589
590 #[test]
591 fn test_generate_from_vendor_invoice() {
592 let mut generator = DocumentFlowJeGenerator::new();
593 let invoice = create_test_vendor_invoice();
594
595 let je = generator.generate_from_vendor_invoice(&invoice);
596
597 assert!(je.is_some());
598 let je = je.unwrap();
599
600 assert!(je.is_balanced());
601 assert_eq!(je.line_count(), 2);
602 assert!(je.header.reference.as_ref().unwrap().contains("VI:"));
603 }
604
605 #[test]
606 fn test_generate_from_ap_payment() {
607 let mut generator = DocumentFlowJeGenerator::new();
608 let payment = create_test_payment();
609
610 let je = generator.generate_from_ap_payment(&payment);
611
612 assert!(je.is_some());
613 let je = je.unwrap();
614
615 assert!(je.is_balanced());
616 assert_eq!(je.line_count(), 2);
617 assert!(je.header.reference.as_ref().unwrap().contains("PAY:"));
618 }
619
620 #[test]
621 fn test_all_entries_are_balanced() {
622 let mut generator = DocumentFlowJeGenerator::new();
623
624 let gr = create_test_gr();
625 let invoice = create_test_vendor_invoice();
626 let payment = create_test_payment();
627
628 let entries = vec![
629 generator.generate_from_goods_receipt(&gr),
630 generator.generate_from_vendor_invoice(&invoice),
631 generator.generate_from_ap_payment(&payment),
632 ];
633
634 for entry in entries.into_iter().flatten() {
635 assert!(
636 entry.is_balanced(),
637 "Entry {} is not balanced",
638 entry.header.document_id
639 );
640 }
641 }
642}