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
294        // Collect O2C references
295        for chain in o2c_chains {
296            // SO references
297            for ref_doc in &chain.sales_order.header.document_references {
298                references.push(ref_doc.clone());
299            }
300
301            // Delivery references
302            for dlv in &chain.deliveries {
303                for ref_doc in &dlv.header.document_references {
304                    references.push(ref_doc.clone());
305                }
306            }
307
308            // Invoice references
309            if let Some(invoice) = &chain.customer_invoice {
310                for ref_doc in &invoice.header.document_references {
311                    references.push(ref_doc.clone());
312                }
313            }
314
315            // Receipt references
316            if let Some(receipt) = &chain.customer_receipt {
317                for ref_doc in &receipt.header.document_references {
318                    references.push(ref_doc.clone());
319                }
320            }
321        }
322
323        references
324    }
325
326    /// Calculate statistics from generated chains.
327    fn calculate_stats(
328        &self,
329        p2p_chains: &[P2PDocumentChain],
330        o2c_chains: &[O2CDocumentChain],
331    ) -> DocumentChainStats {
332        let mut stats = DocumentChainStats {
333            p2p_chains: p2p_chains.len(),
334            ..Default::default()
335        };
336
337        // P2P stats
338        for chain in p2p_chains {
339            stats.purchase_orders += 1;
340            stats.goods_receipts += chain.goods_receipts.len();
341
342            if chain.three_way_match_passed {
343                stats.p2p_three_way_match_passed += 1;
344            }
345
346            if chain.vendor_invoice.is_some() {
347                stats.vendor_invoices += 1;
348            }
349
350            if chain.payment.is_some() {
351                stats.ap_payments += 1;
352            }
353
354            if chain.is_complete {
355                stats.p2p_completed += 1;
356            }
357        }
358
359        // O2C stats
360        stats.o2c_chains = o2c_chains.len();
361        for chain in o2c_chains {
362            stats.sales_orders += 1;
363            stats.deliveries += chain.deliveries.len();
364
365            if chain.credit_check_passed {
366                stats.o2c_credit_check_passed += 1;
367            }
368
369            if chain.customer_invoice.is_some() {
370                stats.customer_invoices += 1;
371            }
372
373            if chain.customer_receipt.is_some() {
374                stats.ar_receipts += 1;
375            }
376
377            if chain.is_complete {
378                stats.o2c_completed += 1;
379            }
380        }
381
382        stats
383    }
384
385    /// Get reference to P2P generator for direct access.
386    pub fn p2p_generator(&mut self) -> &mut P2PGenerator {
387        &mut self.p2p_generator
388    }
389
390    /// Get reference to O2C generator for direct access.
391    pub fn o2c_generator(&mut self) -> &mut O2CGenerator {
392        &mut self.o2c_generator
393    }
394
395    /// Reset all generators.
396    pub fn reset(&mut self) {
397        self.rng = seeded_rng(self.seed, 0);
398        self.p2p_generator.reset();
399        self.o2c_generator.reset();
400    }
401}
402
403/// Helper to extract all journal entry-generating documents from flows.
404pub fn extract_je_sources(flows: &GeneratedDocumentFlows) -> JournalEntrySources {
405    let mut sources = JournalEntrySources::default();
406
407    for chain in &flows.p2p_chains {
408        // GR creates JE: DR Inventory, CR GR/IR
409        for gr in &chain.goods_receipts {
410            sources.goods_receipts.push(gr.clone());
411        }
412
413        // Invoice creates JE: DR Expense/GR-IR, CR AP
414        if let Some(invoice) = &chain.vendor_invoice {
415            sources.vendor_invoices.push(invoice.clone());
416        }
417
418        // Payment creates JE: DR AP, CR Bank
419        if let Some(payment) = &chain.payment {
420            sources.ap_payments.push(payment.clone());
421        }
422    }
423
424    for chain in &flows.o2c_chains {
425        // Delivery/GI creates JE: DR COGS, CR Inventory
426        for dlv in &chain.deliveries {
427            sources.deliveries.push(dlv.clone());
428        }
429
430        // Invoice creates JE: DR AR, CR Revenue
431        if let Some(invoice) = &chain.customer_invoice {
432            sources.customer_invoices.push(invoice.clone());
433        }
434
435        // Receipt creates JE: DR Bank, CR AR
436        if let Some(receipt) = &chain.customer_receipt {
437            sources.ar_receipts.push(receipt.clone());
438        }
439    }
440
441    sources
442}
443
444/// Sources for journal entry generation.
445#[derive(Debug, Default)]
446pub struct JournalEntrySources {
447    /// Goods receipts (DR Inventory, CR GR/IR)
448    pub goods_receipts: Vec<datasynth_core::models::documents::GoodsReceipt>,
449    /// Vendor invoices (DR Expense, CR AP)
450    pub vendor_invoices: Vec<datasynth_core::models::documents::VendorInvoice>,
451    /// AP payments (DR AP, CR Bank)
452    pub ap_payments: Vec<datasynth_core::models::documents::Payment>,
453    /// Deliveries (DR COGS, CR Inventory)
454    pub deliveries: Vec<datasynth_core::models::documents::Delivery>,
455    /// Customer invoices (DR AR, CR Revenue)
456    pub customer_invoices: Vec<datasynth_core::models::documents::CustomerInvoice>,
457    /// AR receipts (DR Bank, CR AR)
458    pub ar_receipts: Vec<datasynth_core::models::documents::Payment>,
459}
460
461#[cfg(test)]
462#[allow(clippy::unwrap_used)]
463mod tests {
464    use super::*;
465    use datasynth_core::models::{
466        CreditRating, Customer, CustomerPaymentBehavior, Material, MaterialType, Vendor,
467    };
468
469    fn create_test_pools() -> (VendorPool, CustomerPool, MaterialPool) {
470        let mut vendors = VendorPool::new();
471        for i in 1..=5 {
472            vendors.add_vendor(Vendor::new(
473                &format!("V-{:06}", i),
474                &format!("Vendor {}", i),
475                datasynth_core::models::VendorType::Supplier,
476            ));
477        }
478
479        let mut customers = CustomerPool::new();
480        for i in 1..=5 {
481            let mut customer = Customer::new(
482                &format!("C-{:06}", i),
483                &format!("Customer {}", i),
484                datasynth_core::models::CustomerType::Corporate,
485            );
486            customer.credit_rating = CreditRating::A;
487            customer.credit_limit = rust_decimal::Decimal::from(1_000_000);
488            customer.payment_behavior = CustomerPaymentBehavior::OnTime;
489            customers.add_customer(customer);
490        }
491
492        let mut materials = MaterialPool::new();
493        for i in 1..=10 {
494            let mut mat = Material::new(
495                format!("MAT-{:06}", i),
496                format!("Material {}", i),
497                MaterialType::FinishedGood,
498            );
499            mat.standard_cost = rust_decimal::Decimal::from(50 + i * 10);
500            mat.list_price = rust_decimal::Decimal::from(100 + i * 20);
501            materials.add_material(mat);
502        }
503
504        (vendors, customers, materials)
505    }
506
507    #[test]
508    fn test_manager_creation() {
509        let manager = DocumentChainManager::new(42);
510        assert!(manager.config.p2p_to_o2c_ratio == 1.0);
511    }
512
513    #[test]
514    fn test_generate_flows() {
515        let mut manager = DocumentChainManager::new(42);
516        let (vendors, customers, materials) = create_test_pools();
517
518        let flows = manager.generate_flows(
519            "1000",
520            20,
521            &vendors,
522            &customers,
523            &materials,
524            (
525                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
526                NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
527            ),
528            2024,
529            "JSMITH",
530        );
531
532        assert_eq!(flows.p2p_chains.len() + flows.o2c_chains.len(), 20);
533        assert!(flows.stats.purchase_orders > 0);
534        assert!(flows.stats.sales_orders > 0);
535    }
536
537    #[test]
538    fn test_balanced_flows() {
539        let mut manager = DocumentChainManager::new(42);
540        let (vendors, customers, materials) = create_test_pools();
541
542        let flows = manager.generate_balanced_flows(
543            10,
544            "1000",
545            &vendors,
546            &customers,
547            &materials,
548            (
549                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
550                NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
551            ),
552            2024,
553            "JSMITH",
554        );
555
556        assert_eq!(flows.p2p_chains.len(), 10);
557        assert_eq!(flows.o2c_chains.len(), 10);
558    }
559
560    #[test]
561    fn test_document_references_collected() {
562        let mut manager = DocumentChainManager::new(42);
563        let (vendors, customers, materials) = create_test_pools();
564
565        let flows = manager.generate_balanced_flows(
566            5,
567            "1000",
568            &vendors,
569            &customers,
570            &materials,
571            (
572                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
573                NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
574            ),
575            2024,
576            "JSMITH",
577        );
578
579        // Should have document references
580        assert!(!flows.document_references.is_empty());
581    }
582
583    #[test]
584    fn test_stats_calculation() {
585        let mut manager = DocumentChainManager::new(42);
586        let (vendors, customers, materials) = create_test_pools();
587
588        let flows = manager.generate_balanced_flows(
589            5,
590            "1000",
591            &vendors,
592            &customers,
593            &materials,
594            (
595                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
596                NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
597            ),
598            2024,
599            "JSMITH",
600        );
601
602        let stats = &flows.stats;
603        assert_eq!(stats.p2p_chains, 5);
604        assert_eq!(stats.o2c_chains, 5);
605        assert_eq!(stats.purchase_orders, 5);
606        assert_eq!(stats.sales_orders, 5);
607    }
608
609    #[test]
610    fn test_je_sources_extraction() {
611        let mut manager = DocumentChainManager::new(42);
612        let (vendors, customers, materials) = create_test_pools();
613
614        let flows = manager.generate_balanced_flows(
615            5,
616            "1000",
617            &vendors,
618            &customers,
619            &materials,
620            (
621                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
622                NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
623            ),
624            2024,
625            "JSMITH",
626        );
627
628        let sources = extract_je_sources(&flows);
629
630        // Should have JE sources from both P2P and O2C
631        assert!(!sources.goods_receipts.is_empty());
632        assert!(!sources.vendor_invoices.is_empty());
633        assert!(!sources.deliveries.is_empty());
634        assert!(!sources.customer_invoices.is_empty());
635    }
636
637    #[test]
638    fn test_custom_ratio() {
639        let config = DocumentChainManagerConfig {
640            p2p_to_o2c_ratio: 2.0, // 2x P2P
641            ..Default::default()
642        };
643
644        let mut manager = DocumentChainManager::with_config(42, config);
645        let (vendors, customers, materials) = create_test_pools();
646
647        let flows = manager.generate_flows(
648            "1000",
649            30,
650            &vendors,
651            &customers,
652            &materials,
653            (
654                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
655                NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
656            ),
657            2024,
658            "JSMITH",
659        );
660
661        // With 2:1 ratio, should have ~20 P2P and ~10 O2C
662        assert!(flows.p2p_chains.len() > flows.o2c_chains.len());
663    }
664
665    #[test]
666    fn test_reset() {
667        let mut manager = DocumentChainManager::new(42);
668        let (vendors, customers, materials) = create_test_pools();
669
670        let flows1 = manager.generate_balanced_flows(
671            5,
672            "1000",
673            &vendors,
674            &customers,
675            &materials,
676            (
677                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
678                NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
679            ),
680            2024,
681            "JSMITH",
682        );
683
684        manager.reset();
685
686        let flows2 = manager.generate_balanced_flows(
687            5,
688            "1000",
689            &vendors,
690            &customers,
691            &materials,
692            (
693                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
694                NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
695            ),
696            2024,
697            "JSMITH",
698        );
699
700        // After reset, should get same results
701        assert_eq!(
702            flows1.p2p_chains[0].purchase_order.header.document_id,
703            flows2.p2p_chains[0].purchase_order.header.document_id
704        );
705    }
706}