Skip to main content

datasynth_generators/document_flow/
document_flow_je_generator.rs

1//! Generate journal entries from document flows.
2//!
3//! This module creates proper GL entries from P2P and O2C document chains,
4//! ensuring that document flow activity is reflected in the general ledger.
5//!
6//! # P2P Flow JE Mappings
7//! - GoodsReceipt → DR Inventory, CR GR/IR Clearing
8//! - VendorInvoice → DR GR/IR Clearing (net), DR Input VAT (tax), CR AP (gross)
9//! - Payment → DR AP, CR Cash
10//!
11//! # O2C Flow JE Mappings
12//! - Delivery → DR COGS, CR Inventory
13//! - CustomerInvoice → DR AR (gross), CR Revenue (net), CR VAT Payable (tax)
14//! - CustomerReceipt → DR Cash, CR AR
15
16use std::collections::{HashMap, HashSet};
17use std::sync::Arc;
18
19use chrono::NaiveDate;
20use rand::prelude::*;
21use rand::SeedableRng;
22use rand_chacha::ChaCha8Rng;
23use rust_decimal::Decimal;
24
25use datasynth_core::accounts::{
26    cash_accounts, control_accounts, expense_accounts, revenue_accounts, suspense_accounts,
27    tax_accounts,
28};
29use datasynth_core::models::{
30    documents::{CustomerInvoice, Delivery, GoodsReceipt, Payment, VendorInvoice},
31    BusinessProcess, DocumentRef, JournalEntry, JournalEntryHeader, JournalEntryLine,
32    TransactionSource,
33};
34use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
35
36use super::{O2CDocumentChain, P2PDocumentChain};
37
38/// Configuration for document flow JE generation.
39#[derive(Debug, Clone)]
40pub struct DocumentFlowJeConfig {
41    /// Inventory account (default: 1200 from control_accounts::INVENTORY)
42    pub inventory_account: String,
43    /// GR/IR clearing account (default: 2900 from control_accounts::GR_IR_CLEARING)
44    pub gr_ir_clearing_account: String,
45    /// Accounts payable control account (default: 2000 from control_accounts::AP_CONTROL)
46    pub ap_account: String,
47    /// Cash/bank account (default: 1000 from cash_accounts::OPERATING_CASH)
48    pub cash_account: String,
49    /// Accounts receivable control account (default: 1100 from control_accounts::AR_CONTROL)
50    pub ar_account: String,
51    /// Revenue account (default: 4000 from revenue_accounts::PRODUCT_REVENUE)
52    pub revenue_account: String,
53    /// COGS account (default: 5000 from expense_accounts::COGS)
54    pub cogs_account: String,
55    /// VAT output (payable) account for O2C (default: 2110 from tax_accounts::VAT_PAYABLE)
56    pub vat_output_account: String,
57    /// VAT input (receivable) account for P2P (default: 1160 from tax_accounts::INPUT_VAT)
58    pub vat_input_account: String,
59    /// Whether to populate FEC auxiliary and lettrage fields on AP/AR lines.
60    /// Only relevant for French GAAP / FEC export.
61    pub populate_fec_fields: bool,
62    /// SP3.13 W1 — Share of vendor invoices that post directly to expense GL
63    /// accounts (bypassing GR/IR Clearing).  corpus ratio is ~70% direct
64    /// (services, utilities, travel, professional fees) vs. ~30% via GR/IR
65    /// (three-way-match for materials receipts).
66    ///
67    /// When `loaded_priors.is_some()`, the direct-expense path draws the GL
68    /// account from the per-source conditional keyed on "KR", so W1.5
69    /// `split_je_expense_lines` can then split it into multiple expense lines.
70    ///
71    /// Clamped to [0.0, 1.0].  Default: 0.70.
72    /// No-op when `loaded_priors` is `None` (priors-disabled path unchanged).
73    pub direct_expense_share: f64,
74}
75
76impl Default for DocumentFlowJeConfig {
77    fn default() -> Self {
78        Self {
79            inventory_account: control_accounts::INVENTORY.to_string(),
80            gr_ir_clearing_account: control_accounts::GR_IR_CLEARING.to_string(),
81            ap_account: control_accounts::AP_CONTROL.to_string(),
82            cash_account: cash_accounts::OPERATING_CASH.to_string(),
83            ar_account: control_accounts::AR_CONTROL.to_string(),
84            revenue_account: revenue_accounts::PRODUCT_REVENUE.to_string(),
85            cogs_account: expense_accounts::COGS.to_string(),
86            vat_output_account: tax_accounts::VAT_PAYABLE.to_string(),
87            vat_input_account: tax_accounts::INPUT_VAT.to_string(),
88            populate_fec_fields: false,
89            direct_expense_share: 0.70,
90        }
91    }
92}
93
94impl DocumentFlowJeConfig {
95    /// Create a config for French GAAP (PCG) with FEC field population enabled.
96    pub fn french_gaap() -> Self {
97        use datasynth_core::pcg;
98        Self {
99            inventory_account: pcg::control_accounts::INVENTORY.to_string(),
100            gr_ir_clearing_account: pcg::control_accounts::GR_IR_CLEARING.to_string(),
101            ap_account: pcg::control_accounts::AP_CONTROL.to_string(),
102            cash_account: pcg::cash_accounts::BANK_ACCOUNT.to_string(),
103            ar_account: pcg::control_accounts::AR_CONTROL.to_string(),
104            revenue_account: pcg::revenue_accounts::PRODUCT_REVENUE.to_string(),
105            cogs_account: pcg::expense_accounts::COGS.to_string(),
106            vat_output_account: pcg::tax_accounts::OUTPUT_VAT.to_string(),
107            vat_input_account: pcg::tax_accounts::INPUT_VAT.to_string(),
108            populate_fec_fields: true,
109            direct_expense_share: 0.70,
110        }
111    }
112}
113
114impl From<&datasynth_core::FrameworkAccounts> for DocumentFlowJeConfig {
115    fn from(fa: &datasynth_core::FrameworkAccounts) -> Self {
116        Self {
117            inventory_account: fa.inventory.clone(),
118            gr_ir_clearing_account: fa.gr_ir_clearing.clone(),
119            ap_account: fa.ap_control.clone(),
120            cash_account: fa.bank_account.clone(),
121            ar_account: fa.ar_control.clone(),
122            revenue_account: fa.product_revenue.clone(),
123            cogs_account: fa.cogs.clone(),
124            vat_output_account: fa.vat_payable.clone(),
125            vat_input_account: fa.input_vat.clone(),
126            populate_fec_fields: fa.audit_export.fec_enabled,
127            direct_expense_share: 0.70,
128        }
129    }
130}
131
132/// Maximum number of sub-lines produced by a single expense split.
133/// Prevents runaway splits on heavy-tailed distributions.
134const MAX_SPLIT_LINES: usize = 8;
135
136/// SP3.13 follow-up — geometric within-bucket draw biased toward `lo`.
137///
138/// Real KR JEs are concentrated at 3 lines; the prior's bucket [4, 9] would
139/// draw uniformly to a mean of ~6.5. A geometric decay with `DECAY = 0.5`
140/// shifts the mean to ~4.5, closely matching the corpus shape.
141///
142/// Probabilities: p(k) = DECAY^(k - lo) / normalizer for k in [lo, hi].
143/// Buckets of width ≤ 1 return `lo` directly (no sampling needed).
144///
145/// Exposed as `pub(crate)` so the inline unit test in `mod tests` can call it.
146pub(crate) fn sample_within_bucket<R: rand::Rng>(lo: u32, hi: u32, rng: &mut R) -> u32 {
147    if hi == lo {
148        return lo;
149    }
150    const DECAY: f64 = 0.5;
151    let n = (hi - lo + 1) as usize;
152    let mut weights: Vec<f64> = (0..n).map(|i| DECAY.powi(i as i32)).collect();
153    // Normalise in-place.
154    let total: f64 = weights.iter().sum();
155    for w in &mut weights {
156        *w /= total;
157    }
158    // Cumulative draw.
159    let r: f64 = rng.random_range(0.0..1.0);
160    let mut cum = 0.0;
161    for (i, w) in weights.iter().enumerate() {
162        cum += w;
163        if r <= cum {
164            return lo + i as u32;
165        }
166    }
167    hi
168}
169
170/// SP3.13 follow-up — sample target line count from a `LineCountHistogram`
171/// using the geometric within-bucket draw instead of the uniform one in
172/// `LineCountHistogram::sample_bucket`.
173///
174/// Phase 1: pick a bucket index weighted by the histogram's probability mass.
175/// Phase 2: draw within that bucket's `[lo, hi]` range geometrically
176///           (see `sample_within_bucket`).
177///
178/// The histogram's bucket convention is inclusive lower-bound, exclusive upper-
179/// bound. The last bucket has no successor, so its upper bound is treated as
180/// equal to the lower bound (single-point bucket).
181fn sample_target_lines_geometric<R: rand::Rng>(
182    hist: &datasynth_core::distributions::behavioral_priors::LineCountHistogram,
183    rng: &mut R,
184) -> u32 {
185    if hist.buckets.is_empty() {
186        return 0;
187    }
188    // Phase 1: pick bucket by probability mass (same logic as sample_bucket).
189    let r: f64 = rng.random_range(0.0..1.0);
190    let mut cum = 0.0;
191    let mut chosen_idx = hist.buckets.len() - 1;
192    for (i, &p) in hist.probabilities.iter().enumerate() {
193        cum += p;
194        if r <= cum {
195            chosen_idx = i;
196            break;
197        }
198    }
199    let lo = hist.buckets[chosen_idx];
200    // Phase 2: compute upper bound (exclusive) and sample geometrically.
201    // The last bucket has no next bucket, so hi = lo (single-point).
202    let hi_exclusive = hist.buckets.get(chosen_idx + 1).copied().unwrap_or(lo);
203    // Convert exclusive upper bound to inclusive for the geometric draw.
204    let hi_inclusive = if hi_exclusive > lo {
205        hi_exclusive - 1
206    } else {
207        lo
208    };
209    sample_within_bucket(lo, hi_inclusive, rng)
210}
211
212/// Generator for creating JEs from document flows.
213pub struct DocumentFlowJeGenerator {
214    config: DocumentFlowJeConfig,
215    uuid_factory: DeterministicUuidFactory,
216    /// Lookup map: partner_id → auxiliary GL account number.
217    /// When populated (from vendor/customer master data), `set_auxiliary_fields`
218    /// uses the framework-specific auxiliary account (e.g., PCG "4010001", SKR04 "33000001")
219    /// instead of the raw partner ID.
220    auxiliary_account_lookup: HashMap<String, String>,
221    /// Cost-center IDs sourced from the generated cost-centers master so
222    /// document-flow-derived JEs (P2P / O2C) reference IDs that join
223    /// back to `cost_centers.id`.  Falls back to the hardcoded
224    /// `COST_CENTER_POOL` const when empty.
225    cost_center_pool: Vec<String>,
226    /// Profit-center IDs sourced from the generated profit-centers master.
227    /// Same population semantics as `cost_center_pool`.
228    profit_center_pool: Vec<String>,
229    /// SP3.12 — when `Some`, pads fixed-shape JEs with balancing pair-lines
230    /// so the lines-per-JE distribution matches the corpus prior.
231    /// Set by the orchestrator from the same `LoadedPriors` used by
232    /// `JournalEntryGenerator`.  No-op when `None`.
233    pub loaded_priors: Option<Arc<crate::priors_loader::LoadedPriors>>,
234    /// RNG for priors-driven padding; seeded deterministically from `seed`.
235    rng: ChaCha8Rng,
236}
237
238impl DocumentFlowJeGenerator {
239    /// Create a new document flow JE generator with default config and seed 0.
240    pub fn new() -> Self {
241        Self::with_config_and_seed(DocumentFlowJeConfig::default(), 0)
242    }
243
244    /// Create with custom account configuration and seed.
245    pub fn with_config_and_seed(config: DocumentFlowJeConfig, seed: u64) -> Self {
246        Self {
247            config,
248            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::DocumentFlow),
249            auxiliary_account_lookup: HashMap::new(),
250            cost_center_pool: Vec::new(),
251            profit_center_pool: Vec::new(),
252            loaded_priors: None,
253            // Offset the seed so this RNG is independent from the uuid_factory seed.
254            rng: ChaCha8Rng::seed_from_u64(seed.wrapping_add(0xDF_5312_0000)),
255        }
256    }
257
258    /// SP3.12 — Wire industry priors so that fixed-shape document-flow JEs are
259    /// padded with balancing pair-lines to match the real lines-per-JE prior.
260    pub fn set_loaded_priors(&mut self, priors: Arc<crate::priors_loader::LoadedPriors>) {
261        self.loaded_priors = Some(priors);
262    }
263
264    /// Set the auxiliary account lookup map (partner_id → auxiliary GL account).
265    ///
266    /// When populated, FEC `auxiliary_account_number` fields will use the
267    /// framework-specific auxiliary GL account (e.g., PCG "4010001") instead
268    /// of the raw partner ID.
269    pub fn set_auxiliary_account_lookup(&mut self, lookup: HashMap<String, String>) {
270        self.auxiliary_account_lookup = lookup;
271    }
272
273    /// Set the cost-center pool (master-data IDs).  See
274    /// `JeGenerator::with_cost_center_pool` for semantics.
275    pub fn set_cost_center_pool(&mut self, ids: Vec<String>) {
276        self.cost_center_pool = ids;
277    }
278
279    /// Set the profit-center pool (master-data IDs).
280    pub fn set_profit_center_pool(&mut self, ids: Vec<String>) {
281        self.profit_center_pool = ids;
282    }
283
284    /// SP3.12 W1.5 — Split the dominant single-account line on the debit (or
285    /// credit) side into N sub-lines drawn from the per-source GL-account
286    /// conditional, matching the corpus pattern: one invoice → 2-4 expense
287    /// lines split across distinct GL accounts.
288    ///
289    /// # Design
290    ///
291    /// 1. Sample the target line count from the source-conditional
292    ///    `LinesPerJePrior` histogram (e.g. "KR" → ~3 lines, "KZ" → 2 lines).
293    /// 2. Find the *splittable* line — the debit line with the highest amount
294    ///    that does NOT post to a control account (AP, AR, Inventory, GR/IR,
295    ///    Cash, Bank, VAT, Suspense).
296    /// 3. Draw N distinct GL accounts from
297    ///    `per_source_attribute[doc_type]["gl_account"]`.
298    /// 4. Split the original line's amount proportionally across N sub-lines
299    ///    (Decimal-exact: last sub-line absorbs rounding residual).
300    /// 5. Replace the original line with the N sub-lines and re-number all lines.
301    ///
302    /// The control-account (AP/AR/Cash/…) line stays a single line — only the
303    /// expense/asset side is split.  Balance is preserved by construction.
304    ///
305    /// No-op when:
306    ///   - `loaded_priors` is `None`
307    ///   - the per-source GL conditional has < 2 distinct values
308    ///   - the entry already has enough lines
309    ///   - no splittable line is found
310    fn split_je_expense_lines(&mut self, entry: &mut JournalEntry) {
311        let priors = match self.loaded_priors.as_ref() {
312            Some(p) => p.clone(),
313            None => return,
314        };
315
316        // 1. Sample target line count from the source-conditional histogram.
317        //    SP3.13 follow-up: use geometric within-bucket draw so the sampler
318        //    concentrates near the bucket's lower bound instead of drawing
319        //    uniformly.  The [4, 9] KR bucket had a uniform mean of ~6.5; with
320        //    geometric decay 0.5 the mean shifts to ~4.5, matching real KR
321        //    which is concentrated at 3 lines/JE.
322        let doc_type = entry.header.document_type.clone();
323        let hist = priors
324            .lines_per_je
325            .by_source
326            .get(doc_type.as_str())
327            .unwrap_or(&priors.lines_per_je.overall);
328        let target = (sample_target_lines_geometric(hist, &mut self.rng) as usize)
329            .clamp(2, 2 + MAX_SPLIT_LINES);
330
331        let current = entry.lines.len();
332        if current >= target {
333            return; // Already has enough lines; no split needed.
334        }
335
336        // n_splits: how many sub-lines to produce (replaces the original 1 line)
337        let n_splits = (target - current + 1).clamp(2, MAX_SPLIT_LINES);
338
339        // 2. Collect the set of control accounts that must NOT be split.
340        let no_split_accounts: HashSet<&str> = [
341            self.config.ap_account.as_str(),
342            self.config.ar_account.as_str(),
343            self.config.cash_account.as_str(),
344            self.config.gr_ir_clearing_account.as_str(),
345            self.config.inventory_account.as_str(),
346            self.config.vat_input_account.as_str(),
347            self.config.vat_output_account.as_str(),
348            control_accounts::AR_CONTROL,
349            control_accounts::AP_CONTROL,
350            control_accounts::INVENTORY,
351            control_accounts::GR_IR_CLEARING,
352            cash_accounts::OPERATING_CASH,
353            cash_accounts::BANK_ACCOUNT,
354            cash_accounts::WIRE_CLEARING,
355            suspense_accounts::GENERAL_SUSPENSE,
356        ]
357        .into_iter()
358        .collect();
359
360        // 3. Find the splittable line: highest-amount debit not in no_split_accounts.
361        //    Fall back to highest-amount credit if no qualifying debit exists.
362        let splittable_idx: Option<usize> = {
363            let mut best_debit: Option<(usize, Decimal)> = None;
364            let mut best_credit: Option<(usize, Decimal)> = None;
365            for (i, line) in entry.lines.iter().enumerate() {
366                if no_split_accounts.contains(line.gl_account.as_str()) {
367                    continue;
368                }
369                if line.debit_amount > Decimal::ZERO {
370                    let amt = line.debit_amount;
371                    if best_debit.map(|(_, a)| amt > a).unwrap_or(true) {
372                        best_debit = Some((i, amt));
373                    }
374                } else if line.credit_amount > Decimal::ZERO {
375                    let amt = line.credit_amount;
376                    if best_credit.map(|(_, a)| amt > a).unwrap_or(true) {
377                        best_credit = Some((i, amt));
378                    }
379                }
380            }
381            best_debit
382                .map(|(i, _)| i)
383                .or_else(|| best_credit.map(|(i, _)| i))
384        };
385
386        let idx = match splittable_idx {
387            Some(i) => i,
388            None => return, // No splittable line found.
389        };
390
391        // 4. Draw N distinct GL accounts from the per-source conditional.
392        // SP4.6 — derive line role from the splittable line's direction so that
393        // expense/asset accounts are drawn for DR splits and liability/revenue
394        // accounts for CR splits.
395        let split_line_role = if entry.lines[idx].debit_amount > Decimal::ZERO {
396            "DR"
397        } else {
398            "CR"
399        };
400        let mut gl_accounts: Vec<String> = Vec::with_capacity(n_splits);
401        {
402            let mut seen: HashSet<String> = HashSet::new();
403            let mut attempts = 0usize;
404            while gl_accounts.len() < n_splits && attempts < 50 {
405                attempts += 1;
406                // SP4.6 — role-aware GL selection. Fall back to source-marginal when
407                // the (source, role) conditional is absent or yields nothing.
408                let candidate = priors
409                    .sample_gl_for_source_role(&doc_type, split_line_role, &mut self.rng)
410                    .or_else(|| {
411                        priors.sample_attribute_for_source(&doc_type, "gl_account", &mut self.rng)
412                    });
413                if let Some(gl) = candidate {
414                    if !no_split_accounts.contains(gl.as_str()) && seen.insert(gl.clone()) {
415                        gl_accounts.push(gl);
416                    }
417                } else {
418                    // No per-source conditional available — fall back gracefully.
419                    break;
420                }
421            }
422        }
423
424        if gl_accounts.len() < 2 {
425            // Cannot split meaningfully — not enough distinct GL accounts.
426            return;
427        }
428        // Cap to the actual number we managed to draw.
429        let n_actual = gl_accounts.len().min(n_splits);
430        let gl_accounts = &gl_accounts[..n_actual];
431
432        // 5. Split the original line's amount across n_actual sub-lines.
433        //    Each sub-line weight is drawn from uniform(0.5, 1.5), then normalised.
434        let original_line = entry.lines[idx].clone();
435        let is_debit_split = original_line.debit_amount > Decimal::ZERO;
436        let total_amount = if is_debit_split {
437            original_line.debit_amount
438        } else {
439            original_line.credit_amount
440        };
441
442        // Draw weights and compute proportional amounts.
443        let raw_weights: Vec<f64> = (0..n_actual)
444            .map(|_| self.rng.random_range(0.5f64..1.5f64))
445            .collect();
446        let weight_sum: f64 = raw_weights.iter().sum();
447        // Convert total_amount to f64 for proportional split, then back to Decimal.
448        let total_f64 = total_amount.to_string().parse::<f64>().unwrap_or(0.0);
449        let mut amounts: Vec<Decimal> = raw_weights
450            .iter()
451            .map(|w| {
452                let proportion = w / weight_sum;
453                let raw = (proportion * total_f64 * 100.0).round() / 100.0;
454                Decimal::new((raw * 100.0) as i64, 2)
455            })
456            .collect();
457
458        // Exact balance: last sub-line absorbs rounding residual.
459        let computed_sum: Decimal = amounts[..n_actual - 1].iter().sum();
460        let residual = total_amount - computed_sum;
461        if residual > Decimal::ZERO {
462            amounts[n_actual - 1] = residual;
463        } else if n_actual >= 2 {
464            // If residual is zero or negative due to rounding, redistribute to last two.
465            let last_two_sum = amounts[n_actual - 2] + amounts[n_actual - 1] + residual;
466            if last_two_sum > Decimal::ZERO {
467                amounts[n_actual - 2] = last_two_sum / Decimal::from(2);
468                amounts[n_actual - 1] = last_two_sum - amounts[n_actual - 2];
469            }
470        }
471
472        // Guard: if any amount wound up ≤ 0 after rounding, abort the split.
473        if amounts.iter().any(|a| *a <= Decimal::ZERO) {
474            return;
475        }
476
477        // 6. Build the sub-lines and insert them where the original was.
478        let doc_id = entry.header.document_id;
479        let base_line_no = original_line.line_number;
480
481        // Remove the original line.
482        entry.lines.remove(idx);
483
484        for (i, (gl, amount)) in gl_accounts.iter().zip(amounts.iter()).enumerate() {
485            let line_no = base_line_no + i as u32;
486            let mut split_line = if is_debit_split {
487                JournalEntryLine::debit(doc_id, line_no, gl.clone(), *amount)
488            } else {
489                JournalEntryLine::credit(doc_id, line_no, gl.clone(), *amount)
490            };
491            // Inherit metadata from the original line.
492            split_line.cost_center = original_line.cost_center.clone();
493            split_line.profit_center = original_line.profit_center.clone();
494            split_line.trading_partner = original_line.trading_partner.clone();
495            split_line.text = original_line.text.clone();
496            split_line.line_text = original_line.line_text.clone();
497            split_line.segment = original_line.segment.clone();
498            split_line.functional_area = original_line.functional_area.clone();
499            split_line.project_code = original_line.project_code.clone();
500            entry.lines.insert(idx + i, split_line);
501        }
502
503        // 7. Re-number all lines sequentially after the insertion.
504        for (i, line) in entry.lines.iter_mut().enumerate() {
505            line.line_number = (i + 1) as u32;
506        }
507    }
508
509    /// Build an account description lookup from the configured accounts.
510    fn account_description_map(&self) -> HashMap<String, String> {
511        let mut map = HashMap::new();
512        map.insert(
513            self.config.inventory_account.clone(),
514            "Inventory".to_string(),
515        );
516        map.insert(
517            self.config.gr_ir_clearing_account.clone(),
518            "GR/IR Clearing".to_string(),
519        );
520        map.insert(
521            self.config.ap_account.clone(),
522            "Accounts Payable".to_string(),
523        );
524        map.insert(
525            self.config.cash_account.clone(),
526            "Cash and Cash Equivalents".to_string(),
527        );
528        map.insert(
529            self.config.ar_account.clone(),
530            "Accounts Receivable".to_string(),
531        );
532        map.insert(
533            self.config.revenue_account.clone(),
534            "Product Revenue".to_string(),
535        );
536        map.insert(
537            self.config.cogs_account.clone(),
538            "Cost of Goods Sold".to_string(),
539        );
540        map.insert(
541            self.config.vat_output_account.clone(),
542            "VAT Payable".to_string(),
543        );
544        map.insert(
545            self.config.vat_input_account.clone(),
546            "Input VAT".to_string(),
547        );
548        map
549    }
550
551    /// Cost center pool used for expense account enrichment.
552    const COST_CENTER_POOL: &'static [&'static str] =
553        &["CC1000", "CC2000", "CC3000", "CC4000", "CC5000"];
554
555    /// Enrich journal entry line items with account descriptions, cost centers,
556    /// profit centers, value dates, line text, and assignment fields.
557    ///
558    /// Uses the configured accounts to derive descriptions, since the document
559    /// flow JE generator does not have access to the full chart of accounts.
560    fn enrich_line_items(&self, entry: &mut JournalEntry) {
561        // T2-D Lever 1b: emit the SAP document-type code in the `source` column
562        // for document-flow-derived JEs, instead of falling back to the coarse
563        // `TransactionSource` enum (csv_sink). `document_type` is already the SAP
564        // code (WE/KR/KZ/WL/DR/DZ). Closes the ~8.5% `automated` source residual
565        // left by Lever 1 (experiments/ml/FINDINGS.md sec.7).
566        if entry.header.sap_source_code.is_none() && !entry.header.document_type.is_empty() {
567            entry.header.sap_source_code = Some(entry.header.document_type.clone());
568        }
569        let desc_map = self.account_description_map();
570        let posting_date = entry.header.posting_date;
571        let company_code = &entry.header.company_code;
572        let header_text = entry.header.header_text.clone();
573        let business_process = entry.header.business_process;
574
575        // Derive a deterministic index from document_id for cost center selection
576        let doc_id_bytes = entry.header.document_id.as_bytes();
577        let mut cc_seed: usize = 0;
578        for &b in doc_id_bytes {
579            cc_seed = cc_seed.wrapping_add(b as usize);
580        }
581
582        for (i, line) in entry.lines.iter_mut().enumerate() {
583            // 1. account_description from known accounts
584            if line.account_description.is_none() {
585                line.account_description = desc_map.get(&line.gl_account).cloned();
586            }
587
588            // 2. cost_center for expense accounts (5xxx/6xxx).
589            //    When the orchestrator wired a master-data pool via
590            //    `set_cost_center_pool`, draw from it filtered to the
591            //    entry's company; otherwise fall back to the hardcoded
592            //    `COST_CENTER_POOL`.
593            if line.cost_center.is_none() {
594                let first_char = line.gl_account.chars().next().unwrap_or('0');
595                if first_char == '5' || first_char == '6' {
596                    if !self.cost_center_pool.is_empty() {
597                        let needle = format!("-{company_code}-");
598                        let candidates: Vec<&String> = self
599                            .cost_center_pool
600                            .iter()
601                            .filter(|id| id.contains(&needle))
602                            .collect();
603                        let pool: Vec<&String> = if candidates.is_empty() {
604                            self.cost_center_pool.iter().collect()
605                        } else {
606                            candidates
607                        };
608                        let idx = cc_seed.wrapping_add(i) % pool.len();
609                        line.cost_center = Some(pool[idx].clone());
610                    } else {
611                        let idx = cc_seed.wrapping_add(i) % Self::COST_CENTER_POOL.len();
612                        line.cost_center = Some(Self::COST_CENTER_POOL[idx].to_string());
613                    }
614                }
615            }
616
617            // 3. profit_center: master pool when available, else
618            //    derived from company code + business process (legacy).
619            if line.profit_center.is_none() {
620                if !self.profit_center_pool.is_empty() {
621                    let needle = format!("-{company_code}-");
622                    let candidates: Vec<&String> = self
623                        .profit_center_pool
624                        .iter()
625                        .filter(|id| id.contains(&needle))
626                        .collect();
627                    let pool: Vec<&String> = if candidates.is_empty() {
628                        self.profit_center_pool.iter().collect()
629                    } else {
630                        candidates
631                    };
632                    let idx = cc_seed.wrapping_add(i) % pool.len();
633                    line.profit_center = Some(pool[idx].clone());
634                } else {
635                    let suffix = match business_process {
636                        Some(BusinessProcess::P2P) => "-P2P",
637                        Some(BusinessProcess::O2C) => "-O2C",
638                        _ => "",
639                    };
640                    line.profit_center = Some(format!("PC-{company_code}{suffix}"));
641                }
642            }
643
644            // 4. line_text: fall back to header_text
645            if line.line_text.is_none() {
646                line.line_text = header_text.clone();
647            }
648
649            // 5. value_date for AR/AP accounts
650            if line.value_date.is_none()
651                && (line.gl_account == self.config.ar_account
652                    || line.gl_account == self.config.ap_account)
653            {
654                line.value_date = Some(posting_date);
655            }
656
657            // 6. assignment for AP/AR lines - extract partner ID from header text
658            if line.assignment.is_none()
659                && (line.gl_account == self.config.ap_account
660                    || line.gl_account == self.config.ar_account)
661            {
662                if let Some(ref ht) = header_text {
663                    if let Some(partner_part) = ht.rsplit(" - ").next() {
664                        line.assignment = Some(partner_part.to_string());
665                    }
666                }
667            }
668        }
669    }
670
671    /// Set auxiliary account fields on AP/AR lines when FEC population is enabled.
672    ///
673    /// Only sets the fields if `populate_fec_fields` is true and the line's
674    /// GL account matches the configured AP or AR control account.
675    ///
676    /// When an auxiliary account lookup is available, uses the framework-specific
677    /// auxiliary GL account (e.g., PCG "4010001", SKR04 "33000001") instead of
678    /// the raw partner ID.
679    fn set_auxiliary_fields(
680        &self,
681        line: &mut JournalEntryLine,
682        partner_id: &str,
683        partner_label: &str,
684    ) {
685        if !self.config.populate_fec_fields {
686            return;
687        }
688        if line.gl_account == self.config.ap_account || line.gl_account == self.config.ar_account {
689            // Prefer the framework-specific auxiliary GL account from the lookup map;
690            // fall back to the raw partner ID if not found.
691            let aux_account = self
692                .auxiliary_account_lookup
693                .get(partner_id)
694                .cloned()
695                .unwrap_or_else(|| partner_id.to_string());
696            line.auxiliary_account_number = Some(aux_account);
697            line.auxiliary_account_label = Some(partner_label.to_string());
698        }
699    }
700
701    /// Apply lettrage (matching) codes to all AP/AR lines in a set of entries.
702    ///
703    /// Only sets lettrage if `populate_fec_fields` is true. The lettrage code
704    /// is derived from the chain ID (e.g. PO or SO document ID) and the date
705    /// is typically the final payment's posting date.
706    fn apply_lettrage(
707        &self,
708        entries: &mut [JournalEntry],
709        chain_id: &str,
710        lettrage_date: NaiveDate,
711    ) {
712        if !self.config.populate_fec_fields {
713            return;
714        }
715        let code = format!("LTR-{}", &chain_id[..chain_id.len().min(8)]);
716        for entry in entries.iter_mut() {
717            for line in entry.lines.iter_mut() {
718                if line.gl_account == self.config.ap_account
719                    || line.gl_account == self.config.ar_account
720                {
721                    line.lettrage = Some(code.clone());
722                    line.lettrage_date = Some(lettrage_date);
723                }
724            }
725        }
726    }
727
728    /// **v5.8.0** — wire line-level `predecessor_line_id` pointers along
729    /// a chain of JEs.
730    ///
731    /// For each adjacent pair `(prev, curr)` in `entries`, scan every
732    /// line in `curr` and look for a line in `prev` with the same
733    /// `gl_account`. When found, set `curr_line.predecessor_line_id =
734    /// derive_transaction_id(prev_line)`.
735    ///
736    /// This corresponds to the natural P2P / O2C booking flow:
737    ///
738    /// - **P2P**: GR (DR Inventory / CR GR/IR) → Invoice (DR GR/IR /
739    ///   CR AP) → Payment (DR AP / CR Cash). The GR/IR-credit line of
740    ///   the GR JE is the predecessor of the GR/IR-debit line of the
741    ///   Invoice JE; the AP-credit line of the Invoice JE is the
742    ///   predecessor of the AP-debit line of the Payment JE.
743    /// - **O2C**: Delivery (DR COGS / CR Inventory) → Invoice (DR AR /
744    ///   CR Revenue) → Receipt (DR Cash / CR AR). AR-credit on Invoice
745    ///   is predecessor of AR-debit on Receipt.
746    ///
747    /// Position-by-`gl_account` matching is intentionally simple and
748    /// unambiguous on the canonical chain shapes; ties (multiple lines
749    /// of the same gl_account in `prev`) match to the first occurrence
750    /// — deterministic but lossy on multi-position chains. Adequate
751    /// for the v5.8.0 MVP edge-list export; a strict 1-to-1 matcher is
752    /// future work.
753    fn wire_predecessor_chain(entries: &mut [JournalEntry]) {
754        if entries.len() < 2 {
755            return;
756        }
757        for i in 1..entries.len() {
758            // Snapshot prev's lines as (gl_account, transaction_id) pairs
759            // so we can mutate `entries[i]` without borrow conflict.
760            let prev_lines: Vec<(String, String)> = entries[i - 1]
761                .lines
762                .iter()
763                .map(|l| {
764                    let tx_id = l.transaction_id.clone().unwrap_or_else(|| {
765                        datasynth_core::models::JournalEntryLine::derive_transaction_id(
766                            l.document_id,
767                            l.line_number,
768                        )
769                    });
770                    (l.gl_account.clone(), tx_id)
771                })
772                .collect();
773
774            for line in entries[i].lines.iter_mut() {
775                if line.predecessor_line_id.is_some() {
776                    continue;
777                }
778                if let Some((_, tx_id)) =
779                    prev_lines.iter().find(|(acct, _)| acct == &line.gl_account)
780                {
781                    line.predecessor_line_id = Some(tx_id.clone());
782                }
783            }
784        }
785    }
786
787    /// Generate all JEs from a P2P document chain.
788    pub fn generate_from_p2p_chain(&mut self, chain: &P2PDocumentChain) -> Vec<JournalEntry> {
789        let mut entries = Vec::new();
790
791        // Generate JEs for goods receipts
792        for gr in &chain.goods_receipts {
793            if let Some(je) = self.generate_from_goods_receipt(gr) {
794                entries.push(je);
795            }
796        }
797
798        // Generate JE for vendor invoice
799        if let Some(ref invoice) = chain.vendor_invoice {
800            if let Some(je) = self.generate_from_vendor_invoice(invoice) {
801                entries.push(je);
802            }
803        }
804
805        // Generate JE for payment
806        if let Some(ref payment) = chain.payment {
807            if let Some(je) = self.generate_from_ap_payment(payment) {
808                entries.push(je);
809            }
810        }
811
812        // Generate JEs for remainder payments
813        for payment in &chain.remainder_payments {
814            if let Some(je) = self.generate_from_ap_payment(payment) {
815                entries.push(je);
816            }
817        }
818
819        // Apply lettrage on complete P2P chains (invoice + payment both present)
820        if self.config.populate_fec_fields && chain.is_complete {
821            if let Some(ref payment) = chain.payment {
822                let posting_date = payment
823                    .header
824                    .posting_date
825                    .unwrap_or(payment.header.document_date);
826                self.apply_lettrage(
827                    &mut entries,
828                    &chain.purchase_order.header.document_id,
829                    posting_date,
830                );
831            }
832        }
833
834        // v5.8.0 — wire line-level predecessor pointers along the chain so
835        // graphs/je_network.csv can trace booking chains across JEs.
836        Self::wire_predecessor_chain(&mut entries);
837
838        entries
839    }
840
841    /// Generate all JEs from an O2C document chain.
842    pub fn generate_from_o2c_chain(&mut self, chain: &O2CDocumentChain) -> Vec<JournalEntry> {
843        let mut entries = Vec::new();
844
845        // Generate JEs for deliveries
846        for delivery in &chain.deliveries {
847            if let Some(je) = self.generate_from_delivery(delivery) {
848                entries.push(je);
849            }
850        }
851
852        // Generate JE for customer invoice
853        if let Some(ref invoice) = chain.customer_invoice {
854            if let Some(je) = self.generate_from_customer_invoice(invoice) {
855                entries.push(je);
856            }
857        }
858
859        // Generate JE for customer receipt
860        if let Some(ref receipt) = chain.customer_receipt {
861            if let Some(je) = self.generate_from_ar_receipt(receipt) {
862                entries.push(je);
863            }
864        }
865
866        // Generate JEs for remainder receipts (follow-up to partial payments)
867        for receipt in &chain.remainder_receipts {
868            if let Some(je) = self.generate_from_ar_receipt(receipt) {
869                entries.push(je);
870            }
871        }
872
873        // Apply lettrage on complete O2C chains (invoice + receipt both present)
874        if self.config.populate_fec_fields && chain.customer_receipt.is_some() {
875            if let Some(ref receipt) = chain.customer_receipt {
876                let posting_date = receipt
877                    .header
878                    .posting_date
879                    .unwrap_or(receipt.header.document_date);
880                self.apply_lettrage(
881                    &mut entries,
882                    &chain.sales_order.header.document_id,
883                    posting_date,
884                );
885            }
886        }
887
888        // v5.8.0 — wire line-level predecessor pointers along the chain.
889        Self::wire_predecessor_chain(&mut entries);
890
891        entries
892    }
893
894    /// Generate JE from Goods Receipt.
895    /// DR Inventory, CR GR/IR Clearing
896    pub fn generate_from_goods_receipt(&mut self, gr: &GoodsReceipt) -> Option<JournalEntry> {
897        if gr.items.is_empty() {
898            return None;
899        }
900
901        let document_id = self.uuid_factory.next();
902
903        // Use the total_value from the GR, or calculate from line items
904        let total_amount = if gr.total_value > Decimal::ZERO {
905            gr.total_value
906        } else {
907            gr.items
908                .iter()
909                .map(|item| item.base.net_amount)
910                .sum::<Decimal>()
911        };
912
913        if total_amount == Decimal::ZERO {
914            return None;
915        }
916
917        // Use posting_date or fall back to document_date
918        let posting_date = gr.header.posting_date.unwrap_or(gr.header.document_date);
919
920        let mut header = JournalEntryHeader::with_deterministic_id(
921            gr.header.company_code.clone(),
922            posting_date,
923            document_id,
924        );
925        header.source = TransactionSource::Automated;
926        header.business_process = Some(BusinessProcess::P2P);
927        header.document_type = "WE".to_string();
928        header.reference = Some(format!("GR:{}", gr.header.document_id));
929        header.source_document = Some(DocumentRef::GoodsReceipt(gr.header.document_id.clone()));
930        header.header_text = Some(format!(
931            "Goods Receipt {} - {}",
932            gr.header.document_id,
933            gr.vendor_id.as_deref().unwrap_or("Unknown")
934        ));
935
936        let mut entry = JournalEntry::new(header);
937
938        // DR Inventory
939        let debit_line = JournalEntryLine::debit(
940            entry.header.document_id,
941            1,
942            self.config.inventory_account.clone(),
943            total_amount,
944        );
945        entry.add_line(debit_line);
946
947        // CR GR/IR Clearing — vendor-linked; populate trading_partner when known
948        let mut credit_line = JournalEntryLine::credit(
949            entry.header.document_id,
950            2,
951            self.config.gr_ir_clearing_account.clone(),
952            total_amount,
953        );
954        credit_line.trading_partner = gr.vendor_id.clone();
955        entry.add_line(credit_line);
956
957        self.enrich_line_items(&mut entry);
958        self.split_je_expense_lines(&mut entry);
959        Some(entry)
960    }
961
962    /// Generate JE from Vendor Invoice.
963    ///
964    /// **Canonical three-way-match path** (priors disabled, or direct-expense
965    /// draw fails):
966    /// - DR GR/IR Clearing = net amount  (or payable when no VAT)
967    /// - DR Input VAT      = tax amount  (only when VAT > 0)
968    /// - CR AP              = gross (payable) amount
969    ///
970    /// **SP3.13 W1 — Direct-expense path** (priors enabled, random draw <
971    /// `direct_expense_share`; ~70 % of real KR invoices are services /
972    /// utilities / travel that bypass GR/IR):
973    /// - DR <expense GL>   = net amount  (or payable when no VAT)
974    /// - DR Input VAT      = tax amount  (only when VAT > 0)
975    /// - CR AP              = gross (payable) amount
976    ///
977    /// The expense GL is drawn from the per-source "KR" conditional in
978    /// `loaded_priors`, enabling W1.5 `split_je_expense_lines` to split it
979    /// into multiple cost-account lines matching the corpus shape.
980    pub fn generate_from_vendor_invoice(
981        &mut self,
982        invoice: &VendorInvoice,
983    ) -> Option<JournalEntry> {
984        if invoice.payable_amount == Decimal::ZERO {
985            return None;
986        }
987
988        let document_id = self.uuid_factory.next();
989
990        // Use posting_date or fall back to document_date
991        let posting_date = invoice
992            .header
993            .posting_date
994            .unwrap_or(invoice.header.document_date);
995
996        let mut header = JournalEntryHeader::with_deterministic_id(
997            invoice.header.company_code.clone(),
998            posting_date,
999            document_id,
1000        );
1001        header.source = TransactionSource::Automated;
1002        header.business_process = Some(BusinessProcess::P2P);
1003        header.document_type = "KR".to_string();
1004        header.reference = Some(format!("VI:{}", invoice.header.document_id));
1005        header.source_document = Some(DocumentRef::VendorInvoice(
1006            invoice.header.document_id.clone(),
1007        ));
1008        header.header_text = Some(format!(
1009            "Vendor Invoice {} - {}",
1010            invoice.vendor_invoice_number, invoice.vendor_id
1011        ));
1012
1013        let mut entry = JournalEntry::new(header);
1014
1015        let has_vat = invoice.tax_amount > Decimal::ZERO;
1016        // Net debit amount: if VAT present use net_amount so that
1017        //   DR net + DR VAT = CR gross (payable_amount).
1018        let debit_net_amount = if has_vat {
1019            invoice.net_amount
1020        } else {
1021            invoice.payable_amount
1022        };
1023
1024        // SP3.13 W1 — Decide whether to post directly to an expense account
1025        // (bypassing GR/IR Clearing).  Gated on `loaded_priors` being set so
1026        // the priors-disabled path is byte-identical to the pre-SP3.13 behaviour.
1027        let direct_expense_share = self.config.direct_expense_share.clamp(0.0, 1.0);
1028        let use_direct_expense = self.loaded_priors.is_some()
1029            && self.rng.random_range(0.0_f64..1.0_f64) < direct_expense_share;
1030
1031        if use_direct_expense {
1032            // Direct-expense path: DR <expense GL> / optional DR VAT / CR AP.
1033            // The single expense DR will be further split by W1.5
1034            // `split_je_expense_lines` when the prior has enough distinct GLs.
1035            // SP4.6 — role-aware GL selection: KR DR lines should draw expense
1036            // accounts (5/6xxx), not AP accounts.  Try (KR, DR) first, then the
1037            // source-marginal, then hard-code to a generic expense GL.
1038            let expense_gl = self
1039                .loaded_priors
1040                .as_ref()
1041                .and_then(|p| p.sample_gl_for_source_role("KR", "DR", &mut self.rng))
1042                .or_else(|| {
1043                    self.loaded_priors.as_ref().and_then(|p| {
1044                        p.sample_attribute_for_source("KR", "gl_account", &mut self.rng)
1045                    })
1046                })
1047                // Fall back to a generic operating expense GL when the
1048                // conditional draw fails (sparse prior or no "KR" entry).
1049                .unwrap_or_else(|| "6000".to_string());
1050
1051            // DR Expense (net amount, or payable when no VAT)
1052            let debit_expense =
1053                JournalEntryLine::debit(entry.header.document_id, 1, expense_gl, debit_net_amount);
1054            entry.add_line(debit_expense);
1055
1056            // DR Input VAT (only when tax is non-zero)
1057            if has_vat {
1058                let vat_line = JournalEntryLine::debit(
1059                    entry.header.document_id,
1060                    2,
1061                    self.config.vat_input_account.clone(),
1062                    invoice.tax_amount,
1063                );
1064                entry.add_line(vat_line);
1065            }
1066
1067            // CR Accounts Payable (gross / payable amount)
1068            let mut credit_ap = JournalEntryLine::credit(
1069                entry.header.document_id,
1070                if has_vat { 3 } else { 2 },
1071                self.config.ap_account.clone(),
1072                invoice.payable_amount,
1073            );
1074            self.set_auxiliary_fields(&mut credit_ap, &invoice.vendor_id, &invoice.vendor_id);
1075            credit_ap.trading_partner = Some(invoice.vendor_id.clone());
1076            entry.add_line(credit_ap);
1077        } else {
1078            // Canonical three-way-match path (existing behaviour, unchanged).
1079            // DR GR/IR Clearing (net amount when VAT present, else payable)
1080            let debit_clearing = JournalEntryLine::debit(
1081                entry.header.document_id,
1082                1,
1083                self.config.gr_ir_clearing_account.clone(),
1084                debit_net_amount,
1085            );
1086            entry.add_line(debit_clearing);
1087
1088            // DR Input VAT (only when tax is non-zero)
1089            if has_vat {
1090                let vat_line = JournalEntryLine::debit(
1091                    entry.header.document_id,
1092                    2,
1093                    self.config.vat_input_account.clone(),
1094                    invoice.tax_amount,
1095                );
1096                entry.add_line(vat_line);
1097            }
1098
1099            // CR Accounts Payable (gross / payable amount)
1100            let mut credit_line = JournalEntryLine::credit(
1101                entry.header.document_id,
1102                if has_vat { 3 } else { 2 },
1103                self.config.ap_account.clone(),
1104                invoice.payable_amount,
1105            );
1106            self.set_auxiliary_fields(&mut credit_line, &invoice.vendor_id, &invoice.vendor_id);
1107            credit_line.trading_partner = Some(invoice.vendor_id.clone());
1108            entry.add_line(credit_line);
1109        }
1110
1111        self.enrich_line_items(&mut entry);
1112        self.split_je_expense_lines(&mut entry);
1113        Some(entry)
1114    }
1115
1116    /// Generate JE from AP Payment.
1117    /// DR AP, CR Cash
1118    pub fn generate_from_ap_payment(&mut self, payment: &Payment) -> Option<JournalEntry> {
1119        if payment.amount == Decimal::ZERO {
1120            return None;
1121        }
1122
1123        let document_id = self.uuid_factory.next();
1124
1125        // Use posting_date or fall back to document_date
1126        let posting_date = payment
1127            .header
1128            .posting_date
1129            .unwrap_or(payment.header.document_date);
1130
1131        let mut header = JournalEntryHeader::with_deterministic_id(
1132            payment.header.company_code.clone(),
1133            posting_date,
1134            document_id,
1135        );
1136        header.source = TransactionSource::Automated;
1137        header.business_process = Some(BusinessProcess::P2P);
1138        header.document_type = "KZ".to_string();
1139        header.reference = Some(format!("PAY:{}", payment.header.document_id));
1140        header.source_document = Some(DocumentRef::Payment(payment.header.document_id.clone()));
1141        header.header_text = Some(format!(
1142            "Payment {} - {}",
1143            payment.header.document_id, payment.business_partner_id
1144        ));
1145
1146        let mut entry = JournalEntry::new(header);
1147
1148        // DR Accounts Payable — vendor-linked; populate trading_partner
1149        let mut debit_line = JournalEntryLine::debit(
1150            entry.header.document_id,
1151            1,
1152            self.config.ap_account.clone(),
1153            payment.amount,
1154        );
1155        self.set_auxiliary_fields(
1156            &mut debit_line,
1157            &payment.business_partner_id,
1158            &payment.business_partner_id,
1159        );
1160        debit_line.trading_partner = Some(payment.business_partner_id.clone());
1161        entry.add_line(debit_line);
1162
1163        // CR Cash/Bank
1164        let credit_line = JournalEntryLine::credit(
1165            entry.header.document_id,
1166            2,
1167            self.config.cash_account.clone(),
1168            payment.amount,
1169        );
1170        entry.add_line(credit_line);
1171
1172        self.enrich_line_items(&mut entry);
1173        self.split_je_expense_lines(&mut entry);
1174        Some(entry)
1175    }
1176
1177    /// Generate JE from Delivery.
1178    /// DR COGS, CR Inventory
1179    pub fn generate_from_delivery(&mut self, delivery: &Delivery) -> Option<JournalEntry> {
1180        if delivery.items.is_empty() {
1181            return None;
1182        }
1183
1184        let document_id = self.uuid_factory.next();
1185
1186        // Calculate total cost from line items
1187        let total_cost = delivery
1188            .items
1189            .iter()
1190            .map(|item| item.base.net_amount)
1191            .sum::<Decimal>();
1192
1193        if total_cost == Decimal::ZERO {
1194            return None;
1195        }
1196
1197        // Use posting_date or fall back to document_date
1198        let posting_date = delivery
1199            .header
1200            .posting_date
1201            .unwrap_or(delivery.header.document_date);
1202
1203        let mut header = JournalEntryHeader::with_deterministic_id(
1204            delivery.header.company_code.clone(),
1205            posting_date,
1206            document_id,
1207        );
1208        header.source = TransactionSource::Automated;
1209        header.business_process = Some(BusinessProcess::O2C);
1210        header.document_type = "WL".to_string();
1211        header.reference = Some(format!("DEL:{}", delivery.header.document_id));
1212        header.source_document = Some(DocumentRef::Delivery(delivery.header.document_id.clone()));
1213        header.header_text = Some(format!(
1214            "Delivery {} - {}",
1215            delivery.header.document_id, delivery.customer_id
1216        ));
1217
1218        let mut entry = JournalEntry::new(header);
1219
1220        // DR COGS
1221        let debit_line = JournalEntryLine::debit(
1222            entry.header.document_id,
1223            1,
1224            self.config.cogs_account.clone(),
1225            total_cost,
1226        );
1227        entry.add_line(debit_line);
1228
1229        // CR Inventory
1230        let credit_line = JournalEntryLine::credit(
1231            entry.header.document_id,
1232            2,
1233            self.config.inventory_account.clone(),
1234            total_cost,
1235        );
1236        entry.add_line(credit_line);
1237
1238        self.enrich_line_items(&mut entry);
1239        self.split_je_expense_lines(&mut entry);
1240        Some(entry)
1241    }
1242
1243    /// Generate JE from Customer Invoice.
1244    ///
1245    /// When the invoice carries tax (`total_tax_amount > 0`), the entry is split:
1246    /// - DR AR          = gross amount
1247    /// - CR Revenue     = net amount
1248    /// - CR VAT Payable = tax amount
1249    ///
1250    /// When there is no tax, the original two-line entry is produced:
1251    /// - DR AR      = gross amount
1252    /// - CR Revenue = gross amount
1253    pub fn generate_from_customer_invoice(
1254        &mut self,
1255        invoice: &CustomerInvoice,
1256    ) -> Option<JournalEntry> {
1257        if invoice.total_gross_amount == Decimal::ZERO {
1258            return None;
1259        }
1260
1261        let document_id = self.uuid_factory.next();
1262
1263        // Use posting_date or fall back to document_date
1264        let posting_date = invoice
1265            .header
1266            .posting_date
1267            .unwrap_or(invoice.header.document_date);
1268
1269        let mut header = JournalEntryHeader::with_deterministic_id(
1270            invoice.header.company_code.clone(),
1271            posting_date,
1272            document_id,
1273        );
1274        header.source = TransactionSource::Automated;
1275        header.business_process = Some(BusinessProcess::O2C);
1276        header.document_type = "DR".to_string();
1277        header.reference = Some(format!("CI:{}", invoice.header.document_id));
1278        header.source_document = Some(DocumentRef::CustomerInvoice(
1279            invoice.header.document_id.clone(),
1280        ));
1281        header.header_text = Some(format!(
1282            "Customer Invoice {} - {}",
1283            invoice.header.document_id, invoice.customer_id
1284        ));
1285
1286        let mut entry = JournalEntry::new(header);
1287
1288        // DR Accounts Receivable (gross amount) — customer-linked; populate trading_partner
1289        let mut debit_line = JournalEntryLine::debit(
1290            entry.header.document_id,
1291            1,
1292            self.config.ar_account.clone(),
1293            invoice.total_gross_amount,
1294        );
1295        self.set_auxiliary_fields(&mut debit_line, &invoice.customer_id, &invoice.customer_id);
1296        debit_line.trading_partner = Some(invoice.customer_id.clone());
1297        entry.add_line(debit_line);
1298
1299        // CR Revenue (net amount when VAT present, else gross)
1300        let revenue_amount = if invoice.total_tax_amount > Decimal::ZERO {
1301            invoice.total_net_amount
1302        } else {
1303            invoice.total_gross_amount
1304        };
1305        let credit_line = JournalEntryLine::credit(
1306            entry.header.document_id,
1307            2,
1308            self.config.revenue_account.clone(),
1309            revenue_amount,
1310        );
1311        entry.add_line(credit_line);
1312
1313        // CR VAT Payable (only when tax is non-zero)
1314        if invoice.total_tax_amount > Decimal::ZERO {
1315            let vat_line = JournalEntryLine::credit(
1316                entry.header.document_id,
1317                3,
1318                self.config.vat_output_account.clone(),
1319                invoice.total_tax_amount,
1320            );
1321            entry.add_line(vat_line);
1322        }
1323
1324        self.enrich_line_items(&mut entry);
1325        self.split_je_expense_lines(&mut entry);
1326        Some(entry)
1327    }
1328
1329    /// Generate JE from AR Receipt (Customer Payment).
1330    /// DR Cash, CR AR
1331    pub fn generate_from_ar_receipt(&mut self, payment: &Payment) -> Option<JournalEntry> {
1332        if payment.amount == Decimal::ZERO {
1333            return None;
1334        }
1335
1336        let document_id = self.uuid_factory.next();
1337
1338        // Use posting_date or fall back to document_date
1339        let posting_date = payment
1340            .header
1341            .posting_date
1342            .unwrap_or(payment.header.document_date);
1343
1344        let mut header = JournalEntryHeader::with_deterministic_id(
1345            payment.header.company_code.clone(),
1346            posting_date,
1347            document_id,
1348        );
1349        header.source = TransactionSource::Automated;
1350        header.business_process = Some(BusinessProcess::O2C);
1351        header.document_type = "DZ".to_string();
1352        header.reference = Some(format!("RCP:{}", payment.header.document_id));
1353        header.source_document = Some(DocumentRef::Receipt(payment.header.document_id.clone()));
1354        header.header_text = Some(format!(
1355            "Customer Receipt {} - {}",
1356            payment.header.document_id, payment.business_partner_id
1357        ));
1358
1359        let mut entry = JournalEntry::new(header);
1360
1361        // DR Cash/Bank
1362        let debit_line = JournalEntryLine::debit(
1363            entry.header.document_id,
1364            1,
1365            self.config.cash_account.clone(),
1366            payment.amount,
1367        );
1368        entry.add_line(debit_line);
1369
1370        // CR Accounts Receivable — customer-linked; populate trading_partner
1371        let mut credit_line = JournalEntryLine::credit(
1372            entry.header.document_id,
1373            2,
1374            self.config.ar_account.clone(),
1375            payment.amount,
1376        );
1377        self.set_auxiliary_fields(
1378            &mut credit_line,
1379            &payment.business_partner_id,
1380            &payment.business_partner_id,
1381        );
1382        credit_line.trading_partner = Some(payment.business_partner_id.clone());
1383        entry.add_line(credit_line);
1384
1385        self.enrich_line_items(&mut entry);
1386        self.split_je_expense_lines(&mut entry);
1387        Some(entry)
1388    }
1389}
1390
1391impl Default for DocumentFlowJeGenerator {
1392    fn default() -> Self {
1393        Self::new()
1394    }
1395}
1396
1397#[cfg(test)]
1398mod tests {
1399    use super::*;
1400    use chrono::NaiveDate;
1401    use datasynth_core::models::documents::{GoodsReceiptItem, MovementType};
1402
1403    fn create_test_gr() -> GoodsReceipt {
1404        let mut gr = GoodsReceipt::from_purchase_order(
1405            "GR-001".to_string(),
1406            "1000",
1407            "PO-001",
1408            "V-001",
1409            "P1000",
1410            "0001",
1411            2024,
1412            1,
1413            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1414            "JSMITH",
1415        );
1416
1417        let item = GoodsReceiptItem::from_po(
1418            10,
1419            "Test Material",
1420            Decimal::from(100),
1421            Decimal::from(50),
1422            "PO-001",
1423            10,
1424        )
1425        .with_movement_type(MovementType::GrForPo);
1426
1427        gr.add_item(item);
1428        gr.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1429
1430        gr
1431    }
1432
1433    fn create_test_vendor_invoice() -> VendorInvoice {
1434        use datasynth_core::models::documents::VendorInvoiceItem;
1435
1436        let mut invoice = VendorInvoice::new(
1437            "VI-001".to_string(),
1438            "1000",
1439            "V-001",
1440            "INV-12345".to_string(),
1441            2024,
1442            1,
1443            NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
1444            "JSMITH",
1445        );
1446
1447        let item = VendorInvoiceItem::from_po_gr(
1448            10,
1449            "Test Material",
1450            Decimal::from(100),
1451            Decimal::from(50),
1452            "PO-001",
1453            10,
1454            Some("GR-001".to_string()),
1455            Some(10),
1456        );
1457
1458        invoice.add_item(item);
1459        invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
1460
1461        invoice
1462    }
1463
1464    fn create_test_payment() -> Payment {
1465        let mut payment = Payment::new_ap_payment(
1466            "PAY-001".to_string(),
1467            "1000",
1468            "V-001",
1469            Decimal::from(5000),
1470            2024,
1471            2,
1472            NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
1473            "JSMITH",
1474        );
1475
1476        payment.post("JSMITH", NaiveDate::from_ymd_opt(2024, 2, 15).unwrap());
1477
1478        payment
1479    }
1480
1481    #[test]
1482    fn test_generate_from_goods_receipt() {
1483        let mut generator = DocumentFlowJeGenerator::new();
1484        let gr = create_test_gr();
1485
1486        let je = generator.generate_from_goods_receipt(&gr);
1487
1488        assert!(je.is_some());
1489        let je = je.unwrap();
1490
1491        // Should be balanced
1492        assert!(je.is_balanced());
1493
1494        // Should have 2 lines
1495        assert_eq!(je.line_count(), 2);
1496
1497        // DR should be inventory, CR should be GR/IR
1498        assert!(je.total_debit() > Decimal::ZERO);
1499        assert_eq!(je.total_debit(), je.total_credit());
1500
1501        // Should reference source document
1502        assert!(je.header.reference.is_some());
1503        assert!(je.header.reference.as_ref().unwrap().contains("GR:"));
1504
1505        // T2-D Lever 1b: the `source` column carries the SAP doc-type code
1506        // (= document_type "WE"), not the coarse TransactionSource enum.
1507        assert_eq!(je.header.sap_source_code.as_deref(), Some("WE"));
1508        assert_eq!(je.header.document_type, "WE");
1509    }
1510
1511    #[test]
1512    fn test_generate_from_vendor_invoice() {
1513        let mut generator = DocumentFlowJeGenerator::new();
1514        let invoice = create_test_vendor_invoice();
1515
1516        let je = generator.generate_from_vendor_invoice(&invoice);
1517
1518        assert!(je.is_some());
1519        let je = je.unwrap();
1520
1521        assert!(je.is_balanced());
1522        assert_eq!(je.line_count(), 2);
1523        assert!(je.header.reference.as_ref().unwrap().contains("VI:"));
1524    }
1525
1526    #[test]
1527    fn test_generate_from_ap_payment() {
1528        let mut generator = DocumentFlowJeGenerator::new();
1529        let payment = create_test_payment();
1530
1531        let je = generator.generate_from_ap_payment(&payment);
1532
1533        assert!(je.is_some());
1534        let je = je.unwrap();
1535
1536        assert!(je.is_balanced());
1537        assert_eq!(je.line_count(), 2);
1538        assert!(je.header.reference.as_ref().unwrap().contains("PAY:"));
1539    }
1540
1541    #[test]
1542    fn test_all_entries_are_balanced() {
1543        let mut generator = DocumentFlowJeGenerator::new();
1544
1545        let gr = create_test_gr();
1546        let invoice = create_test_vendor_invoice();
1547        let payment = create_test_payment();
1548
1549        let entries = vec![
1550            generator.generate_from_goods_receipt(&gr),
1551            generator.generate_from_vendor_invoice(&invoice),
1552            generator.generate_from_ap_payment(&payment),
1553        ];
1554
1555        for entry in entries.into_iter().flatten() {
1556            assert!(
1557                entry.is_balanced(),
1558                "Entry {} is not balanced",
1559                entry.header.document_id
1560            );
1561        }
1562    }
1563
1564    // ====================================================================
1565    // FEC compliance tests
1566    // ====================================================================
1567
1568    #[test]
1569    fn test_french_gaap_auxiliary_on_ap_ar_lines_only() {
1570        // French GAAP config sets auxiliary fields on AP/AR lines only
1571        let mut generator =
1572            DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1573
1574        // Vendor invoice: AP line should have auxiliary, GR/IR line should not
1575        let invoice = create_test_vendor_invoice();
1576        let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1577
1578        // Line 1 = DR GR/IR Clearing → no auxiliary
1579        assert!(
1580            je.lines[0].auxiliary_account_number.is_none(),
1581            "GR/IR clearing line should not have auxiliary"
1582        );
1583
1584        // Line 2 = CR AP → has auxiliary
1585        assert_eq!(
1586            je.lines[1].auxiliary_account_number.as_deref(),
1587            Some("V-001"),
1588            "AP line should have vendor ID as auxiliary"
1589        );
1590        assert_eq!(
1591            je.lines[1].auxiliary_account_label.as_deref(),
1592            Some("V-001"),
1593        );
1594    }
1595
1596    #[test]
1597    fn test_french_gaap_lettrage_on_complete_p2p_chain() {
1598        use datasynth_core::models::documents::PurchaseOrder;
1599
1600        let mut generator =
1601            DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1602
1603        let po = PurchaseOrder::new(
1604            "PO-001",
1605            "1000",
1606            "V-001",
1607            2024,
1608            1,
1609            NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(),
1610            "JSMITH",
1611        );
1612
1613        let chain = P2PDocumentChain {
1614            purchase_order: po,
1615            goods_receipts: vec![create_test_gr()],
1616            vendor_invoice: Some(create_test_vendor_invoice()),
1617            payment: Some(create_test_payment()),
1618            remainder_payments: Vec::new(),
1619            is_complete: true,
1620            three_way_match_passed: true,
1621            payment_timing: None,
1622        };
1623
1624        let entries = generator.generate_from_p2p_chain(&chain);
1625        assert!(!entries.is_empty());
1626
1627        // All AP lines should share the same lettrage code
1628        let ap_account = &generator.config.ap_account;
1629        let mut lettrage_codes: Vec<&str> = Vec::new();
1630        for entry in &entries {
1631            for line in &entry.lines {
1632                if &line.gl_account == ap_account {
1633                    assert!(
1634                        line.lettrage.is_some(),
1635                        "AP line should have lettrage on complete chain"
1636                    );
1637                    assert!(line.lettrage_date.is_some());
1638                    lettrage_codes.push(line.lettrage.as_deref().unwrap());
1639                } else {
1640                    assert!(
1641                        line.lettrage.is_none(),
1642                        "Non-AP line should not have lettrage"
1643                    );
1644                }
1645            }
1646        }
1647
1648        // All AP lettrage codes should be the same
1649        assert!(!lettrage_codes.is_empty());
1650        assert!(
1651            lettrage_codes.iter().all(|c| *c == lettrage_codes[0]),
1652            "All AP lines should share the same lettrage code"
1653        );
1654        assert!(lettrage_codes[0].starts_with("LTR-"));
1655    }
1656
1657    #[test]
1658    fn test_incomplete_chain_has_no_lettrage() {
1659        use datasynth_core::models::documents::PurchaseOrder;
1660
1661        let mut generator =
1662            DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1663
1664        let po = PurchaseOrder::new(
1665            "PO-002",
1666            "1000",
1667            "V-001",
1668            2024,
1669            1,
1670            NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(),
1671            "JSMITH",
1672        );
1673
1674        // Incomplete chain: no payment
1675        let chain = P2PDocumentChain {
1676            purchase_order: po,
1677            goods_receipts: vec![create_test_gr()],
1678            vendor_invoice: Some(create_test_vendor_invoice()),
1679            payment: None,
1680            remainder_payments: Vec::new(),
1681            is_complete: false,
1682            three_way_match_passed: false,
1683            payment_timing: None,
1684        };
1685
1686        let entries = generator.generate_from_p2p_chain(&chain);
1687
1688        for entry in &entries {
1689            for line in &entry.lines {
1690                assert!(
1691                    line.lettrage.is_none(),
1692                    "Incomplete chain should have no lettrage"
1693                );
1694            }
1695        }
1696    }
1697
1698    #[test]
1699    fn test_default_config_no_fec_fields() {
1700        // Default config (non-French) should leave all FEC fields as None
1701        let mut generator = DocumentFlowJeGenerator::new();
1702
1703        let invoice = create_test_vendor_invoice();
1704        let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1705
1706        for line in &je.lines {
1707            assert!(line.auxiliary_account_number.is_none());
1708            assert!(line.auxiliary_account_label.is_none());
1709            assert!(line.lettrage.is_none());
1710            assert!(line.lettrage_date.is_none());
1711        }
1712    }
1713
1714    #[test]
1715    fn test_auxiliary_lookup_uses_gl_account_instead_of_partner_id() {
1716        // When auxiliary lookup is populated, FEC auxiliary_account_number should
1717        // use the framework-specific GL account instead of the raw partner ID.
1718        let mut generator =
1719            DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1720
1721        let mut lookup = HashMap::new();
1722        lookup.insert("V-001".to_string(), "4010001".to_string());
1723        generator.set_auxiliary_account_lookup(lookup);
1724
1725        let invoice = create_test_vendor_invoice();
1726        let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1727
1728        // AP line should use the auxiliary GL account from lookup, not "V-001"
1729        assert_eq!(
1730            je.lines[1].auxiliary_account_number.as_deref(),
1731            Some("4010001"),
1732            "AP line should use auxiliary GL account from lookup"
1733        );
1734        // Label should still be the partner ID (human-readable)
1735        assert_eq!(
1736            je.lines[1].auxiliary_account_label.as_deref(),
1737            Some("V-001"),
1738        );
1739    }
1740
1741    #[test]
1742    fn test_auxiliary_lookup_fallback_to_partner_id() {
1743        // When the auxiliary lookup exists but doesn't contain the partner,
1744        // should fall back to raw partner ID.
1745        let mut generator =
1746            DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1747
1748        // Lookup has a different vendor, not V-001
1749        let mut lookup = HashMap::new();
1750        lookup.insert("V-999".to_string(), "4019999".to_string());
1751        generator.set_auxiliary_account_lookup(lookup);
1752
1753        let invoice = create_test_vendor_invoice();
1754        let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1755
1756        // V-001 not in lookup, so should fall back to raw partner ID
1757        assert_eq!(
1758            je.lines[1].auxiliary_account_number.as_deref(),
1759            Some("V-001"),
1760            "Should fall back to partner ID when not in lookup"
1761        );
1762    }
1763
1764    #[test]
1765    fn test_auxiliary_lookup_works_for_customer_receipt() {
1766        // Verify the lookup also works for O2C AR receipt lines.
1767        let mut generator =
1768            DocumentFlowJeGenerator::with_config_and_seed(DocumentFlowJeConfig::french_gaap(), 42);
1769
1770        let mut lookup = HashMap::new();
1771        lookup.insert("C-001".to_string(), "4110001".to_string());
1772        generator.set_auxiliary_account_lookup(lookup);
1773
1774        let mut receipt = Payment::new_ar_receipt(
1775            "RCP-001".to_string(),
1776            "1000",
1777            "C-001",
1778            Decimal::from(3000),
1779            2024,
1780            3,
1781            NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(),
1782            "JSMITH",
1783        );
1784        receipt.post("JSMITH", NaiveDate::from_ymd_opt(2024, 3, 15).unwrap());
1785
1786        let je = generator.generate_from_ar_receipt(&receipt).unwrap();
1787
1788        // AR line (line 2 = CR AR) should use the auxiliary GL account from lookup
1789        assert_eq!(
1790            je.lines[1].auxiliary_account_number.as_deref(),
1791            Some("4110001"),
1792            "AR line should use auxiliary GL account from lookup"
1793        );
1794    }
1795
1796    // ====================================================================
1797    // VAT / tax splitting tests
1798    // ====================================================================
1799
1800    /// Helper: create a customer invoice with tax on its line items.
1801    fn create_test_customer_invoice_with_tax() -> CustomerInvoice {
1802        use datasynth_core::models::documents::CustomerInvoiceItem;
1803
1804        let mut invoice = CustomerInvoice::new(
1805            "CI-001",
1806            "1000",
1807            "C-001",
1808            2024,
1809            1,
1810            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1811            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
1812            "JSMITH",
1813        );
1814
1815        // 10 units * 100 = 1000 net, 100 tax => 1100 gross
1816        let mut item =
1817            CustomerInvoiceItem::new(1, "Product A", Decimal::from(10), Decimal::from(100));
1818        item.base.tax_amount = Decimal::from(100);
1819        invoice.add_item(item);
1820        invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1821
1822        invoice
1823    }
1824
1825    /// Helper: create a customer invoice without any tax.
1826    fn create_test_customer_invoice_no_tax() -> CustomerInvoice {
1827        use datasynth_core::models::documents::CustomerInvoiceItem;
1828
1829        let mut invoice = CustomerInvoice::new(
1830            "CI-002",
1831            "1000",
1832            "C-002",
1833            2024,
1834            1,
1835            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1836            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
1837            "JSMITH",
1838        );
1839
1840        let item = CustomerInvoiceItem::new(1, "Product B", Decimal::from(10), Decimal::from(100));
1841        invoice.add_item(item);
1842        invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
1843
1844        invoice
1845    }
1846
1847    /// Helper: create a vendor invoice with tax on its line items.
1848    fn create_test_vendor_invoice_with_tax() -> VendorInvoice {
1849        use datasynth_core::models::documents::VendorInvoiceItem;
1850
1851        let mut invoice = VendorInvoice::new(
1852            "VI-002".to_string(),
1853            "1000",
1854            "V-001",
1855            "INV-TAX-001".to_string(),
1856            2024,
1857            1,
1858            NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
1859            "JSMITH",
1860        );
1861
1862        // 100 qty * 50 price = 5000 net, 500 tax => 5500 gross = payable
1863        let item = VendorInvoiceItem::from_po_gr(
1864            10,
1865            "Test Material",
1866            Decimal::from(100),
1867            Decimal::from(50),
1868            "PO-001",
1869            10,
1870            Some("GR-001".to_string()),
1871            Some(10),
1872        )
1873        .with_tax("VAT10", Decimal::from(500));
1874
1875        invoice.add_item(item);
1876        invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 20).unwrap());
1877
1878        invoice
1879    }
1880
1881    #[test]
1882    fn test_customer_invoice_with_tax_produces_three_lines() {
1883        let mut generator = DocumentFlowJeGenerator::new();
1884        let invoice = create_test_customer_invoice_with_tax();
1885
1886        assert_eq!(invoice.total_net_amount, Decimal::from(1000));
1887        assert_eq!(invoice.total_tax_amount, Decimal::from(100));
1888        assert_eq!(invoice.total_gross_amount, Decimal::from(1100));
1889
1890        let je = generator.generate_from_customer_invoice(&invoice).unwrap();
1891
1892        // Should have 3 lines: DR AR, CR Revenue, CR VAT
1893        assert_eq!(
1894            je.line_count(),
1895            3,
1896            "Expected 3 JE lines for invoice with tax"
1897        );
1898        assert!(je.is_balanced(), "Entry must be balanced");
1899
1900        // Line 1: DR AR = gross (1100)
1901        assert_eq!(je.lines[0].gl_account, control_accounts::AR_CONTROL);
1902        assert_eq!(je.lines[0].debit_amount, Decimal::from(1100));
1903        assert_eq!(je.lines[0].credit_amount, Decimal::ZERO);
1904
1905        // Line 2: CR Revenue = net (1000)
1906        assert_eq!(je.lines[1].gl_account, revenue_accounts::PRODUCT_REVENUE);
1907        assert_eq!(je.lines[1].credit_amount, Decimal::from(1000));
1908        assert_eq!(je.lines[1].debit_amount, Decimal::ZERO);
1909
1910        // Line 3: CR VAT Payable = tax (100)
1911        assert_eq!(je.lines[2].gl_account, tax_accounts::VAT_PAYABLE);
1912        assert_eq!(je.lines[2].credit_amount, Decimal::from(100));
1913        assert_eq!(je.lines[2].debit_amount, Decimal::ZERO);
1914    }
1915
1916    #[test]
1917    fn test_customer_invoice_no_tax_produces_two_lines() {
1918        let mut generator = DocumentFlowJeGenerator::new();
1919        let invoice = create_test_customer_invoice_no_tax();
1920
1921        assert_eq!(invoice.total_tax_amount, Decimal::ZERO);
1922        assert_eq!(invoice.total_net_amount, Decimal::from(1000));
1923        assert_eq!(invoice.total_gross_amount, Decimal::from(1000));
1924
1925        let je = generator.generate_from_customer_invoice(&invoice).unwrap();
1926
1927        // Should have 2 lines (no VAT line)
1928        assert_eq!(
1929            je.line_count(),
1930            2,
1931            "Expected 2 JE lines for invoice without tax"
1932        );
1933        assert!(je.is_balanced(), "Entry must be balanced");
1934
1935        // Line 1: DR AR = gross (1000)
1936        assert_eq!(je.lines[0].gl_account, control_accounts::AR_CONTROL);
1937        assert_eq!(je.lines[0].debit_amount, Decimal::from(1000));
1938
1939        // Line 2: CR Revenue = gross (1000)  — same as gross when no tax
1940        assert_eq!(je.lines[1].gl_account, revenue_accounts::PRODUCT_REVENUE);
1941        assert_eq!(je.lines[1].credit_amount, Decimal::from(1000));
1942    }
1943
1944    #[test]
1945    fn test_vendor_invoice_with_tax_produces_three_lines() {
1946        let mut generator = DocumentFlowJeGenerator::new();
1947        let invoice = create_test_vendor_invoice_with_tax();
1948
1949        assert_eq!(invoice.net_amount, Decimal::from(5000));
1950        assert_eq!(invoice.tax_amount, Decimal::from(500));
1951        assert_eq!(invoice.gross_amount, Decimal::from(5500));
1952        assert_eq!(invoice.payable_amount, Decimal::from(5500));
1953
1954        let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1955
1956        // Should have 3 lines: DR GR/IR, DR Input VAT, CR AP
1957        assert_eq!(
1958            je.line_count(),
1959            3,
1960            "Expected 3 JE lines for vendor invoice with tax"
1961        );
1962        assert!(je.is_balanced(), "Entry must be balanced");
1963
1964        // Line 1: DR GR/IR Clearing = net (5000)
1965        assert_eq!(je.lines[0].gl_account, control_accounts::GR_IR_CLEARING);
1966        assert_eq!(je.lines[0].debit_amount, Decimal::from(5000));
1967        assert_eq!(je.lines[0].credit_amount, Decimal::ZERO);
1968
1969        // Line 2: DR Input VAT = tax (500)
1970        assert_eq!(je.lines[1].gl_account, tax_accounts::INPUT_VAT);
1971        assert_eq!(je.lines[1].debit_amount, Decimal::from(500));
1972        assert_eq!(je.lines[1].credit_amount, Decimal::ZERO);
1973
1974        // Line 3: CR AP = gross (5500)
1975        assert_eq!(je.lines[2].gl_account, control_accounts::AP_CONTROL);
1976        assert_eq!(je.lines[2].credit_amount, Decimal::from(5500));
1977        assert_eq!(je.lines[2].debit_amount, Decimal::ZERO);
1978    }
1979
1980    #[test]
1981    fn test_vendor_invoice_no_tax_produces_two_lines() {
1982        // The existing create_test_vendor_invoice() has no tax
1983        let mut generator = DocumentFlowJeGenerator::new();
1984        let invoice = create_test_vendor_invoice();
1985
1986        assert_eq!(invoice.tax_amount, Decimal::ZERO);
1987
1988        let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
1989
1990        // Should have 2 lines (unchanged behavior)
1991        assert_eq!(
1992            je.line_count(),
1993            2,
1994            "Expected 2 JE lines for vendor invoice without tax"
1995        );
1996        assert!(je.is_balanced(), "Entry must be balanced");
1997
1998        // Line 1: DR GR/IR Clearing = payable
1999        assert_eq!(je.lines[0].gl_account, control_accounts::GR_IR_CLEARING);
2000        assert_eq!(je.lines[0].debit_amount, invoice.payable_amount);
2001
2002        // Line 2: CR AP = payable
2003        assert_eq!(je.lines[1].gl_account, control_accounts::AP_CONTROL);
2004        assert_eq!(je.lines[1].credit_amount, invoice.payable_amount);
2005    }
2006
2007    #[test]
2008    fn test_vat_accounts_configurable() {
2009        // Verify that VAT accounts can be customized via config
2010        let config = DocumentFlowJeConfig {
2011            vat_output_account: "2999".to_string(),
2012            vat_input_account: "1999".to_string(),
2013            ..Default::default()
2014        };
2015
2016        let mut generator = DocumentFlowJeGenerator::with_config_and_seed(config, 42);
2017
2018        // Customer invoice with tax
2019        let ci = create_test_customer_invoice_with_tax();
2020        let je = generator.generate_from_customer_invoice(&ci).unwrap();
2021        assert_eq!(
2022            je.lines[2].gl_account, "2999",
2023            "VAT output account should be configurable"
2024        );
2025
2026        // Vendor invoice with tax
2027        let vi = create_test_vendor_invoice_with_tax();
2028        let je = generator.generate_from_vendor_invoice(&vi).unwrap();
2029        assert_eq!(
2030            je.lines[1].gl_account, "1999",
2031            "VAT input account should be configurable"
2032        );
2033    }
2034
2035    #[test]
2036    fn test_vat_entries_from_framework_accounts() {
2037        // FrameworkAccounts should propagate VAT accounts into DocumentFlowJeConfig
2038        let fa = datasynth_core::FrameworkAccounts::us_gaap();
2039        let config = DocumentFlowJeConfig::from(&fa);
2040
2041        assert_eq!(config.vat_output_account, tax_accounts::VAT_PAYABLE);
2042        assert_eq!(config.vat_input_account, tax_accounts::INPUT_VAT);
2043
2044        let fa_fr = datasynth_core::FrameworkAccounts::french_gaap();
2045        let config_fr = DocumentFlowJeConfig::from(&fa_fr);
2046
2047        assert_eq!(config_fr.vat_output_account, "445710");
2048        assert_eq!(config_fr.vat_input_account, "445660");
2049    }
2050
2051    #[test]
2052    fn test_french_gaap_vat_accounts() {
2053        let config = DocumentFlowJeConfig::french_gaap();
2054        assert_eq!(config.vat_output_account, "445710"); // PCG OUTPUT_VAT
2055        assert_eq!(config.vat_input_account, "445660"); // PCG INPUT_VAT
2056    }
2057
2058    #[test]
2059    fn test_vat_balanced_with_multiple_items() {
2060        // Multiple line items with different tax amounts must still balance
2061        use datasynth_core::models::documents::CustomerInvoiceItem;
2062
2063        let mut invoice = CustomerInvoice::new(
2064            "CI-003",
2065            "1000",
2066            "C-003",
2067            2024,
2068            1,
2069            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2070            NaiveDate::from_ymd_opt(2024, 2, 14).unwrap(),
2071            "JSMITH",
2072        );
2073
2074        // Item 1: 500 net, 50 tax
2075        let mut item1 = CustomerInvoiceItem::new(1, "A", Decimal::from(5), Decimal::from(100));
2076        item1.base.tax_amount = Decimal::from(50);
2077        invoice.add_item(item1);
2078
2079        // Item 2: 300 net, 30 tax
2080        let mut item2 = CustomerInvoiceItem::new(2, "B", Decimal::from(3), Decimal::from(100));
2081        item2.base.tax_amount = Decimal::from(30);
2082        invoice.add_item(item2);
2083
2084        invoice.post("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
2085
2086        // net=800, tax=80, gross=880
2087        assert_eq!(invoice.total_net_amount, Decimal::from(800));
2088        assert_eq!(invoice.total_tax_amount, Decimal::from(80));
2089        assert_eq!(invoice.total_gross_amount, Decimal::from(880));
2090
2091        let mut generator = DocumentFlowJeGenerator::new();
2092        let je = generator.generate_from_customer_invoice(&invoice).unwrap();
2093
2094        assert_eq!(je.line_count(), 3);
2095        assert!(je.is_balanced());
2096        assert_eq!(je.total_debit(), Decimal::from(880));
2097        assert_eq!(je.total_credit(), Decimal::from(880));
2098    }
2099
2100    #[test]
2101    fn test_document_types_per_source_document() {
2102        let mut generator = DocumentFlowJeGenerator::new();
2103
2104        let gr = create_test_gr();
2105        let invoice = create_test_vendor_invoice();
2106        let payment = create_test_payment();
2107
2108        let gr_je = generator.generate_from_goods_receipt(&gr).unwrap();
2109        assert_eq!(
2110            gr_je.header.document_type, "WE",
2111            "Goods receipt should be WE"
2112        );
2113
2114        let vi_je = generator.generate_from_vendor_invoice(&invoice).unwrap();
2115        assert_eq!(
2116            vi_je.header.document_type, "KR",
2117            "Vendor invoice should be KR"
2118        );
2119
2120        let pay_je = generator.generate_from_ap_payment(&payment).unwrap();
2121        assert_eq!(pay_je.header.document_type, "KZ", "AP payment should be KZ");
2122
2123        // Collect distinct document types
2124        let types: std::collections::HashSet<&str> = [
2125            gr_je.header.document_type.as_str(),
2126            vi_je.header.document_type.as_str(),
2127            pay_je.header.document_type.as_str(),
2128        ]
2129        .into_iter()
2130        .collect();
2131
2132        assert!(
2133            types.len() >= 3,
2134            "Expected at least 3 distinct document types from P2P flow, got {:?}",
2135            types,
2136        );
2137    }
2138
2139    #[test]
2140    fn test_enrichment_account_descriptions_populated() {
2141        let mut generator = DocumentFlowJeGenerator::new();
2142        let gr = create_test_gr();
2143        let invoice = create_test_vendor_invoice();
2144        let payment = create_test_payment();
2145
2146        let gr_je = generator.generate_from_goods_receipt(&gr).unwrap();
2147        let vi_je = generator.generate_from_vendor_invoice(&invoice).unwrap();
2148        let pay_je = generator.generate_from_ap_payment(&payment).unwrap();
2149
2150        // All lines in all JEs should have account descriptions
2151        for je in [&gr_je, &vi_je, &pay_je] {
2152            for line in &je.lines {
2153                assert!(
2154                    line.account_description.is_some(),
2155                    "Line for account {} should have description, entry doc {}",
2156                    line.gl_account,
2157                    je.header.document_id,
2158                );
2159            }
2160        }
2161
2162        // GR JE: Inventory and GR/IR Clearing
2163        assert_eq!(
2164            gr_je.lines[0].account_description.as_deref(),
2165            Some("Inventory"),
2166        );
2167        assert_eq!(
2168            gr_je.lines[1].account_description.as_deref(),
2169            Some("GR/IR Clearing"),
2170        );
2171    }
2172
2173    #[test]
2174    fn test_enrichment_profit_center_and_line_text() {
2175        let mut generator = DocumentFlowJeGenerator::new();
2176        let gr = create_test_gr();
2177
2178        let je = generator.generate_from_goods_receipt(&gr).unwrap();
2179
2180        for line in &je.lines {
2181            // All lines should have profit_center
2182            assert!(
2183                line.profit_center.is_some(),
2184                "Line {} should have profit_center",
2185                line.gl_account,
2186            );
2187            let pc = line.profit_center.as_ref().unwrap();
2188            assert!(
2189                pc.starts_with("PC-"),
2190                "Profit center should start with PC-, got {}",
2191                pc,
2192            );
2193
2194            // All lines should have line_text (from header fallback)
2195            assert!(
2196                line.line_text.is_some(),
2197                "Line {} should have line_text",
2198                line.gl_account,
2199            );
2200        }
2201    }
2202
2203    #[test]
2204    fn test_enrichment_cost_center_for_expense_accounts() {
2205        let mut generator = DocumentFlowJeGenerator::new();
2206
2207        // Create a delivery which produces COGS (5000) entries
2208        use datasynth_core::models::documents::{Delivery, DeliveryItem};
2209        let mut delivery = Delivery::new(
2210            "DEL-001".to_string(),
2211            "1000",
2212            "SO-001",
2213            "C-001",
2214            2024,
2215            1,
2216            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2217            "JSMITH",
2218        );
2219        let item = DeliveryItem::from_sales_order(
2220            10,
2221            "Test Material",
2222            Decimal::from(100),
2223            Decimal::from(50),
2224            "SO-001",
2225            10,
2226        );
2227        delivery.add_item(item);
2228        delivery.post_goods_issue("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
2229
2230        let je = generator.generate_from_delivery(&delivery).unwrap();
2231
2232        // COGS line (5000) should have cost_center
2233        let cogs_line = je.lines.iter().find(|l| l.gl_account == "5000").unwrap();
2234        assert!(
2235            cogs_line.cost_center.is_some(),
2236            "COGS line should have cost_center assigned",
2237        );
2238        let cc = cogs_line.cost_center.as_ref().unwrap();
2239        assert!(
2240            cc.starts_with("CC"),
2241            "Cost center should start with CC, got {}",
2242            cc,
2243        );
2244
2245        // Inventory line (1200) should NOT have cost_center
2246        let inv_line = je.lines.iter().find(|l| l.gl_account == "1200").unwrap();
2247        assert!(
2248            inv_line.cost_center.is_none(),
2249            "Non-expense line should not have cost_center",
2250        );
2251    }
2252
2253    #[test]
2254    fn test_enrichment_value_date_for_ap_ar() {
2255        let mut generator = DocumentFlowJeGenerator::new();
2256
2257        let invoice = create_test_vendor_invoice();
2258        let je = generator.generate_from_vendor_invoice(&invoice).unwrap();
2259
2260        // AP line should have value_date
2261        let ap_line = je.lines.iter().find(|l| l.gl_account == "2000").unwrap();
2262        assert!(
2263            ap_line.value_date.is_some(),
2264            "AP line should have value_date set",
2265        );
2266        assert_eq!(ap_line.value_date, Some(je.header.posting_date));
2267
2268        // GR/IR clearing line should NOT have value_date
2269        let clearing_line = je.lines.iter().find(|l| l.gl_account == "2900").unwrap();
2270        assert!(
2271            clearing_line.value_date.is_none(),
2272            "Non-AP/AR line should not have value_date",
2273        );
2274    }
2275
2276    // ====================================================================
2277    // SP3 T8 — P2P trading_partner propagation
2278    // ====================================================================
2279
2280    /// Task 8: P2P JE lines that are directly vendor-linked carry
2281    /// `trading_partner = Some(vendor_id)`.
2282    #[test]
2283    fn p2p_generator_populates_trading_partner_for_vendor_lines() {
2284        let mut generator = DocumentFlowJeGenerator::new();
2285
2286        // GR: GR/IR clearing line (line 2) should carry the vendor ID
2287        let gr = create_test_gr();
2288        let gr_je = generator.generate_from_goods_receipt(&gr).unwrap();
2289        let gr_ir_line = gr_je
2290            .lines
2291            .iter()
2292            .find(|l| l.gl_account == control_accounts::GR_IR_CLEARING)
2293            .expect("GR/IR clearing line missing");
2294        assert_eq!(
2295            gr_ir_line.trading_partner.as_deref(),
2296            Some("V-001"),
2297            "GR/IR clearing line should carry vendor_id as trading_partner"
2298        );
2299        // Inventory DR line should NOT carry trading_partner
2300        let inv_line = gr_je
2301            .lines
2302            .iter()
2303            .find(|l| l.gl_account == control_accounts::INVENTORY)
2304            .expect("Inventory line missing");
2305        assert!(
2306            inv_line.trading_partner.is_none(),
2307            "Inventory DR line should not carry trading_partner"
2308        );
2309
2310        // Vendor invoice: AP credit line should carry vendor_id; GR/IR debit should not
2311        let invoice = create_test_vendor_invoice();
2312        let vi_je = generator.generate_from_vendor_invoice(&invoice).unwrap();
2313        let ap_line = vi_je
2314            .lines
2315            .iter()
2316            .find(|l| l.gl_account == control_accounts::AP_CONTROL)
2317            .expect("AP line missing");
2318        assert_eq!(
2319            ap_line.trading_partner.as_deref(),
2320            Some("V-001"),
2321            "AP line should carry vendor_id as trading_partner"
2322        );
2323        let gr_ir_dr = vi_je
2324            .lines
2325            .iter()
2326            .find(|l| l.gl_account == control_accounts::GR_IR_CLEARING)
2327            .expect("GR/IR debit line missing");
2328        assert!(
2329            gr_ir_dr.trading_partner.is_none(),
2330            "GR/IR debit on vendor invoice should not carry trading_partner"
2331        );
2332
2333        // AP payment: AP debit line should carry vendor_id; Cash CR should not
2334        let payment = create_test_payment();
2335        let pay_je = generator.generate_from_ap_payment(&payment).unwrap();
2336        let ap_dr = pay_je
2337            .lines
2338            .iter()
2339            .find(|l| l.gl_account == control_accounts::AP_CONTROL)
2340            .expect("AP debit line missing on payment");
2341        assert_eq!(
2342            ap_dr.trading_partner.as_deref(),
2343            Some("V-001"),
2344            "AP debit line on payment should carry vendor_id as trading_partner"
2345        );
2346        let cash_line = pay_je
2347            .lines
2348            .iter()
2349            .find(|l| l.gl_account == cash_accounts::OPERATING_CASH)
2350            .expect("Cash line missing on payment");
2351        assert!(
2352            cash_line.trading_partner.is_none(),
2353            "Cash CR line should not carry trading_partner"
2354        );
2355    }
2356
2357    // ====================================================================
2358    // SP3 T9 — O2C trading_partner propagation
2359    // ====================================================================
2360
2361    /// Task 9: O2C JE lines that are directly customer-linked carry
2362    /// `trading_partner = Some(customer_id)`.
2363    #[test]
2364    fn o2c_generator_populates_trading_partner_for_customer_lines() {
2365        let mut generator = DocumentFlowJeGenerator::new();
2366
2367        // Customer invoice: AR DR line should carry customer_id; Revenue/VAT CRs should not
2368        let invoice = create_test_customer_invoice_with_tax();
2369        let ci_je = generator.generate_from_customer_invoice(&invoice).unwrap();
2370        let ar_line = ci_je
2371            .lines
2372            .iter()
2373            .find(|l| l.gl_account == control_accounts::AR_CONTROL)
2374            .expect("AR line missing");
2375        assert_eq!(
2376            ar_line.trading_partner.as_deref(),
2377            Some("C-001"),
2378            "AR line should carry customer_id as trading_partner"
2379        );
2380        let rev_line = ci_je
2381            .lines
2382            .iter()
2383            .find(|l| l.gl_account == revenue_accounts::PRODUCT_REVENUE)
2384            .expect("Revenue line missing");
2385        assert!(
2386            rev_line.trading_partner.is_none(),
2387            "Revenue CR line should not carry trading_partner"
2388        );
2389        let vat_line = ci_je
2390            .lines
2391            .iter()
2392            .find(|l| l.gl_account == tax_accounts::VAT_PAYABLE)
2393            .expect("VAT line missing");
2394        assert!(
2395            vat_line.trading_partner.is_none(),
2396            "VAT CR line should not carry trading_partner"
2397        );
2398
2399        // AR receipt: AR CR line should carry customer_id; Cash DR should not
2400        let mut receipt = Payment::new_ar_receipt(
2401            "RCP-T9".to_string(),
2402            "1000",
2403            "C-001",
2404            Decimal::from(1100),
2405            2024,
2406            2,
2407            NaiveDate::from_ymd_opt(2024, 2, 28).unwrap(),
2408            "JSMITH",
2409        );
2410        receipt.post("JSMITH", NaiveDate::from_ymd_opt(2024, 2, 28).unwrap());
2411
2412        let rcp_je = generator.generate_from_ar_receipt(&receipt).unwrap();
2413        let ar_cr = rcp_je
2414            .lines
2415            .iter()
2416            .find(|l| l.gl_account == control_accounts::AR_CONTROL)
2417            .expect("AR CR line missing on receipt");
2418        assert_eq!(
2419            ar_cr.trading_partner.as_deref(),
2420            Some("C-001"),
2421            "AR CR line on receipt should carry customer_id as trading_partner"
2422        );
2423        let cash_dr = rcp_je
2424            .lines
2425            .iter()
2426            .find(|l| l.gl_account == cash_accounts::OPERATING_CASH)
2427            .expect("Cash DR line missing on receipt");
2428        assert!(
2429            cash_dr.trading_partner.is_none(),
2430            "Cash DR line on receipt should not carry trading_partner"
2431        );
2432
2433        // Delivery: COGS and Inventory lines should NOT carry trading_partner
2434        use datasynth_core::models::documents::{Delivery, DeliveryItem};
2435        let mut delivery = Delivery::new(
2436            "DEL-T9".to_string(),
2437            "1000",
2438            "SO-001",
2439            "C-001",
2440            2024,
2441            1,
2442            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2443            "JSMITH",
2444        );
2445        let item = DeliveryItem::from_sales_order(
2446            10,
2447            "Material X",
2448            Decimal::from(100),
2449            Decimal::from(50),
2450            "SO-001",
2451            10,
2452        );
2453        delivery.add_item(item);
2454        delivery.post_goods_issue("JSMITH", NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
2455
2456        let del_je = generator.generate_from_delivery(&delivery).unwrap();
2457        for line in &del_je.lines {
2458            assert!(
2459                line.trading_partner.is_none(),
2460                "Delivery JE lines (COGS/Inventory) should not carry trading_partner; \
2461                 account {} has {:?}",
2462                line.gl_account,
2463                line.trading_partner,
2464            );
2465        }
2466    }
2467
2468    /// SP3.13 W1.5 follow-up — geometric within-bucket draw concentrates the
2469    /// sampled value near the bucket's lower bound instead of drawing uniformly.
2470    ///
2471    /// For the [4, 9] bucket (lo=4, hi=9) with DECAY=0.5:
2472    /// - Expected: mean < 5.5 (vs. uniform mean 6.5)
2473    /// - Expected: ≥40 % of draws land exactly at the lower bound (4)
2474    #[test]
2475    fn sp3_13_w1_5_bucket_tightening_concentrates_target_near_lo() {
2476        use rand::SeedableRng;
2477        use rand_chacha::ChaCha8Rng;
2478        use std::collections::HashMap;
2479
2480        let mut rng = ChaCha8Rng::seed_from_u64(42);
2481        let mut counts: HashMap<u32, i32> = HashMap::new();
2482        for _ in 0..10_000 {
2483            let v = super::sample_within_bucket(4, 9, &mut rng);
2484            *counts.entry(v).or_insert(0) += 1;
2485        }
2486
2487        let total: f64 = counts.values().sum::<i32>() as f64;
2488        let mean: f64 = counts
2489            .iter()
2490            .map(|(&k, &c)| k as f64 * (c as f64 / total))
2491            .sum();
2492        assert!(
2493            mean < 5.5,
2494            "geometric within-bucket draw should pull mean below 5.5 (uniform mean is 6.5), got {mean:.3}"
2495        );
2496
2497        let count_lo = counts.get(&4).copied().unwrap_or(0);
2498        let lo_fraction = count_lo as f64 / total;
2499        assert!(
2500            lo_fraction > 0.40,
2501            "expected ≥40 % of draws at lower bound (4), got {lo_fraction:.3} ({count_lo}/10000)"
2502        );
2503    }
2504}