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