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)]
454#[allow(clippy::unwrap_used)]
455mod tests {
456    use super::*;
457    use datasynth_core::models::{
458        CreditRating, Customer, CustomerPaymentBehavior, Material, MaterialType, Vendor,
459    };
460
461    fn create_test_pools() -> (VendorPool, CustomerPool, MaterialPool) {
462        let mut vendors = VendorPool::new();
463        for i in 1..=5 {
464            vendors.add_vendor(Vendor::new(
465                &format!("V-{:06}", i),
466                &format!("Vendor {}", i),
467                datasynth_core::models::VendorType::Supplier,
468            ));
469        }
470
471        let mut customers = CustomerPool::new();
472        for i in 1..=5 {
473            let mut customer = Customer::new(
474                &format!("C-{:06}", i),
475                &format!("Customer {}", i),
476                datasynth_core::models::CustomerType::Corporate,
477            );
478            customer.credit_rating = CreditRating::A;
479            customer.credit_limit = rust_decimal::Decimal::from(1_000_000);
480            customer.payment_behavior = CustomerPaymentBehavior::OnTime;
481            customers.add_customer(customer);
482        }
483
484        let mut materials = MaterialPool::new();
485        for i in 1..=10 {
486            let mut mat = Material::new(
487                format!("MAT-{:06}", i),
488                format!("Material {}", i),
489                MaterialType::FinishedGood,
490            );
491            mat.standard_cost = rust_decimal::Decimal::from(50 + i * 10);
492            mat.list_price = rust_decimal::Decimal::from(100 + i * 20);
493            materials.add_material(mat);
494        }
495
496        (vendors, customers, materials)
497    }
498
499    #[test]
500    fn test_manager_creation() {
501        let manager = DocumentChainManager::new(42);
502        assert!(manager.config.p2p_to_o2c_ratio == 1.0);
503    }
504
505    #[test]
506    fn test_generate_flows() {
507        let mut manager = DocumentChainManager::new(42);
508        let (vendors, customers, materials) = create_test_pools();
509
510        let flows = manager.generate_flows(
511            "1000",
512            20,
513            &vendors,
514            &customers,
515            &materials,
516            (
517                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
518                NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
519            ),
520            2024,
521            "JSMITH",
522        );
523
524        assert_eq!(flows.p2p_chains.len() + flows.o2c_chains.len(), 20);
525        assert!(flows.stats.purchase_orders > 0);
526        assert!(flows.stats.sales_orders > 0);
527    }
528
529    #[test]
530    fn test_balanced_flows() {
531        let mut manager = DocumentChainManager::new(42);
532        let (vendors, customers, materials) = create_test_pools();
533
534        let flows = manager.generate_balanced_flows(
535            10,
536            "1000",
537            &vendors,
538            &customers,
539            &materials,
540            (
541                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
542                NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
543            ),
544            2024,
545            "JSMITH",
546        );
547
548        assert_eq!(flows.p2p_chains.len(), 10);
549        assert_eq!(flows.o2c_chains.len(), 10);
550    }
551
552    #[test]
553    fn test_document_references_collected() {
554        let mut manager = DocumentChainManager::new(42);
555        let (vendors, customers, materials) = create_test_pools();
556
557        let flows = manager.generate_balanced_flows(
558            5,
559            "1000",
560            &vendors,
561            &customers,
562            &materials,
563            (
564                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
565                NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
566            ),
567            2024,
568            "JSMITH",
569        );
570
571        // Should have document references
572        assert!(!flows.document_references.is_empty());
573    }
574
575    #[test]
576    fn test_stats_calculation() {
577        let mut manager = DocumentChainManager::new(42);
578        let (vendors, customers, materials) = create_test_pools();
579
580        let flows = manager.generate_balanced_flows(
581            5,
582            "1000",
583            &vendors,
584            &customers,
585            &materials,
586            (
587                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
588                NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
589            ),
590            2024,
591            "JSMITH",
592        );
593
594        let stats = &flows.stats;
595        assert_eq!(stats.p2p_chains, 5);
596        assert_eq!(stats.o2c_chains, 5);
597        assert_eq!(stats.purchase_orders, 5);
598        assert_eq!(stats.sales_orders, 5);
599    }
600
601    #[test]
602    fn test_je_sources_extraction() {
603        let mut manager = DocumentChainManager::new(42);
604        let (vendors, customers, materials) = create_test_pools();
605
606        let flows = manager.generate_balanced_flows(
607            5,
608            "1000",
609            &vendors,
610            &customers,
611            &materials,
612            (
613                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
614                NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
615            ),
616            2024,
617            "JSMITH",
618        );
619
620        let sources = extract_je_sources(&flows);
621
622        // Should have JE sources from both P2P and O2C
623        assert!(!sources.goods_receipts.is_empty());
624        assert!(!sources.vendor_invoices.is_empty());
625        assert!(!sources.deliveries.is_empty());
626        assert!(!sources.customer_invoices.is_empty());
627    }
628
629    #[test]
630    fn test_custom_ratio() {
631        let config = DocumentChainManagerConfig {
632            p2p_to_o2c_ratio: 2.0, // 2x P2P
633            ..Default::default()
634        };
635
636        let mut manager = DocumentChainManager::with_config(42, config);
637        let (vendors, customers, materials) = create_test_pools();
638
639        let flows = manager.generate_flows(
640            "1000",
641            30,
642            &vendors,
643            &customers,
644            &materials,
645            (
646                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
647                NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
648            ),
649            2024,
650            "JSMITH",
651        );
652
653        // With 2:1 ratio, should have ~20 P2P and ~10 O2C
654        assert!(flows.p2p_chains.len() > flows.o2c_chains.len());
655    }
656
657    #[test]
658    fn test_reset() {
659        let mut manager = DocumentChainManager::new(42);
660        let (vendors, customers, materials) = create_test_pools();
661
662        let flows1 = manager.generate_balanced_flows(
663            5,
664            "1000",
665            &vendors,
666            &customers,
667            &materials,
668            (
669                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
670                NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
671            ),
672            2024,
673            "JSMITH",
674        );
675
676        manager.reset();
677
678        let flows2 = manager.generate_balanced_flows(
679            5,
680            "1000",
681            &vendors,
682            &customers,
683            &materials,
684            (
685                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
686                NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
687            ),
688            2024,
689            "JSMITH",
690        );
691
692        // After reset, should get same results
693        assert_eq!(
694            flows1.p2p_chains[0].purchase_order.header.document_id,
695            flows2.p2p_chains[0].purchase_order.header.document_id
696        );
697    }
698}