Skip to main content

datasynth_runtime/
output_writer.rs

1//! Comprehensive output writer for all generated data.
2//!
3//! Writes all generated data from the EnhancedGenerationResult to files
4//! in the output directory. Uses CSV for flat tabular data (journal entry
5//! lines) and JSON for types with nested structures (Vecs, sub-structs).
6
7use std::cell::Cell;
8use std::io::Write;
9use std::path::Path;
10
11use crate::enhanced_orchestrator::EnhancedGenerationResult;
12use datasynth_core::documents::PaymentType;
13use datasynth_output::OutputRootConfig;
14use tracing::{info, warn};
15
16thread_local! {
17    /// Thread-local flat-layout flag. When true, every `write_json_safe` call
18    /// routes through `write_json_flat` so nested `{header, lines}` shapes get
19    /// flattened. Set by `write_all_output_with_layout` at the top of its body,
20    /// reset on exit.
21    static FLAT_LAYOUT_ACTIVE: Cell<bool> = const { Cell::new(false) };
22
23    /// Thread-local JSON skip flag. When true, `write_json_safe` becomes a no-op.
24    /// Set by `write_all_output_with_layout` when the requested formats don't
25    /// include JSON. This avoids wrapping 190+ call sites in `if write_json`.
26    static SKIP_JSON: Cell<bool> = const { Cell::new(false) };
27}
28
29/// Write a JSON file for any serializable slice. Skips empty slices.
30///
31/// Streams JSON directly to a buffered file writer instead of allocating
32/// the entire JSON string in memory (Phase 3 I/O optimization).
33/// Write a JSON array by streaming one record at a time.
34///
35/// Instead of serializing the entire `&[T]` in one `to_writer_pretty` call
36/// (which builds a massive in-memory serde state for large arrays), this
37/// writes `[\n` + per-record pretty-printed JSON with commas + `\n]`.
38///
39/// For 200K+ records this reduces peak memory and improves write throughput
40/// by avoiding serde's internal buffering of the full array structure.
41fn write_json<T: serde::Serialize>(
42    data: &[T],
43    path: &Path,
44    label: &str,
45) -> Result<(), Box<dyn std::error::Error>> {
46    use std::io::Write;
47
48    if data.is_empty() {
49        return Ok(());
50    }
51
52    let file = std::fs::File::create(path)?;
53    let mut writer = std::io::BufWriter::with_capacity(512 * 1024, file);
54
55    // Stream records one at a time into a JSON array
56    writer.write_all(b"[\n")?;
57    for (i, item) in data.iter().enumerate() {
58        if i > 0 {
59            writer.write_all(b",\n")?;
60        }
61        serde_json::to_writer_pretty(&mut writer, item)?;
62    }
63    writer.write_all(b"\n]\n")?;
64    writer.flush()?;
65
66    info!(
67        "  {} written: {} records -> {}",
68        label,
69        data.len(),
70        path.display()
71    );
72    Ok(())
73}
74
75/// Write journal entry lines as a flat CSV file.
76///
77/// This extracts the key fields from both the header and each line item to
78/// produce a single flat CSV that can be loaded directly into dataframes.
79fn write_journal_entries_csv(
80    result: &EnhancedGenerationResult,
81    output_dir: &Path,
82) -> Result<(), Box<dyn std::error::Error>> {
83    if result.journal_entries.is_empty() {
84        return Ok(());
85    }
86
87    let path = output_dir.join("journal_entries.csv");
88    let file = std::fs::File::create(&path)?;
89    let mut w = std::io::BufWriter::with_capacity(256 * 1024, file);
90
91    // Write header
92    writeln!(
93        w,
94        "document_id,company_code,fiscal_year,fiscal_period,posting_date,document_date,\
95         document_type,currency,exchange_rate,reference,header_text,created_by,source,\
96         business_process,ledger,is_fraud,is_anomaly,\
97         line_number,gl_account,debit_amount,credit_amount,local_amount,\
98         cost_center,profit_center,line_text,\
99         auxiliary_account_number,auxiliary_account_label,lettrage,lettrage_date"
100    )?;
101
102    for je in &result.journal_entries {
103        let h = &je.header;
104        for line in &je.lines {
105            let lettrage_date_str = line
106                .lettrage_date
107                .map(|d| d.to_string())
108                .unwrap_or_default();
109            writeln!(
110                w,
111                "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}",
112                h.document_id,
113                csv_escape(&h.company_code),
114                h.fiscal_year,
115                h.fiscal_period,
116                h.posting_date,
117                h.document_date,
118                csv_escape(&h.document_type),
119                csv_escape(&h.currency),
120                h.exchange_rate,
121                csv_opt_str(&h.reference),
122                csv_opt_str(&h.header_text),
123                csv_escape(&h.created_by),
124                h.source,
125                h.business_process
126                    .map(|bp| format!("{bp:?}"))
127                    .unwrap_or_default(),
128                csv_escape(&h.ledger),
129                h.is_fraud,
130                h.is_anomaly,
131                line.line_number,
132                csv_escape(&line.gl_account),
133                line.debit_amount,
134                line.credit_amount,
135                line.local_amount,
136                csv_opt_str(&line.cost_center),
137                csv_opt_str(&line.profit_center),
138                csv_opt_str(&line.line_text),
139                csv_opt_str(&line.auxiliary_account_number),
140                csv_opt_str(&line.auxiliary_account_label),
141                csv_opt_str(&line.lettrage),
142                lettrage_date_str,
143            )?;
144        }
145    }
146
147    w.flush()?;
148    let total_lines: usize = result.journal_entries.iter().map(|je| je.lines.len()).sum();
149    info!(
150        "  Journal entries CSV written: {} entries, {} line items -> {}",
151        result.journal_entries.len(),
152        total_lines,
153        path.display()
154    );
155    Ok(())
156}
157
158/// Write journal entries as flat JSON (header fields merged onto each line).
159///
160/// Each object in the output array contains all header fields plus all line fields,
161/// with no nesting. This is the analytics-friendly format.
162fn write_journal_entries_flat_json(
163    result: &EnhancedGenerationResult,
164    output_dir: &Path,
165) -> Result<(), Box<dyn std::error::Error>> {
166    if result.journal_entries.is_empty() {
167        return Ok(());
168    }
169
170    let path = output_dir.join("journal_entries.json");
171    let file = std::fs::File::create(&path)?;
172    let mut writer = std::io::BufWriter::with_capacity(256 * 1024, file);
173
174    // Write opening bracket
175    writer.write_all(b"[\n")?;
176
177    let mut first = true;
178    let mut total_lines = 0usize;
179    for je in &result.journal_entries {
180        // Serialize header to a JSON map
181        let header_value = serde_json::to_value(&je.header)?;
182
183        for line in &je.lines {
184            if !first {
185                writer.write_all(b",\n")?;
186            }
187            first = false;
188            total_lines += 1;
189
190            // Serialize line to a JSON map, then merge header fields in
191            let mut line_value = serde_json::to_value(line)?;
192
193            if let serde_json::Value::Object(ref header_map) = header_value {
194                if let serde_json::Value::Object(ref mut line_map) = line_value {
195                    for (key, val) in header_map {
196                        // Line fields take precedence for shared keys (e.g. document_id)
197                        if !line_map.contains_key(key) {
198                            line_map.insert(key.clone(), val.clone());
199                        }
200                    }
201                }
202            }
203
204            serde_json::to_writer_pretty(&mut writer, &line_value)?;
205        }
206    }
207
208    writer.write_all(b"\n]\n")?;
209    writer.flush()?;
210    info!(
211        "  Journal entries (flat JSON) written: {} line items -> {}",
212        total_lines,
213        path.display()
214    );
215    Ok(())
216}
217
218/// v4.4.2 helper — walk a serialized OCEL event-log `Value` tree and
219/// mirror `object_type_id` into `object_type` on every
220/// `object_refs[*]` entry. The canonical OCEL 2.0 field name is
221/// `object_type`; DataSynth's internal model carries it as
222/// `object_type_id` for historical reasons. Emitting both keys lets
223/// OCEL-spec-compliant consumers (pm4py, Celonis, etc.) see the type
224/// without a rename step.
225fn add_ocel_object_type_alias(value: &mut serde_json::Value) {
226    if let Some(events) = value.get_mut("events").and_then(|v| v.as_array_mut()) {
227        for event in events.iter_mut() {
228            if let Some(refs) = event.get_mut("object_refs").and_then(|r| r.as_array_mut()) {
229                for oref in refs.iter_mut() {
230                    if let Some(obj) = oref.as_object_mut() {
231                        if let Some(oti) = obj.get("object_type_id").cloned() {
232                            obj.entry("object_type").or_insert(oti);
233                        }
234                    }
235                }
236            }
237        }
238    }
239}
240
241/// Escape a string for CSV output by quoting if it contains commas or quotes.
242fn csv_escape(s: &str) -> String {
243    if s.contains(',') || s.contains('"') || s.contains('\n') {
244        format!("\"{}\"", s.replace('"', "\"\""))
245    } else {
246        s.to_string()
247    }
248}
249
250/// Format an Option<String> for CSV output (empty string for None).
251fn csv_opt_str(opt: &Option<String>) -> String {
252    match opt {
253        Some(s) => csv_escape(s),
254        None => String::new(),
255    }
256}
257
258/// Write all generated data to the output directory.
259///
260/// This function exports every non-empty dataset from the generation result.
261/// Journal entries are written as a flat CSV file (one row per line item)
262/// and as a nested JSON file. Other data is written as JSON files since
263/// many model types contain nested structures.
264#[allow(dead_code)]
265pub fn write_all_output(
266    result: &EnhancedGenerationResult,
267    output_dir: &Path,
268) -> Result<(), Box<dyn std::error::Error>> {
269    write_all_output_with_layout(
270        result,
271        output_dir,
272        datasynth_config::ExportLayout::Nested,
273        &[
274            datasynth_config::FileFormat::Csv,
275            datasynth_config::FileFormat::Json,
276        ],
277    )
278}
279
280/// Variant of [`write_all_output_with_layout`] that routes output through
281/// an [`OutputRootConfig`] instead of a raw `&Path`.
282///
283/// Per-entity subtree mode is used by the group-audit shard runner
284/// (v5.0+): the runner sets `per_entity_subtree: true` and
285/// `entity_code: Some(code)` on `root`, and this helper drops each
286/// entity's archive under `{root_dir}/entities/{code}/` so group-wide
287/// artifacts can still live at `{root_dir}/`.
288///
289/// In flat mode (the default for single-entity runs) this is exactly
290/// equivalent to calling [`write_all_output_with_layout`] with
291/// `output_dir = root.root_dir`, so the signature and behavior of the
292/// existing single-entity entrypoints are unchanged.
293#[allow(dead_code)]
294pub fn write_all_output_with_root(
295    result: &EnhancedGenerationResult,
296    root: &OutputRootConfig,
297    export_layout: datasynth_config::ExportLayout,
298    formats: &[datasynth_config::FileFormat],
299) -> Result<(), Box<dyn std::error::Error>> {
300    let effective = root.effective_dir();
301    write_all_output_with_layout(result, &effective, export_layout, formats)
302}
303
304/// Write all generated data with a configurable export layout and format set.
305///
306/// Only writes files for formats present in `formats`. If `formats` is empty,
307/// writes both CSV and JSON (backward compatible). This allows skipping JSON
308/// when only CSV is needed, which halves output time for large datasets.
309pub fn write_all_output_with_layout(
310    result: &EnhancedGenerationResult,
311    output_dir: &Path,
312    export_layout: datasynth_config::ExportLayout,
313    formats: &[datasynth_config::FileFormat],
314) -> Result<(), Box<dyn std::error::Error>> {
315    let csv_enabled = formats.is_empty()
316        || formats.contains(&datasynth_config::FileFormat::Csv)
317        || formats.contains(&datasynth_config::FileFormat::Parquet);
318    let json_enabled = formats.is_empty()
319        || formats.contains(&datasynth_config::FileFormat::Json)
320        || formats.contains(&datasynth_config::FileFormat::JsonLines);
321    std::fs::create_dir_all(output_dir)?;
322    info!("Writing comprehensive output to: {}", output_dir.display());
323
324    // Set flat-layout flag for all `write_json_safe` calls in this pass.
325    // Scope guard ensures we reset on return (including error paths).
326    struct FlatLayoutGuard;
327    impl Drop for FlatLayoutGuard {
328        fn drop(&mut self) {
329            FLAT_LAYOUT_ACTIVE.with(|c| c.set(false));
330        }
331    }
332    let _flat_guard = if export_layout == datasynth_config::ExportLayout::Flat {
333        FLAT_LAYOUT_ACTIVE.with(|c| c.set(true));
334        Some(FlatLayoutGuard)
335    } else {
336        None
337    };
338
339    // Set JSON skip flag so `write_json_safe` becomes a no-op when JSON not requested.
340    struct SkipJsonGuard;
341    impl Drop for SkipJsonGuard {
342        fn drop(&mut self) {
343            SKIP_JSON.with(|c| c.set(false));
344        }
345    }
346    let _skip_json_guard = if !json_enabled {
347        SKIP_JSON.with(|c| c.set(true));
348        info!("JSON output skipped (not in requested formats)");
349        Some(SkipJsonGuard)
350    } else {
351        None
352    };
353
354    // ========================================================================
355    // Journal Entries (CSV + JSON in parallel when both enabled)
356    // ========================================================================
357    if !result.journal_entries.is_empty() {
358        let do_csv = csv_enabled;
359        let do_json = json_enabled;
360        let is_flat = export_layout == datasynth_config::ExportLayout::Flat;
361
362        std::thread::scope(|s| {
363            if do_csv {
364                s.spawn(|| {
365                    if let Err(e) = write_journal_entries_csv(result, output_dir) {
366                        warn!("Failed to write journal_entries.csv: {}", e);
367                    }
368                });
369            }
370            if do_json {
371                s.spawn(|| {
372                    if is_flat {
373                        if let Err(e) = write_journal_entries_flat_json(result, output_dir) {
374                            warn!("Failed to write flat journal_entries.json: {}", e);
375                        }
376                    } else if let Err(e) = write_json(
377                        &result.journal_entries,
378                        &output_dir.join("journal_entries.json"),
379                        "Journal entries (JSON)",
380                    ) {
381                        warn!("Failed to write journal_entries.json: {}", e);
382                    }
383                });
384            }
385        });
386    }
387
388    // ========================================================================
389    // Master Data
390    // ========================================================================
391    let md_dir = output_dir.join("master_data");
392    if !result.master_data.vendors.is_empty()
393        || !result.master_data.customers.is_empty()
394        || !result.master_data.materials.is_empty()
395        || !result.master_data.assets.is_empty()
396        || !result.master_data.employees.is_empty()
397        || !result.master_data.cost_centers.is_empty()
398        || !result.master_data.profit_centers.is_empty()
399    {
400        std::fs::create_dir_all(&md_dir)?;
401        info!("Writing master data...");
402
403        write_json_safe(
404            &result.master_data.vendors,
405            &md_dir.join("vendors.json"),
406            "Vendors",
407        );
408        write_json_safe(
409            &result.master_data.customers,
410            &md_dir.join("customers.json"),
411            "Customers",
412        );
413        write_json_safe(
414            &result.master_data.materials,
415            &md_dir.join("materials.json"),
416            "Materials",
417        );
418        write_json_safe(
419            &result.master_data.assets,
420            &md_dir.join("fixed_assets.json"),
421            "Fixed assets",
422        );
423        write_json_safe(
424            &result.master_data.employees,
425            &md_dir.join("employees.json"),
426            "Employees",
427        );
428        write_json_safe(
429            &result.master_data.cost_centers,
430            &md_dir.join("cost_centers.json"),
431            "Cost centers",
432        );
433        // v5.1: profit-centre hierarchy (segments + sub-units).
434        write_json_safe(
435            &result.master_data.profit_centers,
436            &md_dir.join("profit_centers.json"),
437            "Profit centres",
438        );
439        // v3.3.0: organizational profiles (one per company)
440        write_json_safe(
441            &result.master_data.organizational_profiles,
442            &md_dir.join("organizational_profiles.json"),
443            "Organizational profiles (v3.3.0)",
444        );
445    }
446
447    // ========================================================================
448    // Document Flows
449    // ========================================================================
450    let df_dir = output_dir.join("document_flows");
451    let flat_mode = export_layout == datasynth_config::ExportLayout::Flat;
452    if !result.document_flows.purchase_orders.is_empty()
453        || !result.document_flows.sales_orders.is_empty()
454    {
455        std::fs::create_dir_all(&df_dir)?;
456        info!("Writing document flows...");
457
458        write_json_auto(
459            &result.document_flows.purchase_orders,
460            &df_dir.join("purchase_orders.json"),
461            "Purchase orders",
462            flat_mode,
463        );
464        write_json_auto(
465            &result.document_flows.goods_receipts,
466            &df_dir.join("goods_receipts.json"),
467            "Goods receipts",
468            flat_mode,
469        );
470        write_json_auto(
471            &result.document_flows.vendor_invoices,
472            &df_dir.join("vendor_invoices.json"),
473            "Vendor invoices",
474            flat_mode,
475        );
476        write_json_auto(
477            &result.document_flows.payments,
478            &df_dir.join("payments.json"),
479            "Payments",
480            flat_mode,
481        );
482        let customer_receipts: Vec<_> = result
483            .document_flows
484            .payments
485            .iter()
486            .filter(|p| p.payment_type == PaymentType::ArReceipt)
487            .collect();
488        write_json_auto(
489            &customer_receipts,
490            &df_dir.join("customer_receipts.json"),
491            "Customer receipts",
492            flat_mode,
493        );
494        write_json_auto(
495            &result.document_flows.sales_orders,
496            &df_dir.join("sales_orders.json"),
497            "Sales orders",
498            flat_mode,
499        );
500        write_json_auto(
501            &result.document_flows.deliveries,
502            &df_dir.join("deliveries.json"),
503            "Deliveries",
504            flat_mode,
505        );
506        write_json_auto(
507            &result.document_flows.customer_invoices,
508            &df_dir.join("customer_invoices.json"),
509            "Customer invoices",
510            flat_mode,
511        );
512
513        // Document cross-references (PO→GR, GR→Invoice, Invoice→Payment, etc.).
514        // v4.4.2+: inject SDK-friendly `from_type`/`from_id`/`to_type`/`to_id`
515        // aliases so consumers that follow the graph convention see the
516        // types populated. The canonical `source_doc_*`/`target_doc_*`
517        // keys continue to emit unchanged for backwards compatibility.
518        match serde_json::to_value(&result.document_flows.document_references) {
519            Ok(mut v) => {
520                if let Some(arr) = v.as_array_mut() {
521                    for r in arr.iter_mut() {
522                        if let Some(obj) = r.as_object_mut() {
523                            if let Some(st) = obj.get("source_doc_type").cloned() {
524                                obj.entry("from_type").or_insert(st);
525                            }
526                            if let Some(si) = obj.get("source_doc_id").cloned() {
527                                obj.entry("from_id").or_insert(si);
528                            }
529                            if let Some(tt) = obj.get("target_doc_type").cloned() {
530                                obj.entry("to_type").or_insert(tt);
531                            }
532                            if let Some(ti) = obj.get("target_doc_id").cloned() {
533                                obj.entry("to_id").or_insert(ti);
534                            }
535                        }
536                    }
537                }
538                match serde_json::to_string_pretty(&v) {
539                    Ok(json) => {
540                        let path = df_dir.join("document_references.json");
541                        if let Err(e) = std::fs::write(&path, json) {
542                            warn!("Failed to write document references: {}", e);
543                        } else {
544                            info!(
545                                "  Document references written: {} records -> {}",
546                                result.document_flows.document_references.len(),
547                                path.display()
548                            );
549                        }
550                    }
551                    Err(e) => warn!("Failed to serialize document references: {}", e),
552                }
553            }
554            Err(e) => warn!("Failed to build document references Value: {}", e),
555        }
556
557        // Note: P2P/O2C chain types do not implement Serialize, so we log
558        // their counts instead. The individual documents above capture all data.
559        if !result.document_flows.p2p_chains.is_empty() {
560            info!(
561                "  P2P chains: {} (data exported via individual document files)",
562                result.document_flows.p2p_chains.len()
563            );
564        }
565        if !result.document_flows.o2c_chains.is_empty() {
566            info!(
567                "  O2C chains: {} (data exported via individual document files)",
568                result.document_flows.o2c_chains.len()
569            );
570        }
571    }
572
573    // ========================================================================
574    // Subledger
575    // ========================================================================
576    let sl_dir = output_dir.join("subledger");
577    if !result.subledger.ap_invoices.is_empty()
578        || !result.subledger.ar_invoices.is_empty()
579        || !result.subledger.fa_records.is_empty()
580        || !result.subledger.inventory_positions.is_empty()
581    {
582        std::fs::create_dir_all(&sl_dir)?;
583        info!("Writing subledger data...");
584
585        write_json_safe(
586            &result.subledger.ap_invoices,
587            &sl_dir.join("ap_invoices.json"),
588            "AP invoices",
589        );
590        write_json_safe(
591            &result.subledger.ar_invoices,
592            &sl_dir.join("ar_invoices.json"),
593            "AR invoices",
594        );
595        write_json_safe(
596            &result.subledger.fa_records,
597            &sl_dir.join("fa_records.json"),
598            "FA records",
599        );
600        write_json_safe(
601            &result.subledger.inventory_positions,
602            &sl_dir.join("inventory_positions.json"),
603            "Inventory positions",
604        );
605        write_json_safe(
606            &result.subledger.inventory_movements,
607            &sl_dir.join("inventory_movements.json"),
608            "Inventory movements",
609        );
610        write_json_safe(
611            &result.subledger.ar_aging_reports,
612            &sl_dir.join("ar_aging.json"),
613            "AR aging reports",
614        );
615        write_json_safe(
616            &result.subledger.ap_aging_reports,
617            &sl_dir.join("ap_aging.json"),
618            "AP aging reports",
619        );
620        write_json_safe(
621            &result.subledger.depreciation_runs,
622            &sl_dir.join("depreciation_runs.json"),
623            "Depreciation runs",
624        );
625        write_json_safe(
626            &result.subledger.inventory_valuations,
627            &sl_dir.join("inventory_valuation.json"),
628            "Inventory valuations",
629        );
630        // Dunning runs and letters (generated after AR aging)
631        write_json_safe(
632            &result.subledger.dunning_runs,
633            &sl_dir.join("dunning_runs.json"),
634            "Dunning runs",
635        );
636        write_json_safe(
637            &result.subledger.dunning_letters,
638            &sl_dir.join("dunning_letters.json"),
639            "Dunning letters",
640        );
641    }
642
643    // ========================================================================
644    // Audit
645    // ========================================================================
646    let audit_dir = output_dir.join("audit");
647    if !result.audit.engagements.is_empty() {
648        std::fs::create_dir_all(&audit_dir)?;
649        info!("Writing audit data...");
650
651        write_json_safe(
652            &result.audit.engagements,
653            &audit_dir.join("audit_engagements.json"),
654            "Audit engagements",
655        );
656        write_json_safe(
657            &result.audit.audit_scopes,
658            &audit_dir.join("audit_scopes.json"),
659            "Audit scopes (ISA 220 / ISA 300)",
660        );
661        write_json_safe(
662            &result.audit.workpapers,
663            &audit_dir.join("audit_workpapers.json"),
664            "Audit workpapers",
665        );
666        write_json_safe(
667            &result.audit.evidence,
668            &audit_dir.join("audit_evidence.json"),
669            "Audit evidence",
670        );
671        write_json_safe(
672            &result.audit.risk_assessments,
673            &audit_dir.join("audit_risk_assessments.json"),
674            "Audit risk assessments",
675        );
676        write_json_safe(
677            &result.audit.findings,
678            &audit_dir.join("audit_findings.json"),
679            "Audit findings",
680        );
681        write_json_safe(
682            &result.audit.judgments,
683            &audit_dir.join("audit_judgments.json"),
684            "Audit judgments",
685        );
686        write_json_safe(
687            &result.audit.confirmations,
688            &audit_dir.join("audit_confirmations.json"),
689            "Audit confirmations",
690        );
691        write_json_safe(
692            &result.audit.confirmation_responses,
693            &audit_dir.join("audit_confirmation_responses.json"),
694            "Audit confirmation responses",
695        );
696        write_json_safe(
697            &result.audit.procedure_steps,
698            &audit_dir.join("audit_procedure_steps.json"),
699            "Audit procedure steps",
700        );
701        write_json_safe(
702            &result.audit.samples,
703            &audit_dir.join("audit_samples.json"),
704            "Audit samples",
705        );
706        write_json_safe(
707            &result.audit.analytical_results,
708            &audit_dir.join("audit_analytical_results.json"),
709            "Audit analytical results",
710        );
711        write_json_safe(
712            &result.audit.ia_functions,
713            &audit_dir.join("audit_ia_functions.json"),
714            "Audit IA functions",
715        );
716        write_json_safe(
717            &result.audit.ia_reports,
718            &audit_dir.join("audit_ia_reports.json"),
719            "Audit IA reports",
720        );
721        write_json_safe(
722            &result.audit.related_parties,
723            &audit_dir.join("audit_related_parties.json"),
724            "Audit related parties",
725        );
726        write_json_safe(
727            &result.audit.related_party_transactions,
728            &audit_dir.join("audit_related_party_transactions.json"),
729            "Audit related party transactions",
730        );
731        // ISA 600: Group audit artefacts
732        if !result.audit.component_auditors.is_empty() {
733            write_json_safe(
734                &result.audit.component_auditors,
735                &audit_dir.join("component_auditors.json"),
736                "Component auditors (ISA 600)",
737            );
738            if let Some(plan) = &result.audit.group_audit_plan {
739                write_json_single_safe(
740                    plan,
741                    &audit_dir.join("group_audit_plan.json"),
742                    "Group audit plan (ISA 600)",
743                );
744            }
745            write_json_safe(
746                &result.audit.component_instructions,
747                &audit_dir.join("component_instructions.json"),
748                "Component instructions (ISA 600)",
749            );
750            write_json_safe(
751                &result.audit.component_reports,
752                &audit_dir.join("component_reports.json"),
753                "Component auditor reports (ISA 600)",
754            );
755        }
756        // ISA 210: Engagement letters
757        write_json_safe(
758            &result.audit.engagement_letters,
759            &audit_dir.join("engagement_letters.json"),
760            "Engagement letters (ISA 210)",
761        );
762        // ISA 560 / IAS 10: Subsequent events
763        write_json_safe(
764            &result.audit.subsequent_events,
765            &audit_dir.join("subsequent_events.json"),
766            "Subsequent events (ISA 560 / IAS 10)",
767        );
768        // ISA 402: Service organization controls
769        write_json_safe(
770            &result.audit.service_organizations,
771            &audit_dir.join("service_organizations.json"),
772            "Service organizations (ISA 402)",
773        );
774        write_json_safe(
775            &result.audit.soc_reports,
776            &audit_dir.join("soc_reports.json"),
777            "SOC reports (ISA 402)",
778        );
779        write_json_safe(
780            &result.audit.user_entity_controls,
781            &audit_dir.join("user_entity_controls.json"),
782            "User entity controls (ISA 402)",
783        );
784
785        // ISA 570: Going concern assessments
786        write_json_safe(
787            &result.audit.going_concern_assessments,
788            &audit_dir.join("going_concern_assessments.json"),
789            "Going concern assessments (ISA 570)",
790        );
791
792        // ISA 540: Accounting estimates
793        write_json_safe(
794            &result.audit.accounting_estimates,
795            &audit_dir.join("accounting_estimates.json"),
796            "Accounting estimates (ISA 540)",
797        );
798
799        // ISA 700/701/705/706: Audit opinions and Key Audit Matters.
800        // Always write even if the vec is empty; see the always-emit block
801        // below (outside the `engagements.is_empty()` guard) for the case
802        // where audit is entirely disabled — the files still appear in the
803        // archive with `[]` so SDK consumers don't get 404s on the manifest.
804        write_json_always(
805            &result.audit.audit_opinions,
806            &audit_dir.join("audit_opinions.json"),
807            "Audit opinions (ISA 700/705/706)",
808        );
809        write_json_always(
810            &result.audit.key_audit_matters,
811            &audit_dir.join("key_audit_matters.json"),
812            "Key Audit Matters (ISA 701)",
813        );
814
815        // SOX 302 / 404
816        if !result.audit.sox_302_certifications.is_empty() {
817            write_json_safe(
818                &result.audit.sox_302_certifications,
819                &audit_dir.join("sox_302_certifications.json"),
820                "SOX 302 certifications",
821            );
822            write_json_safe(
823                &result.audit.sox_404_assessments,
824                &audit_dir.join("sox_404_assessments.json"),
825                "SOX 404 ICFR assessments",
826            );
827        }
828
829        // ISA 320: Materiality calculations
830        if !result.audit.materiality_calculations.is_empty() {
831            write_json_safe(
832                &result.audit.materiality_calculations,
833                &audit_dir.join("materiality_calculations.json"),
834                "Materiality calculations (ISA 320)",
835            );
836        }
837
838        // ISA 315: Combined Risk Assessments
839        if !result.audit.combined_risk_assessments.is_empty() {
840            write_json_safe(
841                &result.audit.combined_risk_assessments,
842                &audit_dir.join("combined_risk_assessments.json"),
843                "Combined Risk Assessments (ISA 315)",
844            );
845        }
846
847        // ISA 530: Sampling Plans and Sampled Items
848        if !result.audit.sampling_plans.is_empty() {
849            write_json_safe(
850                &result.audit.sampling_plans,
851                &audit_dir.join("sampling_plans.json"),
852                "Sampling plans (ISA 530)",
853            );
854            write_json_safe(
855                &result.audit.sampled_items,
856                &audit_dir.join("sampled_items.json"),
857                "Sampled items (ISA 530)",
858            );
859        }
860
861        // ISA 315: Significant Classes of Transactions (SCOTS)
862        if !result.audit.significant_transaction_classes.is_empty() {
863            write_json_safe(
864                &result.audit.significant_transaction_classes,
865                &audit_dir.join("significant_transaction_classes.json"),
866                "Significant Classes of Transactions / SCOTS (ISA 315)",
867            );
868        }
869
870        // ISA 520: Unusual Item Markers
871        if !result.audit.unusual_items.is_empty() {
872            write_json_safe(
873                &result.audit.unusual_items,
874                &audit_dir.join("unusual_items.json"),
875                "Unusual item flags (ISA 520)",
876            );
877        }
878
879        // ISA 520: Analytical Relationships
880        if !result.audit.analytical_relationships.is_empty() {
881            write_json_safe(
882                &result.audit.analytical_relationships,
883                &audit_dir.join("analytical_relationships.json"),
884                "Analytical relationships (ISA 520)",
885            );
886        }
887
888        // PCAOB-ISA cross-reference mappings
889        if !result.audit.isa_pcaob_mappings.is_empty() {
890            write_json_safe(
891                &result.audit.isa_pcaob_mappings,
892                &audit_dir.join("isa_pcaob_mappings.json"),
893                "PCAOB-ISA standard mappings",
894            );
895        }
896
897        // ISA standard reference (number, title, series for all 34 ISA standards)
898        if !result.audit.isa_mappings.is_empty() {
899            write_json_safe(
900                &result.audit.isa_mappings,
901                &audit_dir.join("isa_mappings.json"),
902                "ISA standard reference mappings",
903            );
904        }
905
906        // FSM event trail (when audit.fsm.enabled: true)
907        if let Some(ref event_trail) = result.audit.fsm_event_trail {
908            if !event_trail.is_empty() {
909                write_json_safe(
910                    event_trail,
911                    &audit_dir.join("fsm_event_trail.json"),
912                    "FSM audit event trail",
913                );
914            }
915        }
916
917        // v3.3.0: legal documents (when compliance_regulations.legal_documents.enabled)
918        write_json_safe(
919            &result.audit.legal_documents,
920            &audit_dir.join("legal_documents.json"),
921            "Legal documents (v3.3.0)",
922        );
923
924        // v3.3.0: IT general controls — access logs + change records
925        write_json_safe(
926            &result.audit.it_controls_access_logs,
927            &audit_dir.join("it_controls_access_logs.json"),
928            "IT general controls — access logs (v3.3.0)",
929        );
930        write_json_safe(
931            &result.audit.it_controls_change_records,
932            &audit_dir.join("it_controls_change_records.json"),
933            "IT general controls — change management records (v3.3.0)",
934        );
935    } else {
936        // Audit phase disabled or ran with no engagements — still emit
937        // audit_opinions.json + key_audit_matters.json so the archive
938        // structure is consistent and SDK consumers can rely on these
939        // files always existing. v3.1 announced these as archive-shipping
940        // files; v3.1.1 guarantees it regardless of audit.enabled.
941        std::fs::create_dir_all(&audit_dir)?;
942        write_json_always(
943            &result.audit.audit_opinions,
944            &audit_dir.join("audit_opinions.json"),
945            "Audit opinions (ISA 700/705/706) — empty (audit phase disabled)",
946        );
947        write_json_always(
948            &result.audit.key_audit_matters,
949            &audit_dir.join("key_audit_matters.json"),
950            "Key Audit Matters (ISA 701) — empty (audit phase disabled)",
951        );
952    }
953
954    // ========================================================================
955    // Banking (JSON - keep existing format for backward compat)
956    // ========================================================================
957    let banking_dir = output_dir.join("banking");
958    if !result.banking.customers.is_empty() {
959        std::fs::create_dir_all(&banking_dir)?;
960        info!("Writing banking data...");
961
962        // v4.4.2: dual-key risk tier. SDK consumers inspect `risk_level`;
963        // the struct stores it as `risk_tier` for historical reasons.
964        // Serialize through a `serde_json::Value` so we can inject the
965        // `risk_level` alias key on every customer row without touching
966        // the `BankingCustomer` Serialize impl (which has 40+ fields).
967        match serde_json::to_value(&result.banking.customers) {
968            Ok(mut v) => {
969                if let Some(arr) = v.as_array_mut() {
970                    for c in arr.iter_mut() {
971                        if let Some(obj) = c.as_object_mut() {
972                            if let Some(rt) = obj.get("risk_tier").cloned() {
973                                obj.entry("risk_level").or_insert(rt);
974                            }
975                        }
976                    }
977                }
978                match serde_json::to_string_pretty(&v) {
979                    Ok(json) => {
980                        let path = banking_dir.join("banking_customers.json");
981                        if let Err(e) = std::fs::write(&path, json) {
982                            warn!("Failed to write banking_customers.json: {}", e);
983                        } else {
984                            info!(
985                                "  Banking customers written: {} records -> {}",
986                                result.banking.customers.len(),
987                                path.display()
988                            );
989                        }
990                    }
991                    Err(e) => warn!("Failed to serialize banking customers: {}", e),
992                }
993            }
994            Err(e) => warn!("Failed to build banking customers Value: {}", e),
995        }
996        write_json_safe(
997            &result.banking.accounts,
998            &banking_dir.join("banking_accounts.json"),
999            "Banking accounts",
1000        );
1001        write_json_safe(
1002            &result.banking.transactions,
1003            &banking_dir.join("banking_transactions.json"),
1004            "Banking transactions",
1005        );
1006        write_json_safe(
1007            &result.banking.transaction_labels,
1008            &banking_dir.join("aml_transaction_labels.json"),
1009            "AML transaction labels",
1010        );
1011        write_json_safe(
1012            &result.banking.customer_labels,
1013            &banking_dir.join("aml_customer_labels.json"),
1014            "AML customer labels",
1015        );
1016        write_json_safe(
1017            &result.banking.account_labels,
1018            &banking_dir.join("aml_account_labels.json"),
1019            "AML account labels",
1020        );
1021        write_json_safe(
1022            &result.banking.relationship_labels,
1023            &banking_dir.join("aml_relationship_labels.json"),
1024            "AML relationship labels",
1025        );
1026        write_json_safe(
1027            &result.banking.narratives,
1028            &banking_dir.join("aml_narratives.json"),
1029            "AML narratives",
1030        );
1031    }
1032
1033    // ========================================================================
1034    // Sourcing (S2C)
1035    // ========================================================================
1036    let s2c_dir = output_dir.join("sourcing");
1037    if !result.sourcing.spend_analyses.is_empty() || !result.sourcing.sourcing_projects.is_empty() {
1038        std::fs::create_dir_all(&s2c_dir)?;
1039        info!("Writing sourcing (S2C) data...");
1040
1041        write_json_safe(
1042            &result.sourcing.spend_analyses,
1043            &s2c_dir.join("spend_analyses.json"),
1044            "Spend analyses",
1045        );
1046        write_json_safe(
1047            &result.sourcing.sourcing_projects,
1048            &s2c_dir.join("sourcing_projects.json"),
1049            "Sourcing projects",
1050        );
1051        write_json_safe(
1052            &result.sourcing.qualifications,
1053            &s2c_dir.join("supplier_qualifications.json"),
1054            "Supplier qualifications",
1055        );
1056        write_json_safe(
1057            &result.sourcing.rfx_events,
1058            &s2c_dir.join("rfx_events.json"),
1059            "RFx events",
1060        );
1061        write_json_safe(
1062            &result.sourcing.bids,
1063            &s2c_dir.join("supplier_bids.json"),
1064            "Supplier bids",
1065        );
1066        write_json_safe(
1067            &result.sourcing.bid_evaluations,
1068            &s2c_dir.join("bid_evaluations.json"),
1069            "Bid evaluations",
1070        );
1071        write_json_safe(
1072            &result.sourcing.contracts,
1073            &s2c_dir.join("procurement_contracts.json"),
1074            "Procurement contracts",
1075        );
1076        write_json_safe(
1077            &result.sourcing.catalog_items,
1078            &s2c_dir.join("catalog_items.json"),
1079            "Catalog items",
1080        );
1081        write_json_safe(
1082            &result.sourcing.scorecards,
1083            &s2c_dir.join("supplier_scorecards.json"),
1084            "Supplier scorecards",
1085        );
1086    }
1087
1088    // ========================================================================
1089    // Intercompany
1090    // ========================================================================
1091    let ic_dir = output_dir.join("intercompany");
1092    if result.intercompany.group_structure.is_some()
1093        || !result.intercompany.matched_pairs.is_empty()
1094    {
1095        std::fs::create_dir_all(&ic_dir)?;
1096        info!("Writing intercompany data...");
1097
1098        // Always write group structure when present (independent of IC transactions).
1099        if let Some(gs) = &result.intercompany.group_structure {
1100            write_json_single_safe(gs, &ic_dir.join("group_structure.json"), "Group structure");
1101        }
1102
1103        write_json_safe(
1104            &result.intercompany.matched_pairs,
1105            &ic_dir.join("ic_matched_pairs.json"),
1106            "IC matched pairs",
1107        );
1108        write_json_safe(
1109            &result.intercompany.seller_journal_entries,
1110            &ic_dir.join("ic_seller_journal_entries.json"),
1111            "IC seller journal entries",
1112        );
1113        write_json_safe(
1114            &result.intercompany.buyer_journal_entries,
1115            &ic_dir.join("ic_buyer_journal_entries.json"),
1116            "IC buyer journal entries",
1117        );
1118        write_json_safe(
1119            &result.intercompany.elimination_entries,
1120            &ic_dir.join("ic_elimination_entries.json"),
1121            "IC elimination entries",
1122        );
1123
1124        // NCI measurements from group structure ownership percentages
1125        if !result.intercompany.nci_measurements.is_empty() {
1126            write_json_safe(
1127                &result.intercompany.nci_measurements,
1128                &ic_dir.join("nci_measurements.json"),
1129                "NCI measurements",
1130            );
1131        }
1132    }
1133
1134    // ========================================================================
1135    // Financial Reporting
1136    // ========================================================================
1137    let fin_dir = output_dir.join("financial_reporting");
1138    if !result.financial_reporting.financial_statements.is_empty()
1139        || !result.financial_reporting.bank_reconciliations.is_empty()
1140        || !result
1141            .financial_reporting
1142            .consolidated_statements
1143            .is_empty()
1144    {
1145        std::fs::create_dir_all(&fin_dir)?;
1146        info!("Writing financial reporting data...");
1147
1148        // Legacy flat file (all standalone statements combined)
1149        write_json_safe(
1150            &result.financial_reporting.financial_statements,
1151            &fin_dir.join("financial_statements.json"),
1152            "Financial statements",
1153        );
1154
1155        // Per-entity standalone statements
1156        if !result.financial_reporting.standalone_statements.is_empty() {
1157            let standalone_dir = fin_dir.join("standalone");
1158            std::fs::create_dir_all(&standalone_dir)?;
1159            for (entity_code, stmts) in &result.financial_reporting.standalone_statements {
1160                let file_name = format!("{}_financial_statements.json", entity_code);
1161                write_json_safe(
1162                    stmts,
1163                    &standalone_dir.join(&file_name),
1164                    &format!("Standalone statements for {}", entity_code),
1165                );
1166            }
1167        }
1168
1169        // Consolidated statements + schedule
1170        if !result
1171            .financial_reporting
1172            .consolidated_statements
1173            .is_empty()
1174            || !result
1175                .financial_reporting
1176                .consolidation_schedules
1177                .is_empty()
1178        {
1179            let consolidated_dir = fin_dir.join("consolidated");
1180            std::fs::create_dir_all(&consolidated_dir)?;
1181            write_json_safe(
1182                &result.financial_reporting.consolidated_statements,
1183                &consolidated_dir.join("consolidated_financial_statements.json"),
1184                "Consolidated financial statements",
1185            );
1186            write_json_safe(
1187                &result.financial_reporting.consolidation_schedules,
1188                &consolidated_dir.join("consolidation_schedule.json"),
1189                "Consolidation schedule",
1190            );
1191        }
1192
1193        write_json_safe(
1194            &result.financial_reporting.bank_reconciliations,
1195            &fin_dir.join("bank_reconciliations.json"),
1196            "Bank reconciliations",
1197        );
1198
1199        // IFRS 8 / ASC 280 Segment Reporting
1200        if !result.financial_reporting.segment_reports.is_empty()
1201            || !result
1202                .financial_reporting
1203                .segment_reconciliations
1204                .is_empty()
1205        {
1206            let seg_dir = fin_dir.join("segment_reporting");
1207            std::fs::create_dir_all(&seg_dir)?;
1208            write_json_safe(
1209                &result.financial_reporting.segment_reports,
1210                &seg_dir.join("segment_reports.json"),
1211                "Segment reports",
1212            );
1213            write_json_safe(
1214                &result.financial_reporting.segment_reconciliations,
1215                &seg_dir.join("segment_reconciliations.json"),
1216                "Segment reconciliations",
1217            );
1218        }
1219
1220        // IAS 1 / ASC 235: Notes to financial statements
1221        write_json_safe(
1222            &result.financial_reporting.notes_to_financial_statements,
1223            &fin_dir.join("notes_to_financial_statements.json"),
1224            "Notes to financial statements",
1225        );
1226    }
1227
1228    // ========================================================================
1229    // Period-Close Trial Balances
1230    // ========================================================================
1231    //
1232    // v5.1: convert each in-memory `PeriodTrialBalance` to the
1233    // canonical `datasynth_core::models::balance::TrialBalance` before
1234    // writing.  The on-disk shape is now identical to what the group
1235    // aggregate phase loads via `tb_loader::load_entity_trial_balance`,
1236    // so the loader's v5.0 dual-shape detection (`PeriodTrialBalanceOnDisk`
1237    // → `TrialBalance` synthesis) is no longer required.
1238    if !result.financial_reporting.trial_balances.is_empty() {
1239        let pc_dir = output_dir.join("period_close");
1240        std::fs::create_dir_all(&pc_dir)?;
1241        info!(
1242            "Writing {} period-close trial balances...",
1243            result.financial_reporting.trial_balances.len()
1244        );
1245        // Pick the first JE's company_code + currency as the
1246        // canonical identifiers; the orchestrator only emits one TB
1247        // per period (gated by `if company_idx == 0` at the push
1248        // site), so all trial-balance entries belong to that company.
1249        // Fallback to safe defaults when the JE list is empty
1250        // (effectively only test fixtures).
1251        let (company_code, currency) = result
1252            .journal_entries
1253            .first()
1254            .map(|je| (je.header.company_code.as_str(), je.header.currency.as_str()))
1255            .unwrap_or(("UNKNOWN", "USD"));
1256        let canonical: Vec<datasynth_core::models::balance::TrialBalance> = result
1257            .financial_reporting
1258            .trial_balances
1259            .iter()
1260            .cloned()
1261            .map(|tb| tb.into_canonical(company_code, currency))
1262            .collect();
1263        write_json_safe(
1264            &canonical,
1265            &pc_dir.join("trial_balances.json"),
1266            "Period-close trial balances (canonical)",
1267        );
1268    }
1269
1270    // ========================================================================
1271    // Balance: Opening Balances + GL-Subledger Reconciliation
1272    // ========================================================================
1273    if !result.opening_balances.is_empty() || !result.subledger_reconciliation.is_empty() {
1274        let balance_dir = output_dir.join("balance");
1275        std::fs::create_dir_all(&balance_dir)?;
1276        info!("Writing balance data...");
1277
1278        write_json_safe(
1279            &result.opening_balances,
1280            &balance_dir.join("opening_balances.json"),
1281            "Opening balances",
1282        );
1283        write_json_safe(
1284            &result.subledger_reconciliation,
1285            &balance_dir.join("subledger_reconciliation.json"),
1286            "Subledger reconciliation",
1287        );
1288    }
1289
1290    // ========================================================================
1291    // HR (Payroll, Time Entries, Expense Reports, Benefit Enrollments, Pensions)
1292    // ========================================================================
1293    let hr_dir = output_dir.join("hr");
1294    if !result.hr.payroll_runs.is_empty()
1295        || !result.hr.time_entries.is_empty()
1296        || !result.hr.expense_reports.is_empty()
1297        || !result.hr.benefit_enrollments.is_empty()
1298        || !result.hr.pension_plans.is_empty()
1299        || !result.hr.stock_grants.is_empty()
1300        || !result.master_data.employee_change_history.is_empty()
1301    {
1302        std::fs::create_dir_all(&hr_dir)?;
1303        info!("Writing HR data...");
1304
1305        write_json_safe(
1306            &result.hr.payroll_runs,
1307            &hr_dir.join("payroll_runs.json"),
1308            "Payroll runs",
1309        );
1310        write_json_safe(
1311            &result.hr.payroll_line_items,
1312            &hr_dir.join("payroll_line_items.json"),
1313            "Payroll line items",
1314        );
1315        write_json_safe(
1316            &result.hr.time_entries,
1317            &hr_dir.join("time_entries.json"),
1318            "Time entries",
1319        );
1320        write_json_safe(
1321            &result.hr.expense_reports,
1322            &hr_dir.join("expense_reports.json"),
1323            "Expense reports",
1324        );
1325        write_json_safe(
1326            &result.hr.benefit_enrollments,
1327            &hr_dir.join("benefit_enrollments.json"),
1328            "Benefit enrollments",
1329        );
1330        write_json_safe(
1331            &result.hr.pension_plans,
1332            &hr_dir.join("pension_plans.json"),
1333            "Pension plans",
1334        );
1335        write_json_safe(
1336            &result.hr.pension_obligations,
1337            &hr_dir.join("pension_obligations.json"),
1338            "Pension obligations",
1339        );
1340        write_json_safe(
1341            &result.hr.pension_plan_assets,
1342            &hr_dir.join("plan_assets.json"),
1343            "Plan assets",
1344        );
1345        write_json_safe(
1346            &result.hr.pension_disclosures,
1347            &hr_dir.join("pension_disclosures.json"),
1348            "Pension disclosures",
1349        );
1350        write_json_safe(
1351            &result.hr.stock_grants,
1352            &hr_dir.join("stock_grants.json"),
1353            "Stock grants",
1354        );
1355        write_json_safe(
1356            &result.hr.stock_comp_expenses,
1357            &hr_dir.join("stock_comp_expense.json"),
1358            "Stock comp expense",
1359        );
1360        write_json_safe(
1361            &result.master_data.employee_change_history,
1362            &hr_dir.join("employee_change_history.json"),
1363            "Employee change history",
1364        );
1365    }
1366
1367    // ========================================================================
1368    // Manufacturing
1369    // ========================================================================
1370    let mfg_dir = output_dir.join("manufacturing");
1371    if !result.manufacturing.production_orders.is_empty()
1372        || !result.manufacturing.quality_inspections.is_empty()
1373        || !result.manufacturing.cycle_counts.is_empty()
1374        || !result.manufacturing.bom_components.is_empty()
1375        || !result.manufacturing.inventory_movements.is_empty()
1376    {
1377        std::fs::create_dir_all(&mfg_dir)?;
1378        info!("Writing manufacturing data...");
1379
1380        write_json_safe(
1381            &result.manufacturing.production_orders,
1382            &mfg_dir.join("production_orders.json"),
1383            "Production orders",
1384        );
1385        write_json_safe(
1386            &result.manufacturing.quality_inspections,
1387            &mfg_dir.join("quality_inspections.json"),
1388            "Quality inspections",
1389        );
1390        write_json_safe(
1391            &result.manufacturing.cycle_counts,
1392            &mfg_dir.join("cycle_counts.json"),
1393            "Cycle counts",
1394        );
1395        write_json_safe(
1396            &result.manufacturing.bom_components,
1397            &mfg_dir.join("bom_components.json"),
1398            "BOM components",
1399        );
1400        write_json_safe(
1401            &result.manufacturing.inventory_movements,
1402            &mfg_dir.join("inventory_movements.json"),
1403            "Inventory movements",
1404        );
1405    }
1406
1407    // ========================================================================
1408    // Sales, KPIs, Budgets
1409    // ========================================================================
1410    let sales_dir = output_dir.join("sales_kpi_budgets");
1411    if !result.sales_kpi_budgets.sales_quotes.is_empty()
1412        || !result.sales_kpi_budgets.kpis.is_empty()
1413        || !result.sales_kpi_budgets.budgets.is_empty()
1414    {
1415        std::fs::create_dir_all(&sales_dir)?;
1416        info!("Writing sales, KPI, and budget data...");
1417
1418        write_json_safe(
1419            &result.sales_kpi_budgets.sales_quotes,
1420            &sales_dir.join("sales_quotes.json"),
1421            "Sales quotes",
1422        );
1423        write_json_safe(
1424            &result.sales_kpi_budgets.kpis,
1425            &sales_dir.join("management_kpis.json"),
1426            "Management KPIs",
1427        );
1428        write_json_safe(
1429            &result.sales_kpi_budgets.budgets,
1430            &sales_dir.join("budgets.json"),
1431            "Budgets",
1432        );
1433    }
1434
1435    // ========================================================================
1436    // Tax
1437    // ========================================================================
1438    let tax_dir = output_dir.join("tax");
1439    if !result.tax.jurisdictions.is_empty()
1440        || !result.tax.codes.is_empty()
1441        || !result.tax.tax_provisions.is_empty()
1442    {
1443        std::fs::create_dir_all(&tax_dir)?;
1444        info!("Writing tax data...");
1445
1446        write_json_safe(
1447            &result.tax.jurisdictions,
1448            &tax_dir.join("tax_jurisdictions.json"),
1449            "Tax jurisdictions",
1450        );
1451        write_json_safe(
1452            &result.tax.codes,
1453            &tax_dir.join("tax_codes.json"),
1454            "Tax codes",
1455        );
1456        write_json_safe(
1457            &result.tax.tax_provisions,
1458            &tax_dir.join("tax_provisions.json"),
1459            "Tax provisions",
1460        );
1461        write_json_safe(
1462            &result.tax.tax_lines,
1463            &tax_dir.join("tax_lines.json"),
1464            "Tax lines",
1465        );
1466        write_json_safe(
1467            &result.tax.tax_returns,
1468            &tax_dir.join("tax_returns.json"),
1469            "Tax returns",
1470        );
1471        write_json_safe(
1472            &result.tax.withholding_records,
1473            &tax_dir.join("withholding_records.json"),
1474            "Withholding tax records",
1475        );
1476        if !result.tax.tax_anomaly_labels.is_empty() {
1477            write_json_safe(
1478                &result.tax.tax_anomaly_labels,
1479                &tax_dir.join("tax_anomaly_labels.json"),
1480                "Tax anomaly labels",
1481            );
1482        }
1483        // Deferred tax engine output (IAS 12 / ASC 740)
1484        if !result.tax.deferred_tax.temporary_differences.is_empty() {
1485            write_json_safe(
1486                &result.tax.deferred_tax.temporary_differences,
1487                &tax_dir.join("temporary_differences.json"),
1488                "Temporary differences",
1489            );
1490            write_json_safe(
1491                &result.tax.deferred_tax.etr_reconciliations,
1492                &tax_dir.join("etr_reconciliation.json"),
1493                "ETR reconciliation",
1494            );
1495            write_json_safe(
1496                &result.tax.deferred_tax.rollforwards,
1497                &tax_dir.join("deferred_tax_rollforward.json"),
1498                "Deferred tax rollforward",
1499            );
1500            write_json_safe(
1501                &result.tax.deferred_tax.journal_entries,
1502                &tax_dir.join("deferred_tax_journal_entries.json"),
1503                "Deferred tax journal entries",
1504            );
1505        }
1506    }
1507
1508    // ========================================================================
1509    // ESG
1510    // ========================================================================
1511    let esg_dir = output_dir.join("esg");
1512    if !result.esg.emissions.is_empty()
1513        || !result.esg.energy.is_empty()
1514        || !result.esg.diversity.is_empty()
1515        || !result.esg.governance.is_empty()
1516    {
1517        std::fs::create_dir_all(&esg_dir)?;
1518        info!("Writing ESG data...");
1519
1520        write_json_safe(
1521            &result.esg.emissions,
1522            &esg_dir.join("emission_records.json"),
1523            "Emission records",
1524        );
1525        write_json_safe(
1526            &result.esg.energy,
1527            &esg_dir.join("energy_consumption.json"),
1528            "Energy consumption",
1529        );
1530        write_json_safe(
1531            &result.esg.water,
1532            &esg_dir.join("water_usage.json"),
1533            "Water usage",
1534        );
1535        write_json_safe(
1536            &result.esg.waste,
1537            &esg_dir.join("waste_records.json"),
1538            "Waste records",
1539        );
1540        write_json_safe(
1541            &result.esg.diversity,
1542            &esg_dir.join("workforce_diversity.json"),
1543            "Workforce diversity",
1544        );
1545        write_json_safe(
1546            &result.esg.pay_equity,
1547            &esg_dir.join("pay_equity.json"),
1548            "Pay equity",
1549        );
1550        write_json_safe(
1551            &result.esg.safety_incidents,
1552            &esg_dir.join("safety_incidents.json"),
1553            "Safety incidents",
1554        );
1555        write_json_safe(
1556            &result.esg.safety_metrics,
1557            &esg_dir.join("safety_metrics.json"),
1558            "Safety metrics",
1559        );
1560        write_json_safe(
1561            &result.esg.governance,
1562            &esg_dir.join("governance_metrics.json"),
1563            "Governance metrics",
1564        );
1565        write_json_safe(
1566            &result.esg.supplier_assessments,
1567            &esg_dir.join("supplier_esg_assessments.json"),
1568            "Supplier ESG assessments",
1569        );
1570        write_json_safe(
1571            &result.esg.materiality,
1572            &esg_dir.join("materiality_assessments.json"),
1573            "Materiality assessments",
1574        );
1575        write_json_safe(
1576            &result.esg.disclosures,
1577            &esg_dir.join("esg_disclosures.json"),
1578            "ESG disclosures",
1579        );
1580        write_json_safe(
1581            &result.esg.climate_scenarios,
1582            &esg_dir.join("climate_scenarios.json"),
1583            "Climate scenarios",
1584        );
1585        write_json_safe(
1586            &result.esg.anomaly_labels,
1587            &esg_dir.join("esg_anomaly_labels.json"),
1588            "ESG anomaly labels",
1589        );
1590    }
1591
1592    // ========================================================================
1593    // Process Mining (OCPM)
1594    // ========================================================================
1595    if let Some(ref event_log) = result.ocpm.event_log {
1596        if !event_log.events.is_empty() || !event_log.objects.is_empty() {
1597            let pm_dir = output_dir.join("process_mining");
1598            std::fs::create_dir_all(&pm_dir)?;
1599            info!("Writing process mining (OCPM) data...");
1600
1601            // Write the full OCEL 2.0 event log. v4.4.2+ patches every
1602            // `object_refs[*].object_type_id` with a companion
1603            // `object_type` key, matching the OCEL 2.0 spec and SDK
1604            // consumer expectations that previously saw `object_type`
1605            // arrive as null. See `add_ocel_object_type_alias` below.
1606            match serde_json::to_value(event_log) {
1607                Ok(mut v) => {
1608                    add_ocel_object_type_alias(&mut v);
1609                    match serde_json::to_string_pretty(&v) {
1610                        Ok(json) => {
1611                            if let Err(e) = std::fs::write(pm_dir.join("event_log.json"), json) {
1612                                warn!("Failed to write OCPM event log: {}", e);
1613                            } else {
1614                                info!(
1615                                    "  Event log written: {} events, {} objects",
1616                                    result.ocpm.event_count, result.ocpm.object_count
1617                                );
1618                            }
1619                        }
1620                        Err(e) => warn!("Failed to serialize OCPM event log: {}", e),
1621                    }
1622                }
1623                Err(e) => warn!("Failed to build OCPM event log Value: {}", e),
1624            }
1625
1626            // Write events separately for easy consumption
1627            if !event_log.events.is_empty() {
1628                match serde_json::to_string_pretty(&event_log.events) {
1629                    Ok(json) => {
1630                        if let Err(e) = std::fs::write(pm_dir.join("events.json"), json) {
1631                            warn!("Failed to write OCPM events: {}", e);
1632                        } else {
1633                            info!("  Events written: {} records", event_log.events.len());
1634                        }
1635                    }
1636                    Err(e) => warn!("Failed to serialize OCPM events: {}", e),
1637                }
1638            }
1639
1640            // Write objects separately for easy consumption
1641            if !event_log.objects.is_empty() {
1642                let objects: Vec<&_> = event_log.objects.iter().collect();
1643                match serde_json::to_string_pretty(&objects) {
1644                    Ok(json) => {
1645                        if let Err(e) = std::fs::write(pm_dir.join("objects.json"), json) {
1646                            warn!("Failed to write OCPM objects: {}", e);
1647                        } else {
1648                            info!("  Objects written: {} records", event_log.objects.len());
1649                        }
1650                    }
1651                    Err(e) => warn!("Failed to serialize OCPM objects: {}", e),
1652                }
1653            }
1654
1655            // Write process variants if any were computed
1656            if !event_log.variants.is_empty() {
1657                let variants: Vec<&_> = event_log.variants.values().collect();
1658                match serde_json::to_string_pretty(&variants) {
1659                    Ok(json) => {
1660                        if let Err(e) = std::fs::write(pm_dir.join("process_variants.json"), json) {
1661                            warn!("Failed to write process variants: {}", e);
1662                        } else {
1663                            info!(
1664                                "  Process variants written: {} variants",
1665                                event_log.variants.len()
1666                            );
1667                        }
1668                    }
1669                    Err(e) => warn!("Failed to serialize process variants: {}", e),
1670                }
1671            }
1672        }
1673    }
1674
1675    // ========================================================================
1676    // Chart of Accounts
1677    // ========================================================================
1678    // Primary file: flat array of accounts (shape stable since v3.x —
1679    // consumers iterate over it).
1680    match serde_json::to_string_pretty(&result.chart_of_accounts.accounts) {
1681        Ok(json) => {
1682            if let Err(e) = std::fs::write(output_dir.join("chart_of_accounts.json"), json) {
1683                warn!("Failed to write chart of accounts: {}", e);
1684            } else {
1685                info!("  Chart of accounts written");
1686            }
1687        }
1688        Err(e) => warn!("Failed to serialize chart of accounts: {}", e),
1689    }
1690    // v4.4.1 — companion metadata file so SDK consumers can read the
1691    // accounting framework + complexity + ID without having to infer
1692    // them from each account row. The SDK team flagged
1693    // `CoA.accounting_framework` arriving as null in v4.1.x; the field
1694    // didn't exist at all until v4.4.1.
1695    let coa_meta = serde_json::json!({
1696        "coa_id": result.chart_of_accounts.coa_id,
1697        "name": result.chart_of_accounts.name,
1698        "country": result.chart_of_accounts.country,
1699        "industry": result.chart_of_accounts.industry,
1700        "complexity": result.chart_of_accounts.complexity,
1701        "account_format": result.chart_of_accounts.account_format,
1702        "accounting_framework": result.chart_of_accounts.accounting_framework,
1703        "account_count": result.chart_of_accounts.accounts.len(),
1704    });
1705    match serde_json::to_string_pretty(&coa_meta) {
1706        Ok(json) => {
1707            if let Err(e) = std::fs::write(output_dir.join("chart_of_accounts_meta.json"), json) {
1708                warn!("Failed to write CoA metadata: {}", e);
1709            } else {
1710                info!(
1711                    "  Chart of accounts metadata written (accounting_framework: {:?})",
1712                    result.chart_of_accounts.accounting_framework
1713                );
1714            }
1715        }
1716        Err(e) => warn!("Failed to serialize CoA metadata: {}", e),
1717    }
1718
1719    // ========================================================================
1720    // Balance Validation Summary
1721    // ========================================================================
1722    if result.balance_validation.validated {
1723        match serde_json::to_string_pretty(&BalanceValidationSummary::from(
1724            &result.balance_validation,
1725        )) {
1726            Ok(json) => {
1727                if let Err(e) = std::fs::write(output_dir.join("balance_validation.json"), json) {
1728                    warn!("Failed to write balance validation: {}", e);
1729                } else {
1730                    info!("  Balance validation summary written");
1731                }
1732            }
1733            Err(e) => warn!("Failed to serialize balance validation: {}", e),
1734        }
1735    }
1736
1737    // ========================================================================
1738    // Data Quality Statistics (now serializable directly via Serialize derives)
1739    // ========================================================================
1740    {
1741        match serde_json::to_string_pretty(&result.data_quality_stats) {
1742            Ok(json) => {
1743                if let Err(e) = std::fs::write(output_dir.join("data_quality_stats.json"), json) {
1744                    warn!("Failed to write data quality stats: {}", e);
1745                } else {
1746                    info!("  Data quality stats written (full detail)");
1747                }
1748            }
1749            Err(e) => warn!("Failed to serialize data quality stats: {}", e),
1750        }
1751    }
1752
1753    // ========================================================================
1754    // v3.3.0: Analytics-metadata phase outputs (prior year, industry
1755    // benchmarks, management reports, drift events).
1756    // ========================================================================
1757    {
1758        let am = &result.analytics_metadata;
1759        if !am.prior_year_comparatives.is_empty()
1760            || !am.industry_benchmarks.is_empty()
1761            || !am.management_reports.is_empty()
1762            || !am.drift_events.is_empty()
1763        {
1764            let analytics_dir = output_dir.join("analytics");
1765            std::fs::create_dir_all(&analytics_dir)?;
1766            write_json_safe(
1767                &am.prior_year_comparatives,
1768                &analytics_dir.join("prior_year_comparatives.json"),
1769                "Prior-year comparatives (v3.3.0)",
1770            );
1771            write_json_safe(
1772                &am.industry_benchmarks,
1773                &analytics_dir.join("industry_benchmarks.json"),
1774                "Industry benchmarks (v3.3.0)",
1775            );
1776            write_json_safe(
1777                &am.management_reports,
1778                &analytics_dir.join("management_reports.json"),
1779                "Management reports (v3.3.0)",
1780            );
1781            write_json_safe(
1782                &am.drift_events,
1783                &analytics_dir.join("drift_events.json"),
1784                "Drift event labels (v3.3.0)",
1785            );
1786        }
1787    }
1788
1789    // ========================================================================
1790    // Pre-built Analytics (Benford, amount distribution, process variants)
1791    // ========================================================================
1792    {
1793        let analytics_dir = output_dir.join("analytics");
1794
1795        // Collect non-zero amounts from journal entry lines
1796        let amounts: Vec<_> = result
1797            .journal_entries
1798            .iter()
1799            .flat_map(|je| je.lines.iter())
1800            .flat_map(|line| {
1801                let d = (!line.debit_amount.is_zero()).then_some(line.debit_amount);
1802                let c = (!line.credit_amount.is_zero()).then_some(line.credit_amount);
1803                d.into_iter().chain(c)
1804            })
1805            .collect();
1806
1807        if amounts.len() >= 10 {
1808            std::fs::create_dir_all(&analytics_dir)?;
1809            info!("Writing pre-built analytics ({} amounts)...", amounts.len());
1810
1811            // Benford's Law analysis
1812            let benford_analyzer = datasynth_eval::BenfordAnalyzer::default();
1813            match benford_analyzer.analyze(&amounts) {
1814                Ok(ref benford_result) => {
1815                    if let Ok(json) = serde_json::to_string_pretty(benford_result) {
1816                        if let Err(e) =
1817                            std::fs::write(analytics_dir.join("benford_analysis.json"), json)
1818                        {
1819                            warn!("Failed to write Benford analysis: {}", e);
1820                        } else {
1821                            info!(
1822                                "  Benford analysis written (conformity: {:?}, MAD: {:.4})",
1823                                benford_result.conformity, benford_result.mad
1824                            );
1825                        }
1826                    }
1827                }
1828                Err(e) => warn!("Benford analysis skipped: {}", e),
1829            }
1830
1831            // Amount distribution analysis
1832            let amount_analyzer = datasynth_eval::AmountDistributionAnalyzer::new();
1833            match amount_analyzer.analyze(&amounts) {
1834                Ok(ref dist_result) => {
1835                    if let Ok(json) = serde_json::to_string_pretty(dist_result) {
1836                        if let Err(e) =
1837                            std::fs::write(analytics_dir.join("amount_distribution.json"), json)
1838                        {
1839                            warn!("Failed to write amount distribution: {}", e);
1840                        } else {
1841                            info!(
1842                                "  Amount distribution written (skewness: {:.2}, kurtosis: {:.2})",
1843                                dist_result.skewness, dist_result.kurtosis
1844                            );
1845                        }
1846                    }
1847                }
1848                Err(e) => warn!("Amount distribution analysis skipped: {}", e),
1849            }
1850        }
1851
1852        // Process variant summary (from OCPM event log).
1853        //
1854        // v3.1.1 — always emit the file when an event_log exists. When the
1855        // event_log has no pre-computed `variants` map (older OCPM phases
1856        // didn't populate it), derive variants on the fly from the raw
1857        // events so SDK consumers see `analytics/process_variant_summary.json`
1858        // in every archive rather than `null`. Without this, the v3.1
1859        // claim that the file exists was only true when OCPM happened to
1860        // populate its variants map.
1861        if let Some(ref event_log) = result.ocpm.event_log {
1862            std::fs::create_dir_all(&analytics_dir)?;
1863            let variant_data: Vec<datasynth_eval::VariantData> = if !event_log.variants.is_empty() {
1864                event_log
1865                    .variants
1866                    .values()
1867                    .map(|v| datasynth_eval::VariantData {
1868                        variant_id: v.variant_id.clone(),
1869                        case_count: v.frequency as usize,
1870                        is_happy_path: v.is_happy_path,
1871                    })
1872                    .collect()
1873            } else {
1874                // Fallback: derive variants from raw events by case_id.
1875                // Each case's activity sequence (by activity_id) defines a
1876                // variant; cases with the same sequence collapse into one
1877                // variant. Events without a case_id are skipped since they
1878                // can't be grouped into a process instance.
1879                use std::collections::HashMap;
1880                // Key by case_id's string form to avoid pulling the uuid
1881                // crate into the output writer's dependency graph.
1882                let mut per_case: HashMap<String, Vec<String>> = HashMap::new();
1883                for ev in &event_log.events {
1884                    if let Some(case_id) = ev.case_id {
1885                        per_case
1886                            .entry(case_id.to_string())
1887                            .or_default()
1888                            .push(ev.activity_id.clone());
1889                    }
1890                }
1891                let mut variant_counts: HashMap<Vec<String>, usize> = HashMap::new();
1892                for activities in per_case.into_values() {
1893                    *variant_counts.entry(activities).or_insert(0) += 1;
1894                }
1895                // Happy path heuristic: the highest-frequency variant.
1896                let max_count = variant_counts.values().copied().max().unwrap_or(0);
1897                variant_counts
1898                    .into_iter()
1899                    .enumerate()
1900                    .map(|(i, (seq, count))| datasynth_eval::VariantData {
1901                        variant_id: format!("V{i:04}:{}", seq.join("->")),
1902                        case_count: count,
1903                        is_happy_path: count == max_count && max_count > 0,
1904                    })
1905                    .collect()
1906            };
1907
1908            let variant_analyzer = datasynth_eval::VariantAnalyzer::new();
1909            match variant_analyzer.analyze(&variant_data) {
1910                Ok(ref variant_result) => {
1911                    if let Ok(json) = serde_json::to_string_pretty(variant_result) {
1912                        if let Err(e) =
1913                            std::fs::write(analytics_dir.join("process_variant_summary.json"), json)
1914                        {
1915                            warn!("Failed to write variant summary: {}", e);
1916                        } else {
1917                            info!(
1918                                "  Process variant summary written ({} variants, entropy: {:.2})",
1919                                variant_result.variant_count, variant_result.variant_entropy
1920                            );
1921                        }
1922                    }
1923                }
1924                Err(e) => {
1925                    // Even on analyzer error, emit a minimal JSON placeholder
1926                    // so the file always exists in the archive.
1927                    warn!("Variant analysis failed: {}; emitting empty summary", e);
1928                    let placeholder = serde_json::json!({
1929                        "variant_count": 0,
1930                        "variant_entropy": null,
1931                        "happy_path_concentration": null,
1932                        "top_variants": [],
1933                        "passes": false,
1934                        "issues": [format!("analyzer error: {e}")],
1935                    });
1936                    if let Ok(json) = serde_json::to_string_pretty(&placeholder) {
1937                        let _ = std::fs::write(
1938                            analytics_dir.join("process_variant_summary.json"),
1939                            json,
1940                        );
1941                    }
1942                }
1943            }
1944        }
1945
1946        // Banking evaluation (KYC completeness + AML detectability).
1947        // Matches the payload served by /v1/jobs/{id}/analytics so
1948        // archive-mode consumers see the same four files the endpoint returns.
1949        if !result.banking.customers.is_empty() {
1950            use datasynth_core::models::banking::BankingCustomerType;
1951            use datasynth_eval::banking::{
1952                AmlDetectabilityAnalyzer, AmlTransactionData, BankingEvaluation,
1953                KycCompletenessAnalyzer, KycProfileData, TypologyData,
1954            };
1955            use std::collections::HashMap;
1956            std::fs::create_dir_all(&analytics_dir)?;
1957
1958            let kyc_data: Vec<KycProfileData> = result
1959                .banking
1960                .customers
1961                .iter()
1962                .map(|c| KycProfileData {
1963                    profile_id: c.customer_id.to_string(),
1964                    has_name: true,
1965                    has_dob: c.date_of_birth.is_some(),
1966                    has_address: c.address_line1.is_some(),
1967                    has_id_document: c.national_id.is_some() || c.passport_number.is_some(),
1968                    has_risk_rating: true,
1969                    has_beneficial_owner: !c.beneficial_owners.is_empty(),
1970                    is_entity: c.customer_type == BankingCustomerType::Business,
1971                    is_verified: c.kyc_truthful,
1972                })
1973                .collect();
1974
1975            let mut banking_eval = BankingEvaluation::new();
1976            if let Ok(kyc_res) = KycCompletenessAnalyzer::new().analyze(&kyc_data) {
1977                banking_eval.kyc = Some(kyc_res);
1978            }
1979
1980            let suspicious: Vec<&_> = result
1981                .banking
1982                .transactions
1983                .iter()
1984                .filter(|t| t.is_suspicious)
1985                .collect();
1986            if !suspicious.is_empty() {
1987                // Use AmlTypology::canonical_name() so the evaluator's
1988                // exact-string match against EXPECTED_TYPOLOGIES succeeds.
1989                // Prior to v3.1.1 we used `format!("{:?}", r)` (Debug /
1990                // PascalCase) which never matched the lowercase expected
1991                // names and produced "typology_coverage = 0.000" in every
1992                // run regardless of actual typology injection.
1993                let aml_data: Vec<AmlTransactionData> = suspicious
1994                    .iter()
1995                    .map(|t| AmlTransactionData {
1996                        transaction_id: t.transaction_id.to_string(),
1997                        typology: t
1998                            .suspicion_reason
1999                            .as_ref()
2000                            .map(|r| r.canonical_name().to_string())
2001                            .unwrap_or_default(),
2002                        case_id: t.case_id.clone().unwrap_or_default(),
2003                        amount: t.amount.try_into().unwrap_or(0.0),
2004                        is_flagged: t.is_suspicious,
2005                    })
2006                    .collect();
2007
2008                let mut typology_map: HashMap<String, (usize, HashMap<String, bool>)> =
2009                    HashMap::new();
2010                for txn in &aml_data {
2011                    if !txn.typology.is_empty() {
2012                        let entry = typology_map
2013                            .entry(txn.typology.clone())
2014                            .or_insert_with(|| (0, HashMap::new()));
2015                        entry.0 += 1;
2016                        entry.1.insert(txn.case_id.clone(), true);
2017                    }
2018                }
2019                let typology_data: Vec<TypologyData> = typology_map
2020                    .iter()
2021                    .map(|(name, (count, cases))| TypologyData {
2022                        name: name.clone(),
2023                        scenario_count: *count,
2024                        case_ids_consistent: cases.len() <= *count,
2025                    })
2026                    .collect();
2027
2028                if let Ok(aml_res) =
2029                    AmlDetectabilityAnalyzer::new().analyze(&aml_data, &typology_data)
2030                {
2031                    banking_eval.aml = Some(aml_res);
2032                }
2033            }
2034            banking_eval.check_thresholds();
2035
2036            match serde_json::to_string_pretty(&banking_eval) {
2037                Ok(json) => {
2038                    if let Err(e) =
2039                        std::fs::write(analytics_dir.join("banking_evaluation.json"), json)
2040                    {
2041                        warn!("Failed to write banking evaluation: {}", e);
2042                    } else {
2043                        info!(
2044                            "  Banking evaluation written ({} profiles, {} issues, passes={})",
2045                            result.banking.customers.len(),
2046                            banking_eval.issues.len(),
2047                            banking_eval.passes
2048                        );
2049                    }
2050                }
2051                Err(e) => warn!("Failed to serialize banking evaluation: {}", e),
2052            }
2053        }
2054    }
2055
2056    // ========================================================================
2057    // Data Quality Issue Records + Quality Labels
2058    // ========================================================================
2059    if !result.quality_issues.is_empty() {
2060        let labels_dir = output_dir.join("labels");
2061        std::fs::create_dir_all(&labels_dir)?;
2062        info!("Writing data quality issue records...");
2063        write_json_safe(
2064            &result.quality_issues,
2065            &labels_dir.join("quality_issues.json"),
2066            "Data quality issues",
2067        );
2068
2069        // Derive quality_labels.json from quality_issues: maps each QualityIssue
2070        // to a QualityIssueLabel with the corresponding LabeledIssueType and severity.
2071        use datasynth_generators::{
2072            LabeledIssueType, QualityIssueLabel, QualityIssueType, QualityLabels,
2073        };
2074        let mut quality_labels = QualityLabels::with_capacity(result.quality_issues.len());
2075        for issue in &result.quality_issues {
2076            let labeled_type = match issue.issue_type {
2077                QualityIssueType::MissingValue => LabeledIssueType::MissingValue,
2078                QualityIssueType::Typo => LabeledIssueType::Typo,
2079                QualityIssueType::DateFormatVariation
2080                | QualityIssueType::AmountFormatVariation
2081                | QualityIssueType::IdentifierFormatVariation
2082                | QualityIssueType::TextFormatVariation => LabeledIssueType::FormatVariation,
2083                QualityIssueType::ExactDuplicate
2084                | QualityIssueType::NearDuplicate
2085                | QualityIssueType::FuzzyDuplicate => LabeledIssueType::Duplicate,
2086                QualityIssueType::EncodingIssue => LabeledIssueType::EncodingIssue,
2087            };
2088            let mut label = QualityIssueLabel::new(
2089                labeled_type,
2090                issue.record_id.clone(),
2091                issue.field.clone().unwrap_or_else(|| "_record".to_string()),
2092                "data_quality_injector",
2093            );
2094            if let Some(ref orig) = issue.original_value {
2095                label = label.with_original(orig.clone());
2096            }
2097            if let Some(ref modified) = issue.modified_value {
2098                label = label.with_modified(modified.clone());
2099            }
2100            quality_labels.add(label);
2101        }
2102        if let Ok(json) = serde_json::to_string_pretty(&quality_labels) {
2103            if let Err(e) = std::fs::write(labels_dir.join("quality_labels.json"), json.as_bytes())
2104            {
2105                warn!("Failed to write quality labels: {}", e);
2106            } else {
2107                info!(
2108                    "  Quality labels written: {} labels -> labels/quality_labels.json",
2109                    quality_labels.len()
2110                );
2111            }
2112        }
2113    }
2114
2115    // ========================================================================
2116    // Internal Controls
2117    // ========================================================================
2118    if !result.internal_controls.is_empty() || !result.sod_violations.is_empty() {
2119        let ctrl_dir = output_dir.join("internal_controls");
2120        std::fs::create_dir_all(&ctrl_dir)?;
2121        info!("Writing internal controls data...");
2122
2123        write_json_safe(
2124            &result.internal_controls,
2125            &ctrl_dir.join("internal_controls.json"),
2126            "Internal controls",
2127        );
2128        // SoD violations extracted from control-annotated journal entries
2129        write_json_safe(
2130            &result.sod_violations,
2131            &ctrl_dir.join("sod_violations.json"),
2132            "SoD violations",
2133        );
2134
2135        // SoD conflict pairs, SoD rules, control mappings, and COSO control mapping
2136        // are static reference data — export via ControlExporter regardless of whether
2137        // individual violations were generated so the master catalog is always present.
2138        let exporter = datasynth_output::ControlExporter::new(&ctrl_dir);
2139        match exporter.export_standard() {
2140            Ok(summary) => {
2141                info!(
2142                    "  Control master data written: {} controls, {} SoD conflicts, {} SoD rules, {} COSO mappings, {} account mappings",
2143                    summary.controls_count,
2144                    summary.sod_conflicts_count,
2145                    summary.sod_rules_count,
2146                    summary.coso_mappings_count,
2147                    summary.account_mappings_count,
2148                );
2149            }
2150            Err(e) => warn!("Failed to write control master data: {}", e),
2151        }
2152    }
2153
2154    // ========================================================================
2155    // Accounting Standards
2156    // ========================================================================
2157    if !result.accounting_standards.contracts.is_empty()
2158        || !result.accounting_standards.impairment_tests.is_empty()
2159        || !result.accounting_standards.business_combinations.is_empty()
2160        || !result.accounting_standards.ecl_models.is_empty()
2161        || !result.accounting_standards.provisions.is_empty()
2162        || !result
2163            .accounting_standards
2164            .currency_translation_results
2165            .is_empty()
2166    {
2167        let acct_dir = output_dir.join("accounting_standards");
2168        std::fs::create_dir_all(&acct_dir)?;
2169        info!("Writing accounting standards data...");
2170
2171        write_json_safe(
2172            &result.accounting_standards.contracts,
2173            &acct_dir.join("customer_contracts.json"),
2174            "Customer contracts",
2175        );
2176        write_json_safe(
2177            &result.accounting_standards.impairment_tests,
2178            &acct_dir.join("impairment_tests.json"),
2179            "Impairment tests",
2180        );
2181        write_json_safe(
2182            &result.accounting_standards.business_combinations,
2183            &acct_dir.join("business_combinations.json"),
2184            "Business combinations",
2185        );
2186        write_json_safe(
2187            &result
2188                .accounting_standards
2189                .business_combination_journal_entries,
2190            &acct_dir.join("business_combination_journal_entries.json"),
2191            "Business combination journal entries",
2192        );
2193        write_json_safe(
2194            &result.accounting_standards.ecl_models,
2195            &acct_dir.join("ecl_models.json"),
2196            "ECL models",
2197        );
2198        write_json_safe(
2199            &result.accounting_standards.ecl_provision_movements,
2200            &acct_dir.join("ecl_provision_movements.json"),
2201            "ECL provision movements",
2202        );
2203        write_json_safe(
2204            &result.accounting_standards.ecl_journal_entries,
2205            &acct_dir.join("ecl_journal_entries.json"),
2206            "ECL journal entries",
2207        );
2208        write_json_safe(
2209            &result.accounting_standards.provisions,
2210            &acct_dir.join("provisions.json"),
2211            "Provisions (IAS 37 / ASC 450)",
2212        );
2213        write_json_safe(
2214            &result.accounting_standards.provision_movements,
2215            &acct_dir.join("provision_movements.json"),
2216            "Provision movements",
2217        );
2218        write_json_safe(
2219            &result.accounting_standards.contingent_liabilities,
2220            &acct_dir.join("contingent_liabilities.json"),
2221            "Contingent liabilities",
2222        );
2223        write_json_safe(
2224            &result.accounting_standards.provision_journal_entries,
2225            &acct_dir.join("provision_journal_entries.json"),
2226            "Provision journal entries",
2227        );
2228
2229        // IAS 21 — write under accounting_standards/fx/
2230        if !result
2231            .accounting_standards
2232            .currency_translation_results
2233            .is_empty()
2234        {
2235            let fx_dir = acct_dir.join("fx");
2236            std::fs::create_dir_all(&fx_dir)?;
2237            write_json_safe(
2238                &result.accounting_standards.currency_translation_results,
2239                &fx_dir.join("currency_translation_results.json"),
2240                "IAS 21 currency translation results",
2241            );
2242        }
2243
2244        // v3.3.1: Leases (IFRS 16 / ASC 842)
2245        if !result.accounting_standards.leases.is_empty() {
2246            let leases_dir = acct_dir.join("leases");
2247            std::fs::create_dir_all(&leases_dir)?;
2248            write_json_safe(
2249                &result.accounting_standards.leases,
2250                &leases_dir.join("leases.json"),
2251                "Leases (IFRS 16 / ASC 842) — v3.3.1",
2252            );
2253        }
2254
2255        // v3.3.1: Fair value measurements (IFRS 13 / ASC 820)
2256        if !result
2257            .accounting_standards
2258            .fair_value_measurements
2259            .is_empty()
2260        {
2261            let fv_dir = acct_dir.join("fair_value");
2262            std::fs::create_dir_all(&fv_dir)?;
2263            write_json_safe(
2264                &result.accounting_standards.fair_value_measurements,
2265                &fv_dir.join("fair_value_measurements.json"),
2266                "Fair value measurements (IFRS 13 / ASC 820) — v3.3.1",
2267            );
2268        }
2269
2270        // v3.3.1: Framework reconciliation (dual reporting)
2271        if !result.accounting_standards.framework_differences.is_empty() {
2272            let diff_dir = acct_dir.join("framework_differences");
2273            std::fs::create_dir_all(&diff_dir)?;
2274            write_json_safe(
2275                &result.accounting_standards.framework_differences,
2276                &diff_dir.join("framework_differences.json"),
2277                "Framework differences (US GAAP vs IFRS) — v3.3.1",
2278            );
2279            write_json_safe(
2280                &result.accounting_standards.framework_reconciliations,
2281                &diff_dir.join("framework_reconciliations.json"),
2282                "Per-entity framework reconciliation — v3.3.1",
2283            );
2284        }
2285    }
2286
2287    // ========================================================================
2288    // Quality Gate Results
2289    // ========================================================================
2290    if let Some(ref gate_result) = result.gate_result {
2291        match serde_json::to_string_pretty(gate_result) {
2292            Ok(json) => {
2293                if let Err(e) = std::fs::write(output_dir.join("quality_gate_result.json"), json) {
2294                    warn!("Failed to write quality gate result: {}", e);
2295                } else {
2296                    info!(
2297                        "  Quality gate result written (passed={})",
2298                        gate_result.passed
2299                    );
2300                }
2301            }
2302            Err(e) => warn!("Failed to serialize quality gate result: {}", e),
2303        }
2304    }
2305
2306    // ========================================================================
2307    // Treasury
2308    // ========================================================================
2309    if !result.treasury.debt_instruments.is_empty()
2310        || !result.treasury.cash_positions.is_empty()
2311        || !result.treasury.hedging_instruments.is_empty()
2312    {
2313        let treasury_dir = output_dir.join("treasury");
2314        std::fs::create_dir_all(&treasury_dir)?;
2315        info!("Writing treasury data...");
2316
2317        write_json_safe(
2318            &result.treasury.debt_instruments,
2319            &treasury_dir.join("debt_instruments.json"),
2320            "Debt instruments",
2321        );
2322        write_json_safe(
2323            &result.treasury.hedging_instruments,
2324            &treasury_dir.join("hedging_instruments.json"),
2325            "Hedging instruments",
2326        );
2327        write_json_safe(
2328            &result.treasury.hedge_relationships,
2329            &treasury_dir.join("hedge_relationships.json"),
2330            "Hedge relationships",
2331        );
2332        write_json_safe(
2333            &result.treasury.cash_positions,
2334            &treasury_dir.join("cash_positions.json"),
2335            "Cash positions",
2336        );
2337        write_json_safe(
2338            &result.treasury.cash_forecasts,
2339            &treasury_dir.join("cash_forecasts.json"),
2340            "Cash forecasts",
2341        );
2342        write_json_safe(
2343            &result.treasury.cash_pools,
2344            &treasury_dir.join("cash_pools.json"),
2345            "Cash pools",
2346        );
2347        write_json_safe(
2348            &result.treasury.cash_pool_sweeps,
2349            &treasury_dir.join("cash_pool_sweeps.json"),
2350            "Cash pool sweeps",
2351        );
2352        write_json_safe(
2353            &result.treasury.bank_guarantees,
2354            &treasury_dir.join("bank_guarantees.json"),
2355            "Bank guarantees",
2356        );
2357        write_json_safe(
2358            &result.treasury.netting_runs,
2359            &treasury_dir.join("netting_runs.json"),
2360            "Netting runs",
2361        );
2362        if !result.treasury.treasury_anomaly_labels.is_empty() {
2363            write_json_safe(
2364                &result.treasury.treasury_anomaly_labels,
2365                &treasury_dir.join("treasury_anomaly_labels.json"),
2366                "Treasury anomaly labels",
2367            );
2368        }
2369    }
2370
2371    // ========================================================================
2372    // Project Accounting
2373    // ========================================================================
2374    if !result.project_accounting.projects.is_empty() {
2375        let pa_dir = output_dir.join("project_accounting");
2376        std::fs::create_dir_all(&pa_dir)?;
2377        info!("Writing project accounting data...");
2378
2379        write_json_safe(
2380            &result.project_accounting.projects,
2381            &pa_dir.join("projects.json"),
2382            "Projects",
2383        );
2384        write_json_safe(
2385            &result.project_accounting.cost_lines,
2386            &pa_dir.join("cost_lines.json"),
2387            "Project cost lines",
2388        );
2389        write_json_safe(
2390            &result.project_accounting.revenue_records,
2391            &pa_dir.join("revenue_records.json"),
2392            "Project revenue records",
2393        );
2394        write_json_safe(
2395            &result.project_accounting.earned_value_metrics,
2396            &pa_dir.join("earned_value_metrics.json"),
2397            "Earned value metrics",
2398        );
2399        write_json_safe(
2400            &result.project_accounting.change_orders,
2401            &pa_dir.join("change_orders.json"),
2402            "Change orders",
2403        );
2404        write_json_safe(
2405            &result.project_accounting.milestones,
2406            &pa_dir.join("milestones.json"),
2407            "Project milestones",
2408        );
2409    }
2410
2411    // ========================================================================
2412    // Evolution Events (Process Evolution + Organizational Events)
2413    // ========================================================================
2414    if !result.process_evolution.is_empty()
2415        || !result.organizational_events.is_empty()
2416        || !result.disruption_events.is_empty()
2417    {
2418        let events_dir = output_dir.join("events");
2419        std::fs::create_dir_all(&events_dir)?;
2420        info!("Writing evolution events...");
2421
2422        write_json_safe(
2423            &result.process_evolution,
2424            &events_dir.join("process_evolution_events.json"),
2425            "Process evolution events",
2426        );
2427        write_json_safe(
2428            &result.organizational_events,
2429            &events_dir.join("organizational_events.json"),
2430            "Organizational events",
2431        );
2432        write_json_safe(
2433            &result.disruption_events,
2434            &events_dir.join("disruption_events.json"),
2435            "Disruption events",
2436        );
2437    }
2438
2439    // ========================================================================
2440    // ML Training: Counterfactual Pairs
2441    // ========================================================================
2442    if !result.counterfactual_pairs.is_empty() {
2443        let ml_dir = output_dir.join("ml_training");
2444        std::fs::create_dir_all(&ml_dir)?;
2445        info!("Writing ML training data...");
2446
2447        write_json_safe(
2448            &result.counterfactual_pairs,
2449            &ml_dir.join("counterfactual_pairs.json"),
2450            "Counterfactual pairs",
2451        );
2452    }
2453
2454    // ========================================================================
2455    // Fraud Red-Flag Indicators
2456    // ========================================================================
2457    if !result.red_flags.is_empty() {
2458        let labels_dir = output_dir.join("labels");
2459        std::fs::create_dir_all(&labels_dir)?;
2460        info!("Writing fraud red-flag indicators...");
2461
2462        write_json_safe(
2463            &result.red_flags,
2464            &labels_dir.join("fraud_red_flags.json"),
2465            "Fraud red flags",
2466        );
2467    }
2468
2469    // ========================================================================
2470    // Collusion Rings
2471    // ========================================================================
2472    if !result.collusion_rings.is_empty() {
2473        let labels_dir = output_dir.join("labels");
2474        std::fs::create_dir_all(&labels_dir)?;
2475        info!("Writing collusion rings...");
2476
2477        write_json_safe(
2478            &result.collusion_rings,
2479            &labels_dir.join("collusion_rings.json"),
2480            "Collusion rings",
2481        );
2482    }
2483
2484    // ========================================================================
2485    // Temporal Vendor Version Chains
2486    // ========================================================================
2487    if !result.temporal_vendor_chains.is_empty() {
2488        let temporal_dir = output_dir.join("temporal");
2489        std::fs::create_dir_all(&temporal_dir)?;
2490        info!("Writing temporal vendor version chains...");
2491
2492        write_json_safe(
2493            &result.temporal_vendor_chains,
2494            &temporal_dir.join("vendor_version_chains.json"),
2495            "Vendor version chains",
2496        );
2497    }
2498
2499    // ========================================================================
2500    // Entity Relationship Graph + Cross-Process Links
2501    // ========================================================================
2502    if result.entity_relationship_graph.is_some() || !result.cross_process_links.is_empty() {
2503        let rel_dir = output_dir.join("relationships");
2504        std::fs::create_dir_all(&rel_dir)?;
2505        info!("Writing entity relationship data...");
2506
2507        if let Some(ref graph) = result.entity_relationship_graph {
2508            match serde_json::to_string_pretty(graph) {
2509                Ok(json) => {
2510                    let path = rel_dir.join("entity_relationship_graph.json");
2511                    if let Err(e) = std::fs::write(&path, json) {
2512                        warn!("Failed to write entity relationship graph: {}", e);
2513                    } else {
2514                        info!(
2515                            "  Entity relationship graph written: {} nodes, {} edges -> {}",
2516                            graph.nodes.len(),
2517                            graph.edges.len(),
2518                            path.display()
2519                        );
2520                    }
2521                }
2522                Err(e) => warn!("Failed to serialize entity relationship graph: {}", e),
2523            }
2524        }
2525
2526        write_json_safe(
2527            &result.cross_process_links,
2528            &rel_dir.join("cross_process_links.json"),
2529            "Cross-process links",
2530        );
2531    }
2532
2533    // ========================================================================
2534    // Industry-Specific Data
2535    // ========================================================================
2536    if let Some(ref industry_output) = result.industry_output {
2537        if !industry_output.gl_accounts.is_empty() {
2538            let industry_dir = output_dir.join("industry");
2539            std::fs::create_dir_all(&industry_dir).ok();
2540            info!("Writing industry-specific data...");
2541            match serde_json::to_string_pretty(industry_output) {
2542                Ok(json) => {
2543                    if let Err(e) = std::fs::write(industry_dir.join("industry_data.json"), json) {
2544                        warn!("Failed to write industry data: {}", e);
2545                    } else {
2546                        info!(
2547                            "  Industry data written: {} GL accounts for {}",
2548                            industry_output.gl_accounts.len(),
2549                            industry_output.industry
2550                        );
2551                    }
2552                }
2553                Err(e) => warn!("Failed to serialize industry data: {}", e),
2554            }
2555        }
2556    }
2557
2558    // ========================================================================
2559    // Graph Export Summary
2560    // ========================================================================
2561    if result.graph_export.exported {
2562        let graph_dir = output_dir.join("graph_export");
2563        std::fs::create_dir_all(&graph_dir).ok();
2564        match serde_json::to_string_pretty(&result.graph_export) {
2565            Ok(json) => {
2566                if let Err(e) = std::fs::write(graph_dir.join("graph_export_summary.json"), json) {
2567                    warn!("Failed to write graph export summary: {}", e);
2568                } else {
2569                    info!("  Graph export summary written");
2570                }
2571            }
2572            Err(e) => warn!("Failed to serialize graph export summary: {}", e),
2573        }
2574    }
2575
2576    // ========================================================================
2577    // Compliance Regulations
2578    // ========================================================================
2579    let cr = &result.compliance_regulations;
2580    let has_compliance_data = !cr.standard_records.is_empty()
2581        || !cr.audit_procedures.is_empty()
2582        || !cr.findings.is_empty()
2583        || !cr.filings.is_empty();
2584    if has_compliance_data {
2585        let cr_dir = output_dir.join("compliance_regulations");
2586        std::fs::create_dir_all(&cr_dir)?;
2587        info!("Writing compliance regulations data...");
2588
2589        write_json_safe(
2590            &cr.standard_records,
2591            &cr_dir.join("compliance_standards.json"),
2592            "Compliance standards",
2593        );
2594        write_json_safe(
2595            &cr.cross_reference_records,
2596            &cr_dir.join("cross_references.json"),
2597            "Cross-references",
2598        );
2599        write_json_safe(
2600            &cr.jurisdiction_records,
2601            &cr_dir.join("jurisdiction_profiles.json"),
2602            "Jurisdiction profiles",
2603        );
2604        write_json_safe(
2605            &cr.audit_procedures,
2606            &cr_dir.join("audit_procedures.json"),
2607            "Audit procedures",
2608        );
2609        write_json_safe(
2610            &cr.findings,
2611            &cr_dir.join("compliance_findings.json"),
2612            "Compliance findings",
2613        );
2614        write_json_safe(
2615            &cr.filings,
2616            &cr_dir.join("regulatory_filings.json"),
2617            "Regulatory filings",
2618        );
2619
2620        if let Some(ref graph) = cr.compliance_graph {
2621            match serde_json::to_string_pretty(graph) {
2622                Ok(json) => {
2623                    if let Err(e) = std::fs::write(cr_dir.join("compliance_graph.json"), json) {
2624                        warn!("Failed to write compliance graph: {}", e);
2625                    } else {
2626                        info!(
2627                            "  Compliance graph written: {} nodes, {} edges",
2628                            graph.nodes.len(),
2629                            graph.edges.len()
2630                        );
2631                    }
2632                }
2633                Err(e) => warn!("Failed to serialize compliance graph: {}", e),
2634            }
2635        }
2636    }
2637
2638    // ========================================================================
2639    // Generation Statistics
2640    // ========================================================================
2641    match serde_json::to_string_pretty(&result.statistics) {
2642        Ok(json) => {
2643            if let Err(e) = std::fs::write(output_dir.join("generation_statistics.json"), json) {
2644                warn!("Failed to write generation statistics: {}", e);
2645            } else {
2646                info!("  Generation statistics written");
2647            }
2648        }
2649        Err(e) => warn!("Failed to serialize generation statistics: {}", e),
2650    }
2651
2652    info!("Output writing complete.");
2653    Ok(())
2654}
2655
2656/// Write JSON with error handling - logs a warning on failure but does not abort.
2657///
2658/// When the `FLAT_LAYOUT_ACTIVE` thread-local is true (set by
2659/// `write_all_output_with_layout` when `export_layout: flat`), this routes
2660/// through `write_json_flat` so nested `{header, lines|items|allocations}`
2661/// shapes are automatically flattened. For structures without that shape,
2662/// `write_json_flat` passes through unchanged.
2663fn write_json_safe<T: serde::Serialize>(data: &[T], path: &Path, label: &str) {
2664    // Skip JSON entirely when not in requested output formats
2665    if SKIP_JSON.with(|c| c.get()) {
2666        return;
2667    }
2668    if FLAT_LAYOUT_ACTIVE.with(|c| c.get()) {
2669        write_json_flat(data, path, label);
2670    } else if let Err(e) = write_json(data, path, label) {
2671        warn!("Failed to write {}: {}", label, e);
2672    }
2673}
2674
2675/// Write JSON, choosing flat or nested layout based on the flag.
2676fn write_json_auto<T: serde::Serialize>(data: &[T], path: &Path, label: &str, flat: bool) {
2677    if flat {
2678        write_json_flat(data, path, label);
2679    } else {
2680        write_json_safe(data, path, label);
2681    }
2682}
2683
2684/// Write a JSON file ALWAYS, even when the slice is empty (writes `[]`).
2685///
2686/// Use for files that must exist in the archive for SDK consumers
2687/// (e.g., `audit_opinions.json`) regardless of whether the phase that
2688/// populates them ran. `write_json_safe` / `write_json` short-circuit
2689/// on empty slices, which would break manifest-driven clients that
2690/// expect the file to be present.
2691fn write_json_always<T: serde::Serialize>(data: &[T], path: &Path, label: &str) {
2692    if SKIP_JSON.with(|c| c.get()) {
2693        return;
2694    }
2695    match std::fs::File::create(path) {
2696        Ok(file) => {
2697            let mut writer = std::io::BufWriter::with_capacity(64 * 1024, file);
2698            if let Err(e) = (|| -> Result<(), Box<dyn std::error::Error>> {
2699                writer.write_all(b"[\n")?;
2700                for (i, item) in data.iter().enumerate() {
2701                    if i > 0 {
2702                        writer.write_all(b",\n")?;
2703                    }
2704                    serde_json::to_writer_pretty(&mut writer, item)?;
2705                }
2706                if !data.is_empty() {
2707                    writer.write_all(b"\n")?;
2708                }
2709                writer.write_all(b"]\n")?;
2710                writer.flush()?;
2711                Ok(())
2712            })() {
2713                warn!("Failed to write {}: {}", label, e);
2714            } else {
2715                info!(
2716                    "  {} written: {} records -> {}",
2717                    label,
2718                    data.len(),
2719                    path.display()
2720                );
2721            }
2722        }
2723        Err(e) => {
2724            warn!("Failed to create {}: {}", path.display(), e);
2725        }
2726    }
2727}
2728
2729/// Write a flat JSON file by expanding a primary items array and merging the
2730/// surrounding context onto each line.
2731///
2732/// Flattens any record that contains a recognised items array
2733/// (`items`, `lines`, `line_items`, or `allocations`) into one row per line,
2734/// carrying over both the optional `header` sub-object and all other
2735/// top-level fields. Records without a recognised items array are emitted
2736/// as-is, except that an optional nested `header` sub-object is unwrapped
2737/// onto the top level so consumers see a uniformly flat shape.
2738///
2739/// Flow-style documents (`{header, items}`) and subledger-style documents
2740/// (`{..top-level scalars.., lines}`, e.g. AP/AR invoices, inventory
2741/// valuation runs) are both handled — fixing the SDK-team-reported gap
2742/// where subledger invoices were left with `lines` nested in flat mode.
2743///
2744/// Uses heap-allocated intermediates to avoid stack overflow with large
2745/// records in constrained environments (e.g., distroless containers with
2746/// glibc 2.36). Fixes #116.
2747fn write_json_flat<T: serde::Serialize>(data: &[T], path: &Path, label: &str) {
2748    if data.is_empty() {
2749        return;
2750    }
2751
2752    // Pre-allocate on heap — avoid flat_map closure accumulating on the stack
2753    let mut flat: Vec<serde_json::Value> = Vec::with_capacity(data.len());
2754
2755    for item in data {
2756        let val = match serde_json::to_value(item) {
2757            Ok(v) => v,
2758            Err(e) => {
2759                warn!("Failed to serialize record for flat export: {}", e);
2760                continue;
2761            }
2762        };
2763
2764        let serde_json::Value::Object(map) = val else {
2765            flat.push(val);
2766            continue;
2767        };
2768
2769        // Find the primary items array key (first match wins).
2770        let items_key = ["items", "lines", "allocations", "line_items"]
2771            .iter()
2772            .find(|k| map.contains_key(**k))
2773            .copied();
2774
2775        // Optional nested header sub-object (used by document flows).
2776        let header_map = match map.get("header") {
2777            Some(serde_json::Value::Object(h)) => Some(h),
2778            _ => None,
2779        };
2780
2781        let Some(items_key) = items_key else {
2782            // No items array. Emit one row, unwrapping the optional header
2783            // sub-object so consumers see a flat shape regardless of model
2784            // layout (e.g. Payments have `header` but no items/allocations
2785            // when allocations are empty).
2786            if let Some(header_map) = header_map {
2787                let mut merged = map.clone();
2788                merged.remove("header");
2789                for (k, v) in header_map {
2790                    merged.entry(k.clone()).or_insert_with(|| v.clone());
2791                }
2792                flat.push(serde_json::Value::Object(merged));
2793            } else {
2794                flat.push(serde_json::Value::Object(map));
2795            }
2796            continue;
2797        };
2798
2799        let Some(serde_json::Value::Array(items)) = map.get(items_key) else {
2800            // `items_key` present but not an array — passthrough.
2801            flat.push(serde_json::Value::Object(map));
2802            continue;
2803        };
2804
2805        // Empty items array: emit one row with the (unwrapped) header
2806        // context so downstream consumers can still find the parent
2807        // record — prevents silently dropping empty-lines invoices.
2808        if items.is_empty() {
2809            let mut merged = map.clone();
2810            merged.remove(items_key);
2811            if let Some(header_map) = header_map {
2812                merged.remove("header");
2813                for (k, v) in header_map {
2814                    merged.entry(k.clone()).or_insert_with(|| v.clone());
2815                }
2816            }
2817            flat.push(serde_json::Value::Object(merged));
2818            continue;
2819        }
2820
2821        // Collect all other top-level fields (scalars, objects, arrays)
2822        // so they carry over onto every flattened line — matching pandas
2823        // `explode()` semantics. This is the behaviour SDK consumers
2824        // expect: header context is repeated per line, nested objects
2825        // like `net_amount: {amount, currency}` come along for the ride.
2826        let top_fields: Vec<(&String, &serde_json::Value)> = map
2827            .iter()
2828            .filter(|(k, _)| k.as_str() != "header" && k.as_str() != items_key)
2829            .collect();
2830
2831        flat.reserve(items.len());
2832        for item_val in items {
2833            let mut merged = serde_json::Map::new();
2834            // Line/item fields first (take precedence on collisions).
2835            if let serde_json::Value::Object(m) = item_val {
2836                merged.extend(m.iter().map(|(k, v)| (k.clone(), v.clone())));
2837            }
2838            // Header sub-object (when present) — don't overwrite line fields.
2839            if let Some(header_map) = header_map {
2840                for (k, v) in header_map {
2841                    merged.entry(k.clone()).or_insert_with(|| v.clone());
2842                }
2843            }
2844            // All other top-level fields.
2845            for &(k, v) in &top_fields {
2846                merged.entry(k.clone()).or_insert_with(|| v.clone());
2847            }
2848            flat.push(serde_json::Value::Object(merged));
2849        }
2850    }
2851
2852    if flat.is_empty() {
2853        return;
2854    }
2855
2856    // Stream-write each flattened record instead of serializing the whole Vec
2857    let count = flat.len();
2858    match std::fs::File::create(path) {
2859        Ok(file) => {
2860            use std::io::Write;
2861            let mut writer = std::io::BufWriter::with_capacity(512 * 1024, file);
2862            if let Err(e) = (|| -> Result<(), Box<dyn std::error::Error>> {
2863                writer.write_all(b"[\n")?;
2864                for (i, item) in flat.iter().enumerate() {
2865                    if i > 0 {
2866                        writer.write_all(b",\n")?;
2867                    }
2868                    serde_json::to_writer_pretty(&mut writer, item)?;
2869                }
2870                writer.write_all(b"\n]\n")?;
2871                writer.flush()?;
2872                Ok(())
2873            })() {
2874                warn!("Failed to write {}: {}", label, e);
2875            } else {
2876                info!(
2877                    "  {} written (flat): {} records -> {}",
2878                    label,
2879                    count,
2880                    path.display()
2881                );
2882            }
2883        }
2884        Err(e) => warn!("Failed to create {}: {}", label, e),
2885    }
2886}
2887
2888/// Write a single serializable value as a JSON file.
2889fn write_json_single<T: serde::Serialize>(
2890    data: &T,
2891    path: &Path,
2892    label: &str,
2893) -> Result<(), Box<dyn std::error::Error>> {
2894    let file = std::fs::File::create(path)?;
2895    let writer = std::io::BufWriter::with_capacity(256 * 1024, file);
2896    serde_json::to_writer_pretty(writer, data)?;
2897    info!("  {} written -> {}", label, path.display());
2898    Ok(())
2899}
2900
2901/// Write a single serializable value as a JSON file, logging a warning on failure.
2902fn write_json_single_safe<T: serde::Serialize>(data: &T, path: &Path, label: &str) {
2903    if SKIP_JSON.with(|c| c.get()) {
2904        return;
2905    }
2906    if let Err(e) = write_json_single(data, path, label) {
2907        warn!("Failed to write {}: {}", label, e);
2908    }
2909}
2910
2911/// Serializable summary of balance validation (avoids serializing the full
2912/// `BalanceValidationResult` which has non-Serialize validation error types).
2913#[derive(serde::Serialize)]
2914struct BalanceValidationSummary {
2915    validated: bool,
2916    is_balanced: bool,
2917    entries_processed: u64,
2918    total_debits: String,
2919    total_credits: String,
2920    accounts_tracked: usize,
2921    companies_tracked: usize,
2922    has_unbalanced_entries: bool,
2923    validation_error_count: usize,
2924}
2925
2926impl BalanceValidationSummary {
2927    fn from(v: &crate::enhanced_orchestrator::BalanceValidationResult) -> Self {
2928        Self {
2929            validated: v.validated,
2930            is_balanced: v.is_balanced,
2931            entries_processed: v.entries_processed,
2932            total_debits: v.total_debits.to_string(),
2933            total_credits: v.total_credits.to_string(),
2934            accounts_tracked: v.accounts_tracked,
2935            companies_tracked: v.companies_tracked,
2936            has_unbalanced_entries: v.has_unbalanced_entries,
2937            validation_error_count: v.validation_errors.len(),
2938        }
2939    }
2940}