Skip to main content

datasynth_generators/document_flow/
document_chain_manager.rs

1//! Document Chain Manager for coordinated document flow generation.
2//!
3//! This module provides a central manager that coordinates the generation
4//! of both P2P and O2C document flows, maintaining document references
5//! and ensuring coherent data generation.
6
7use 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/// Configuration for document chain manager.
20#[derive(Debug, Clone)]
21pub struct DocumentChainManagerConfig {
22    /// P2P flow configuration
23    pub p2p_config: P2PGeneratorConfig,
24    /// O2C flow configuration
25    pub o2c_config: O2CGeneratorConfig,
26    /// Ratio of P2P to O2C transactions (1.0 = equal, 2.0 = 2x P2P)
27    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/// Summary statistics for generated document chains.
41#[derive(Debug, Default)]
42pub struct DocumentChainStats {
43    /// Total P2P chains generated
44    pub p2p_chains: usize,
45    /// P2P chains with three-way match passed
46    pub p2p_three_way_match_passed: usize,
47    /// P2P chains fully completed (payment made)
48    pub p2p_completed: usize,
49    /// Total O2C chains generated
50    pub o2c_chains: usize,
51    /// O2C chains with credit check passed
52    pub o2c_credit_check_passed: usize,
53    /// O2C chains fully completed (payment received)
54    pub o2c_completed: usize,
55    /// Total purchase orders
56    pub purchase_orders: usize,
57    /// Total goods receipts
58    pub goods_receipts: usize,
59    /// Total vendor invoices
60    pub vendor_invoices: usize,
61    /// Total AP payments
62    pub ap_payments: usize,
63    /// Total sales orders
64    pub sales_orders: usize,
65    /// Total deliveries
66    pub deliveries: usize,
67    /// Total customer invoices
68    pub customer_invoices: usize,
69    /// Total AR receipts
70    pub ar_receipts: usize,
71}
72
73/// Generated document flows result.
74#[derive(Debug)]
75pub struct GeneratedDocumentFlows {
76    /// P2P chains
77    pub p2p_chains: Vec<P2PDocumentChain>,
78    /// O2C chains
79    pub o2c_chains: Vec<O2CDocumentChain>,
80    /// All document references
81    pub document_references: Vec<DocumentReference>,
82    /// Statistics
83    pub stats: DocumentChainStats,
84}
85
86/// Document Chain Manager for coordinated P2P and O2C generation.
87pub struct DocumentChainManager {
88    rng: ChaCha8Rng,
89    seed: u64,
90    config: DocumentChainManagerConfig,
91    p2p_generator: P2PGenerator,
92    o2c_generator: O2CGenerator,
93}
94
95impl DocumentChainManager {
96    /// Create a new document chain manager.
97    pub fn new(seed: u64) -> Self {
98        Self::with_config(seed, DocumentChainManagerConfig::default())
99    }
100
101    /// Create a new document chain manager with custom configuration.
102    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    /// Generate document flows for a company.
113    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        // Calculate P2P and O2C counts based on ratio
125        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        // Generate P2P chains
130        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        // Generate O2C chains
141        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        // Collect all document references
152        let document_references = self.collect_document_references(&p2p_chains, &o2c_chains);
153
154        // Calculate statistics
155        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    /// Generate balanced document flows (equal P2P and O2C).
166    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        // Generate P2P chains
178        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        // Generate O2C chains
189        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    /// Generate flows for multiple company codes.
211    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, // Shared materials
218        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    /// Collect all document references from chains.
258    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        // Collect P2P references
266        for chain in p2p_chains {
267            // PO references
268            for ref_doc in &chain.purchase_order.header.document_references {
269                references.push(ref_doc.clone());
270            }
271
272            // GR references
273            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            // Invoice references
280            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            // Payment references
287            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            // Remainder payment references
294            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        // Collect O2C references
302        for chain in o2c_chains {
303            // SO references
304            for ref_doc in &chain.sales_order.header.document_references {
305                references.push(ref_doc.clone());
306            }
307
308            // Delivery references
309            for dlv in &chain.deliveries {
310                for ref_doc in &dlv.header.document_references {
311                    references.push(ref_doc.clone());
312                }
313            }
314
315            // Invoice references
316            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            // Receipt references
323            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            // Remainder receipt references
330            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    /// Calculate statistics from generated chains.
341    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        // P2P stats
352        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        // O2C stats
376        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    /// Get reference to P2P generator for direct access.
404    pub fn p2p_generator(&mut self) -> &mut P2PGenerator {
405        &mut self.p2p_generator
406    }
407
408    /// Get reference to O2C generator for direct access.
409    pub fn o2c_generator(&mut self) -> &mut O2CGenerator {
410        &mut self.o2c_generator
411    }
412
413    /// Reset all generators.
414    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
421/// Helper to extract all journal entry-generating documents from flows.
422pub fn extract_je_sources(flows: &GeneratedDocumentFlows) -> JournalEntrySources {
423    let mut sources = JournalEntrySources::default();
424
425    for chain in &flows.p2p_chains {
426        // GR creates JE: DR Inventory, CR GR/IR
427        for gr in &chain.goods_receipts {
428            sources.goods_receipts.push(gr.clone());
429        }
430
431        // Invoice creates JE: DR Expense/GR-IR, CR AP
432        if let Some(invoice) = &chain.vendor_invoice {
433            sources.vendor_invoices.push(invoice.clone());
434        }
435
436        // Payment creates JE: DR AP, CR Bank
437        if let Some(payment) = &chain.payment {
438            sources.ap_payments.push(payment.clone());
439        }
440
441        // Remainder payments also create JEs: DR AP, CR Bank
442        for payment in &chain.remainder_payments {
443            sources.ap_payments.push(payment.clone());
444        }
445    }
446
447    for chain in &flows.o2c_chains {
448        // Delivery/GI creates JE: DR COGS, CR Inventory
449        for dlv in &chain.deliveries {
450            sources.deliveries.push(dlv.clone());
451        }
452
453        // Invoice creates JE: DR AR, CR Revenue
454        if let Some(invoice) = &chain.customer_invoice {
455            sources.customer_invoices.push(invoice.clone());
456        }
457
458        // Receipt creates JE: DR Bank, CR AR
459        if let Some(receipt) = &chain.customer_receipt {
460            sources.ar_receipts.push(receipt.clone());
461        }
462
463        // Remainder receipts also create JEs: DR Bank, CR AR
464        for receipt in &chain.remainder_receipts {
465            sources.ar_receipts.push(receipt.clone());
466        }
467    }
468
469    sources
470}
471
472/// Sources for journal entry generation.
473#[derive(Debug, Default)]
474pub struct JournalEntrySources {
475    /// Goods receipts (DR Inventory, CR GR/IR)
476    pub goods_receipts: Vec<datasynth_core::models::documents::GoodsReceipt>,
477    /// Vendor invoices (DR Expense, CR AP)
478    pub vendor_invoices: Vec<datasynth_core::models::documents::VendorInvoice>,
479    /// AP payments (DR AP, CR Bank)
480    pub ap_payments: Vec<datasynth_core::models::documents::Payment>,
481    /// Deliveries (DR COGS, CR Inventory)
482    pub deliveries: Vec<datasynth_core::models::documents::Delivery>,
483    /// Customer invoices (DR AR, CR Revenue)
484    pub customer_invoices: Vec<datasynth_core::models::documents::CustomerInvoice>,
485    /// AR receipts (DR Bank, CR AR)
486    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        // Should have document references
608        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        // Should have JE sources from both P2P and O2C
659        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, // 2x P2P
669            ..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        // With 2:1 ratio, should have ~20 P2P and ~10 O2C
690        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        // After reset, should get same results
729        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}