1use chrono::NaiveDate;
8use datasynth_core::models::{
9 documents::DocumentReference, CustomerPool, MaterialPool, VendorPool,
10};
11use datasynth_core::utils::seeded_rng;
12use rand_chacha::ChaCha8Rng;
13
14use super::{
15 O2CDocumentChain, O2CGenerator, O2CGeneratorConfig, P2PDocumentChain, P2PGenerator,
16 P2PGeneratorConfig,
17};
18
19#[derive(Debug, Clone)]
21pub struct DocumentChainManagerConfig {
22 pub p2p_config: P2PGeneratorConfig,
24 pub o2c_config: O2CGeneratorConfig,
26 pub p2p_to_o2c_ratio: f64,
28}
29
30impl Default for DocumentChainManagerConfig {
31 fn default() -> Self {
32 Self {
33 p2p_config: P2PGeneratorConfig::default(),
34 o2c_config: O2CGeneratorConfig::default(),
35 p2p_to_o2c_ratio: 1.0,
36 }
37 }
38}
39
40#[derive(Debug, Default)]
42pub struct DocumentChainStats {
43 pub p2p_chains: usize,
45 pub p2p_three_way_match_passed: usize,
47 pub p2p_completed: usize,
49 pub o2c_chains: usize,
51 pub o2c_credit_check_passed: usize,
53 pub o2c_completed: usize,
55 pub purchase_orders: usize,
57 pub goods_receipts: usize,
59 pub vendor_invoices: usize,
61 pub ap_payments: usize,
63 pub sales_orders: usize,
65 pub deliveries: usize,
67 pub customer_invoices: usize,
69 pub ar_receipts: usize,
71}
72
73#[derive(Debug)]
75pub struct GeneratedDocumentFlows {
76 pub p2p_chains: Vec<P2PDocumentChain>,
78 pub o2c_chains: Vec<O2CDocumentChain>,
80 pub document_references: Vec<DocumentReference>,
82 pub stats: DocumentChainStats,
84}
85
86pub struct DocumentChainManager {
88 rng: ChaCha8Rng,
89 seed: u64,
90 config: DocumentChainManagerConfig,
91 p2p_generator: P2PGenerator,
92 o2c_generator: O2CGenerator,
93}
94
95impl DocumentChainManager {
96 pub fn new(seed: u64) -> Self {
98 Self::with_config(seed, DocumentChainManagerConfig::default())
99 }
100
101 pub fn with_config(seed: u64, config: DocumentChainManagerConfig) -> Self {
103 Self {
104 rng: seeded_rng(seed, 0),
105 seed,
106 p2p_generator: P2PGenerator::with_config(seed, config.p2p_config.clone()),
107 o2c_generator: O2CGenerator::with_config(seed + 1000, config.o2c_config.clone()),
108 config,
109 }
110 }
111
112 pub fn generate_flows(
114 &mut self,
115 company_code: &str,
116 total_chains: usize,
117 vendors: &VendorPool,
118 customers: &CustomerPool,
119 materials: &MaterialPool,
120 date_range: (NaiveDate, NaiveDate),
121 fiscal_year: u16,
122 created_by: &str,
123 ) -> GeneratedDocumentFlows {
124 let ratio = self.config.p2p_to_o2c_ratio;
126 let p2p_count = ((total_chains as f64) * ratio / (1.0 + ratio)) as usize;
127 let o2c_count = total_chains - p2p_count;
128
129 let p2p_chains = self.p2p_generator.generate_chains(
131 p2p_count,
132 company_code,
133 vendors,
134 materials,
135 date_range,
136 fiscal_year,
137 created_by,
138 );
139
140 let o2c_chains = self.o2c_generator.generate_chains(
142 o2c_count,
143 company_code,
144 customers,
145 materials,
146 date_range,
147 fiscal_year,
148 created_by,
149 );
150
151 let document_references = self.collect_document_references(&p2p_chains, &o2c_chains);
153
154 let stats = self.calculate_stats(&p2p_chains, &o2c_chains);
156
157 GeneratedDocumentFlows {
158 p2p_chains,
159 o2c_chains,
160 document_references,
161 stats,
162 }
163 }
164
165 pub fn generate_balanced_flows(
167 &mut self,
168 chains_per_type: usize,
169 company_code: &str,
170 vendors: &VendorPool,
171 customers: &CustomerPool,
172 materials: &MaterialPool,
173 date_range: (NaiveDate, NaiveDate),
174 fiscal_year: u16,
175 created_by: &str,
176 ) -> GeneratedDocumentFlows {
177 let p2p_chains = self.p2p_generator.generate_chains(
179 chains_per_type,
180 company_code,
181 vendors,
182 materials,
183 date_range,
184 fiscal_year,
185 created_by,
186 );
187
188 let o2c_chains = self.o2c_generator.generate_chains(
190 chains_per_type,
191 company_code,
192 customers,
193 materials,
194 date_range,
195 fiscal_year,
196 created_by,
197 );
198
199 let document_references = self.collect_document_references(&p2p_chains, &o2c_chains);
200 let stats = self.calculate_stats(&p2p_chains, &o2c_chains);
201
202 GeneratedDocumentFlows {
203 p2p_chains,
204 o2c_chains,
205 document_references,
206 stats,
207 }
208 }
209
210 pub fn generate_multi_company_flows(
212 &mut self,
213 company_codes: &[String],
214 chains_per_company: usize,
215 vendors_by_company: &std::collections::HashMap<String, VendorPool>,
216 customers_by_company: &std::collections::HashMap<String, CustomerPool>,
217 materials: &MaterialPool, date_range: (NaiveDate, NaiveDate),
219 fiscal_year: u16,
220 created_by: &str,
221 ) -> Vec<GeneratedDocumentFlows> {
222 let mut results = Vec::new();
223
224 for company_code in company_codes {
225 let Some(vendors) = vendors_by_company.get(company_code) else {
226 tracing::warn!(
227 "Vendor pool not found for company '{}'; skipping",
228 company_code
229 );
230 continue;
231 };
232 let Some(customers) = customers_by_company.get(company_code) else {
233 tracing::warn!(
234 "Customer pool not found for company '{}'; skipping",
235 company_code
236 );
237 continue;
238 };
239
240 let flows = self.generate_flows(
241 company_code,
242 chains_per_company,
243 vendors,
244 customers,
245 materials,
246 date_range,
247 fiscal_year,
248 created_by,
249 );
250
251 results.push(flows);
252 }
253
254 results
255 }
256
257 fn collect_document_references(
259 &self,
260 p2p_chains: &[P2PDocumentChain],
261 o2c_chains: &[O2CDocumentChain],
262 ) -> Vec<DocumentReference> {
263 let mut references = Vec::new();
264
265 for chain in p2p_chains {
267 for ref_doc in &chain.purchase_order.header.document_references {
269 references.push(ref_doc.clone());
270 }
271
272 for gr in &chain.goods_receipts {
274 for ref_doc in &gr.header.document_references {
275 references.push(ref_doc.clone());
276 }
277 }
278
279 if let Some(invoice) = &chain.vendor_invoice {
281 for ref_doc in &invoice.header.document_references {
282 references.push(ref_doc.clone());
283 }
284 }
285
286 if let Some(payment) = &chain.payment {
288 for ref_doc in &payment.header.document_references {
289 references.push(ref_doc.clone());
290 }
291 }
292
293 for payment in &chain.remainder_payments {
295 for ref_doc in &payment.header.document_references {
296 references.push(ref_doc.clone());
297 }
298 }
299 }
300
301 for chain in o2c_chains {
303 for ref_doc in &chain.sales_order.header.document_references {
305 references.push(ref_doc.clone());
306 }
307
308 for dlv in &chain.deliveries {
310 for ref_doc in &dlv.header.document_references {
311 references.push(ref_doc.clone());
312 }
313 }
314
315 if let Some(invoice) = &chain.customer_invoice {
317 for ref_doc in &invoice.header.document_references {
318 references.push(ref_doc.clone());
319 }
320 }
321
322 if let Some(receipt) = &chain.customer_receipt {
324 for ref_doc in &receipt.header.document_references {
325 references.push(ref_doc.clone());
326 }
327 }
328
329 for receipt in &chain.remainder_receipts {
331 for ref_doc in &receipt.header.document_references {
332 references.push(ref_doc.clone());
333 }
334 }
335 }
336
337 references
338 }
339
340 fn calculate_stats(
342 &self,
343 p2p_chains: &[P2PDocumentChain],
344 o2c_chains: &[O2CDocumentChain],
345 ) -> DocumentChainStats {
346 let mut stats = DocumentChainStats {
347 p2p_chains: p2p_chains.len(),
348 ..Default::default()
349 };
350
351 for chain in p2p_chains {
353 stats.purchase_orders += 1;
354 stats.goods_receipts += chain.goods_receipts.len();
355
356 if chain.three_way_match_passed {
357 stats.p2p_three_way_match_passed += 1;
358 }
359
360 if chain.vendor_invoice.is_some() {
361 stats.vendor_invoices += 1;
362 }
363
364 if chain.payment.is_some() {
365 stats.ap_payments += 1;
366 }
367
368 stats.ap_payments += chain.remainder_payments.len();
369
370 if chain.is_complete {
371 stats.p2p_completed += 1;
372 }
373 }
374
375 stats.o2c_chains = o2c_chains.len();
377 for chain in o2c_chains {
378 stats.sales_orders += 1;
379 stats.deliveries += chain.deliveries.len();
380
381 if chain.credit_check_passed {
382 stats.o2c_credit_check_passed += 1;
383 }
384
385 if chain.customer_invoice.is_some() {
386 stats.customer_invoices += 1;
387 }
388
389 if chain.customer_receipt.is_some() {
390 stats.ar_receipts += 1;
391 }
392
393 stats.ar_receipts += chain.remainder_receipts.len();
394
395 if chain.is_complete {
396 stats.o2c_completed += 1;
397 }
398 }
399
400 stats
401 }
402
403 pub fn p2p_generator(&mut self) -> &mut P2PGenerator {
405 &mut self.p2p_generator
406 }
407
408 pub fn o2c_generator(&mut self) -> &mut O2CGenerator {
410 &mut self.o2c_generator
411 }
412
413 pub fn reset(&mut self) {
415 self.rng = seeded_rng(self.seed, 0);
416 self.p2p_generator.reset();
417 self.o2c_generator.reset();
418 }
419}
420
421pub fn extract_je_sources(flows: &GeneratedDocumentFlows) -> JournalEntrySources {
423 let mut sources = JournalEntrySources::default();
424
425 for chain in &flows.p2p_chains {
426 for gr in &chain.goods_receipts {
428 sources.goods_receipts.push(gr.clone());
429 }
430
431 if let Some(invoice) = &chain.vendor_invoice {
433 sources.vendor_invoices.push(invoice.clone());
434 }
435
436 if let Some(payment) = &chain.payment {
438 sources.ap_payments.push(payment.clone());
439 }
440
441 for payment in &chain.remainder_payments {
443 sources.ap_payments.push(payment.clone());
444 }
445 }
446
447 for chain in &flows.o2c_chains {
448 for dlv in &chain.deliveries {
450 sources.deliveries.push(dlv.clone());
451 }
452
453 if let Some(invoice) = &chain.customer_invoice {
455 sources.customer_invoices.push(invoice.clone());
456 }
457
458 if let Some(receipt) = &chain.customer_receipt {
460 sources.ar_receipts.push(receipt.clone());
461 }
462
463 for receipt in &chain.remainder_receipts {
465 sources.ar_receipts.push(receipt.clone());
466 }
467 }
468
469 sources
470}
471
472#[derive(Debug, Default)]
474pub struct JournalEntrySources {
475 pub goods_receipts: Vec<datasynth_core::models::documents::GoodsReceipt>,
477 pub vendor_invoices: Vec<datasynth_core::models::documents::VendorInvoice>,
479 pub ap_payments: Vec<datasynth_core::models::documents::Payment>,
481 pub deliveries: Vec<datasynth_core::models::documents::Delivery>,
483 pub customer_invoices: Vec<datasynth_core::models::documents::CustomerInvoice>,
485 pub ar_receipts: Vec<datasynth_core::models::documents::Payment>,
487}
488
489#[cfg(test)]
490#[allow(clippy::unwrap_used)]
491mod tests {
492 use super::*;
493 use datasynth_core::models::{
494 CreditRating, Customer, CustomerPaymentBehavior, Material, MaterialType, Vendor,
495 };
496
497 fn create_test_pools() -> (VendorPool, CustomerPool, MaterialPool) {
498 let mut vendors = VendorPool::new();
499 for i in 1..=5 {
500 vendors.add_vendor(Vendor::new(
501 &format!("V-{:06}", i),
502 &format!("Vendor {}", i),
503 datasynth_core::models::VendorType::Supplier,
504 ));
505 }
506
507 let mut customers = CustomerPool::new();
508 for i in 1..=5 {
509 let mut customer = Customer::new(
510 &format!("C-{:06}", i),
511 &format!("Customer {}", i),
512 datasynth_core::models::CustomerType::Corporate,
513 );
514 customer.credit_rating = CreditRating::A;
515 customer.credit_limit = rust_decimal::Decimal::from(1_000_000);
516 customer.payment_behavior = CustomerPaymentBehavior::OnTime;
517 customers.add_customer(customer);
518 }
519
520 let mut materials = MaterialPool::new();
521 for i in 1..=10 {
522 let mut mat = Material::new(
523 format!("MAT-{:06}", i),
524 format!("Material {}", i),
525 MaterialType::FinishedGood,
526 );
527 mat.standard_cost = rust_decimal::Decimal::from(50 + i * 10);
528 mat.list_price = rust_decimal::Decimal::from(100 + i * 20);
529 materials.add_material(mat);
530 }
531
532 (vendors, customers, materials)
533 }
534
535 #[test]
536 fn test_manager_creation() {
537 let manager = DocumentChainManager::new(42);
538 assert!(manager.config.p2p_to_o2c_ratio == 1.0);
539 }
540
541 #[test]
542 fn test_generate_flows() {
543 let mut manager = DocumentChainManager::new(42);
544 let (vendors, customers, materials) = create_test_pools();
545
546 let flows = manager.generate_flows(
547 "1000",
548 20,
549 &vendors,
550 &customers,
551 &materials,
552 (
553 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
554 NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
555 ),
556 2024,
557 "JSMITH",
558 );
559
560 assert_eq!(flows.p2p_chains.len() + flows.o2c_chains.len(), 20);
561 assert!(flows.stats.purchase_orders > 0);
562 assert!(flows.stats.sales_orders > 0);
563 }
564
565 #[test]
566 fn test_balanced_flows() {
567 let mut manager = DocumentChainManager::new(42);
568 let (vendors, customers, materials) = create_test_pools();
569
570 let flows = manager.generate_balanced_flows(
571 10,
572 "1000",
573 &vendors,
574 &customers,
575 &materials,
576 (
577 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
578 NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
579 ),
580 2024,
581 "JSMITH",
582 );
583
584 assert_eq!(flows.p2p_chains.len(), 10);
585 assert_eq!(flows.o2c_chains.len(), 10);
586 }
587
588 #[test]
589 fn test_document_references_collected() {
590 let mut manager = DocumentChainManager::new(42);
591 let (vendors, customers, materials) = create_test_pools();
592
593 let flows = manager.generate_balanced_flows(
594 5,
595 "1000",
596 &vendors,
597 &customers,
598 &materials,
599 (
600 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
601 NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
602 ),
603 2024,
604 "JSMITH",
605 );
606
607 assert!(!flows.document_references.is_empty());
609 }
610
611 #[test]
612 fn test_stats_calculation() {
613 let mut manager = DocumentChainManager::new(42);
614 let (vendors, customers, materials) = create_test_pools();
615
616 let flows = manager.generate_balanced_flows(
617 5,
618 "1000",
619 &vendors,
620 &customers,
621 &materials,
622 (
623 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
624 NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
625 ),
626 2024,
627 "JSMITH",
628 );
629
630 let stats = &flows.stats;
631 assert_eq!(stats.p2p_chains, 5);
632 assert_eq!(stats.o2c_chains, 5);
633 assert_eq!(stats.purchase_orders, 5);
634 assert_eq!(stats.sales_orders, 5);
635 }
636
637 #[test]
638 fn test_je_sources_extraction() {
639 let mut manager = DocumentChainManager::new(42);
640 let (vendors, customers, materials) = create_test_pools();
641
642 let flows = manager.generate_balanced_flows(
643 5,
644 "1000",
645 &vendors,
646 &customers,
647 &materials,
648 (
649 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
650 NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
651 ),
652 2024,
653 "JSMITH",
654 );
655
656 let sources = extract_je_sources(&flows);
657
658 assert!(!sources.goods_receipts.is_empty());
660 assert!(!sources.vendor_invoices.is_empty());
661 assert!(!sources.deliveries.is_empty());
662 assert!(!sources.customer_invoices.is_empty());
663 }
664
665 #[test]
666 fn test_custom_ratio() {
667 let config = DocumentChainManagerConfig {
668 p2p_to_o2c_ratio: 2.0, ..Default::default()
670 };
671
672 let mut manager = DocumentChainManager::with_config(42, config);
673 let (vendors, customers, materials) = create_test_pools();
674
675 let flows = manager.generate_flows(
676 "1000",
677 30,
678 &vendors,
679 &customers,
680 &materials,
681 (
682 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
683 NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
684 ),
685 2024,
686 "JSMITH",
687 );
688
689 assert!(flows.p2p_chains.len() > flows.o2c_chains.len());
691 }
692
693 #[test]
694 fn test_reset() {
695 let mut manager = DocumentChainManager::new(42);
696 let (vendors, customers, materials) = create_test_pools();
697
698 let flows1 = manager.generate_balanced_flows(
699 5,
700 "1000",
701 &vendors,
702 &customers,
703 &materials,
704 (
705 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
706 NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
707 ),
708 2024,
709 "JSMITH",
710 );
711
712 manager.reset();
713
714 let flows2 = manager.generate_balanced_flows(
715 5,
716 "1000",
717 &vendors,
718 &customers,
719 &materials,
720 (
721 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
722 NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
723 ),
724 2024,
725 "JSMITH",
726 );
727
728 assert_eq!(
730 flows1.p2p_chains[0].purchase_order.header.document_id,
731 flows2.p2p_chains[0].purchase_order.header.document_id
732 );
733 }
734}