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