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