1use 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 static FLAT_LAYOUT_ACTIVE: Cell<bool> = const { Cell::new(false) };
22
23 static SKIP_JSON: Cell<bool> = const { Cell::new(false) };
27}
28
29fn 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 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
75fn 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 writeln!(
121 w,
122 "document_id,company_code,fiscal_year,fiscal_period,posting_date,document_date,\
123 document_type,currency,exchange_rate,reference,header_text,created_by,source,\
124 business_process,ledger,is_fraud,is_anomaly,\
125 line_number,gl_account,debit_amount,credit_amount,local_amount,transaction_amount,\
126 cost_center,profit_center,business_unit,line_text,\
127 auxiliary_account_number,auxiliary_account_label,lettrage,lettrage_date,\
128 is_manual,is_post_close,source_system,\
129 account_description,financial_statement_category,\
130 assignment,value_date,tax_code,transaction_id,\
131 account_class,account_class_name,account_sub_class,account_sub_class_name,\
132 predecessor_line_id,trading_partner,fraud_type,anomaly_type"
133 )?;
134
135 let coa_index: std::collections::HashMap<&str, (&str, &str, &str, &str, &str)> = result
140 .chart_of_accounts
141 .accounts
142 .iter()
143 .map(|a| {
144 (
145 a.account_number.as_str(),
146 (
147 a.short_description.as_str(),
148 a.account_class.as_str(),
149 a.account_class_name.as_str(),
150 a.account_sub_class.as_str(),
151 a.account_sub_class_name.as_str(),
152 ),
153 )
154 })
155 .collect();
156
157 let coa_semantic_index: std::collections::HashMap<&str, (&str, &str, &str, &str, &str)> =
168 result
169 .coa_semantic_prior
170 .as_ref()
171 .map(|prior| {
172 prior
173 .accounts
174 .iter()
175 .map(|(account_number, sem)| {
176 (
177 account_number.as_str(),
178 (
179 sem.description.as_str(),
180 sem.account_class.as_deref().unwrap_or(""),
181 sem.account_class_name.as_deref().unwrap_or(""),
182 sem.account_sub_class.as_deref().unwrap_or(""),
183 sem.account_sub_class_name.as_deref().unwrap_or(""),
184 ),
185 )
186 })
187 .collect()
188 })
189 .unwrap_or_default();
190
191 for je in &result.journal_entries {
192 let h = &je.header;
193 let source_label: std::borrow::Cow<str> = match &h.sap_source_code {
197 Some(code) => std::borrow::Cow::Borrowed(code.as_str()),
198 None => std::borrow::Cow::Owned(h.source.to_string()),
199 };
200 for line in &je.lines {
201 let lettrage_date_str = line
202 .lettrage_date
203 .map(|d| d.to_string())
204 .unwrap_or_default();
205 let value_date_str = line.value_date.map(|d| d.to_string()).unwrap_or_default();
206 let coa_hit = coa_index
210 .get(line.gl_account.as_str())
211 .copied()
212 .or_else(|| coa_semantic_index.get(line.gl_account.as_str()).copied());
213 let coa_short_desc = coa_hit.map(|t| t.0).unwrap_or("");
214 let coa_class = coa_hit.map(|t| t.1).unwrap_or("");
215 let coa_class_name = coa_hit.map(|t| t.2).unwrap_or("");
216 let coa_sub_class = coa_hit.map(|t| t.3).unwrap_or("");
217 let coa_sub_class_name = coa_hit.map(|t| t.4).unwrap_or("");
218 let account_description: &str = line
222 .account_description
223 .as_deref()
224 .filter(|s| !s.is_empty())
225 .unwrap_or(coa_short_desc);
226 let fsa_category =
229 datasynth_core::accounts::AccountCategory::from_account(line.gl_account.as_str())
230 .as_label();
231 let transaction_id = line.transaction_id.clone().unwrap_or_else(|| {
233 datasynth_core::models::JournalEntryLine::derive_transaction_id(
234 line.document_id,
235 line.line_number,
236 )
237 });
238 let fraud_type_str = h.fraud_type.map(|ft| format!("{ft:?}")).unwrap_or_default();
242 let anomaly_type_str = h.anomaly_type.as_deref().unwrap_or("").to_string();
243 writeln!(
244 w,
245 "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}",
246 h.document_id,
247 csv_escape(&h.company_code),
248 h.fiscal_year,
249 h.fiscal_period,
250 h.posting_date,
251 h.document_date,
252 csv_escape(&h.document_type),
253 csv_escape(&h.currency),
254 h.exchange_rate,
255 csv_opt_str(&h.reference),
256 csv_opt_str(&h.header_text),
257 csv_escape(&h.created_by),
258 source_label,
259 h.business_process
260 .map(|bp| format!("{bp:?}"))
261 .unwrap_or_default(),
262 csv_escape(&h.ledger),
263 h.is_fraud,
264 h.is_anomaly,
265 line.line_number,
266 csv_escape(&line.gl_account),
267 line.debit_amount,
268 line.credit_amount,
269 line.local_amount,
270 line.transaction_amount.map(|d| d.to_string()).unwrap_or_default(),
271 csv_opt_str(&line.cost_center),
272 csv_opt_str(&line.profit_center),
273 csv_opt_str(&line.business_unit),
274 csv_opt_str(&line.line_text),
275 csv_opt_str(&line.auxiliary_account_number),
276 csv_opt_str(&line.auxiliary_account_label),
277 csv_opt_str(&line.lettrage),
278 lettrage_date_str,
279 h.is_manual,
280 h.is_post_close,
281 csv_escape(&h.source_system),
282 csv_escape(account_description),
283 fsa_category,
284 csv_opt_str(&line.assignment),
285 value_date_str,
286 csv_opt_str(&line.tax_code),
287 csv_escape(&transaction_id),
288 csv_escape(coa_class),
289 csv_escape(coa_class_name),
290 csv_escape(coa_sub_class),
291 csv_escape(coa_sub_class_name),
292 csv_opt_str(&line.predecessor_line_id),
293 csv_opt_str(&line.trading_partner),
295 csv_escape(&fraud_type_str),
297 csv_escape(&anomaly_type_str),
298 )?;
299 }
300 }
301
302 w.flush()?;
303 let total_lines: usize = result.journal_entries.iter().map(|je| je.lines.len()).sum();
304 info!(
305 " Journal entries CSV written: {} entries, {} line items -> {}",
306 result.journal_entries.len(),
307 total_lines,
308 path.display()
309 );
310 Ok(())
311}
312
313fn write_je_network_csv(
333 result: &EnhancedGenerationResult,
334 output_dir: &Path,
335 method: datasynth_config::JeNetworkMethod,
336) -> Result<(), Box<dyn std::error::Error>> {
337 if result.journal_entries.is_empty() {
338 return Ok(());
339 }
340 let graphs_dir = output_dir.join("graphs");
341 std::fs::create_dir_all(&graphs_dir)?;
342 let path = graphs_dir.join("je_network.csv");
343 let file = std::fs::File::create(&path)?;
344 let mut w = std::io::BufWriter::with_capacity(256 * 1024, file);
345
346 writeln!(
347 w,
348 "edge_id,document_id,posting_date,from_account,to_account,\
349 from_line_id,to_line_id,amount,confidence,\
350 predecessor_edge_id,business_process,is_fraud,is_anomaly,fraud_type"
351 )?;
352
353 let edges = crate::je_network::build_je_network_edges(&result.journal_entries, method);
354
355 for e in &edges {
356 writeln!(
357 w,
358 "{},{},{},{},{},{},{},{},{},{},{},{},{},{}",
359 csv_escape(&e.edge_id),
360 csv_escape(&e.document_id.to_string()),
361 csv_escape(&e.posting_date.to_string()),
362 csv_escape(&e.from_account),
363 csv_escape(&e.to_account),
364 csv_escape(&e.from_line_id),
365 csv_escape(&e.to_line_id),
366 e.amount,
367 e.confidence,
368 csv_escape(&e.predecessor_edge_id),
369 csv_escape(&e.business_process),
370 e.is_fraud,
371 e.is_anomaly,
372 csv_escape(e.fraud_type.as_deref().unwrap_or("")),
373 )?;
374 }
375
376 w.flush()?;
377 info!(
378 " JE network CSV written: {} edges from {} entries -> {}",
379 edges.len(),
380 result.journal_entries.len(),
381 path.display()
382 );
383 Ok(())
384}
385
386fn write_journal_entries_flat_json(
391 result: &EnhancedGenerationResult,
392 output_dir: &Path,
393) -> Result<(), Box<dyn std::error::Error>> {
394 if result.journal_entries.is_empty() {
395 return Ok(());
396 }
397
398 let path = output_dir.join("journal_entries.json");
399 let file = std::fs::File::create(&path)?;
400 let mut writer = std::io::BufWriter::with_capacity(256 * 1024, file);
401
402 writer.write_all(b"[\n")?;
404
405 let mut first = true;
406 let mut total_lines = 0usize;
407 for je in &result.journal_entries {
408 let header_value = serde_json::to_value(&je.header)?;
410
411 for line in &je.lines {
412 if !first {
413 writer.write_all(b",\n")?;
414 }
415 first = false;
416 total_lines += 1;
417
418 let mut line_value = serde_json::to_value(line)?;
420
421 if let serde_json::Value::Object(ref header_map) = header_value {
422 if let serde_json::Value::Object(ref mut line_map) = line_value {
423 for (key, val) in header_map {
424 if !line_map.contains_key(key) {
426 line_map.insert(key.clone(), val.clone());
427 }
428 }
429 }
430 }
431
432 serde_json::to_writer_pretty(&mut writer, &line_value)?;
433 }
434 }
435
436 writer.write_all(b"\n]\n")?;
437 writer.flush()?;
438 info!(
439 " Journal entries (flat JSON) written: {} line items -> {}",
440 total_lines,
441 path.display()
442 );
443 Ok(())
444}
445
446fn add_ocel_object_type_alias(value: &mut serde_json::Value) {
454 if let Some(events) = value.get_mut("events").and_then(|v| v.as_array_mut()) {
455 for event in events.iter_mut() {
456 if let Some(refs) = event.get_mut("object_refs").and_then(|r| r.as_array_mut()) {
457 for oref in refs.iter_mut() {
458 if let Some(obj) = oref.as_object_mut() {
459 if let Some(oti) = obj.get("object_type_id").cloned() {
460 obj.entry("object_type").or_insert(oti);
461 }
462 }
463 }
464 }
465 }
466 }
467}
468
469fn csv_escape(s: &str) -> String {
471 if s.contains(',') || s.contains('"') || s.contains('\n') {
472 format!("\"{}\"", s.replace('"', "\"\""))
473 } else {
474 s.to_string()
475 }
476}
477
478fn csv_opt_str(opt: &Option<String>) -> String {
480 match opt {
481 Some(s) => csv_escape(s),
482 None => String::new(),
483 }
484}
485
486#[allow(dead_code)]
493pub fn write_all_output(
494 result: &EnhancedGenerationResult,
495 output_dir: &Path,
496) -> Result<(), Box<dyn std::error::Error>> {
497 write_all_output_with_layout(
498 result,
499 output_dir,
500 datasynth_config::ExportLayout::Nested,
501 &[
502 datasynth_config::FileFormat::Csv,
503 datasynth_config::FileFormat::Json,
504 ],
505 datasynth_config::JeNetworkMethod::default(),
506 )
507}
508
509#[allow(dead_code)]
523pub fn write_all_output_with_root(
524 result: &EnhancedGenerationResult,
525 root: &OutputRootConfig,
526 export_layout: datasynth_config::ExportLayout,
527 formats: &[datasynth_config::FileFormat],
528) -> Result<(), Box<dyn std::error::Error>> {
529 let effective = root.effective_dir();
530 write_all_output_with_layout(
531 result,
532 &effective,
533 export_layout,
534 formats,
535 datasynth_config::JeNetworkMethod::default(),
536 )
537}
538
539pub fn write_all_output_with_layout(
545 result: &EnhancedGenerationResult,
546 output_dir: &Path,
547 export_layout: datasynth_config::ExportLayout,
548 formats: &[datasynth_config::FileFormat],
549 je_network_method: datasynth_config::JeNetworkMethod,
550) -> Result<(), Box<dyn std::error::Error>> {
551 let csv_enabled = formats.is_empty()
552 || formats.contains(&datasynth_config::FileFormat::Csv)
553 || formats.contains(&datasynth_config::FileFormat::Parquet);
554 let json_enabled = formats.is_empty()
555 || formats.contains(&datasynth_config::FileFormat::Json)
556 || formats.contains(&datasynth_config::FileFormat::JsonLines);
557 std::fs::create_dir_all(output_dir)?;
558 info!("Writing comprehensive output to: {}", output_dir.display());
559
560 struct FlatLayoutGuard;
563 impl Drop for FlatLayoutGuard {
564 fn drop(&mut self) {
565 FLAT_LAYOUT_ACTIVE.with(|c| c.set(false));
566 }
567 }
568 let _flat_guard = if export_layout == datasynth_config::ExportLayout::Flat {
569 FLAT_LAYOUT_ACTIVE.with(|c| c.set(true));
570 Some(FlatLayoutGuard)
571 } else {
572 None
573 };
574
575 struct SkipJsonGuard;
577 impl Drop for SkipJsonGuard {
578 fn drop(&mut self) {
579 SKIP_JSON.with(|c| c.set(false));
580 }
581 }
582 let _skip_json_guard = if !json_enabled {
583 SKIP_JSON.with(|c| c.set(true));
584 info!("JSON output skipped (not in requested formats)");
585 Some(SkipJsonGuard)
586 } else {
587 None
588 };
589
590 if !result.journal_entries.is_empty() {
594 let do_csv = csv_enabled;
595 let do_json = json_enabled;
596 let is_flat = export_layout == datasynth_config::ExportLayout::Flat;
597
598 std::thread::scope(|s| {
599 if do_csv {
600 s.spawn(|| {
601 if let Err(e) = write_journal_entries_csv(result, output_dir) {
602 warn!("Failed to write journal_entries.csv: {}", e);
603 }
604 });
605 s.spawn(|| {
609 if let Err(e) = write_je_network_csv(result, output_dir, je_network_method) {
610 warn!("Failed to write graphs/je_network.csv: {}", e);
611 }
612 });
613 }
614 if do_json {
615 s.spawn(|| {
616 if is_flat {
617 if let Err(e) = write_journal_entries_flat_json(result, output_dir) {
618 warn!("Failed to write flat journal_entries.json: {}", e);
619 }
620 } else if let Err(e) = write_json(
621 &result.journal_entries,
622 &output_dir.join("journal_entries.json"),
623 "Journal entries (JSON)",
624 ) {
625 warn!("Failed to write journal_entries.json: {}", e);
626 }
627 });
628 }
629 });
630 }
631
632 let md_dir = output_dir.join("master_data");
636 if !result.master_data.vendors.is_empty()
637 || !result.master_data.customers.is_empty()
638 || !result.master_data.materials.is_empty()
639 || !result.master_data.assets.is_empty()
640 || !result.master_data.employees.is_empty()
641 || !result.master_data.cost_centers.is_empty()
642 || !result.master_data.profit_centers.is_empty()
643 {
644 std::fs::create_dir_all(&md_dir)?;
645 info!("Writing master data...");
646
647 write_json_safe(
648 &result.master_data.vendors,
649 &md_dir.join("vendors.json"),
650 "Vendors",
651 );
652 write_json_safe(
653 &result.master_data.customers,
654 &md_dir.join("customers.json"),
655 "Customers",
656 );
657 write_json_safe(
658 &result.master_data.materials,
659 &md_dir.join("materials.json"),
660 "Materials",
661 );
662 write_json_safe(
663 &result.master_data.assets,
664 &md_dir.join("fixed_assets.json"),
665 "Fixed assets",
666 );
667 write_json_safe(
668 &result.master_data.employees,
669 &md_dir.join("employees.json"),
670 "Employees",
671 );
672 write_json_safe(
673 &result.master_data.cost_centers,
674 &md_dir.join("cost_centers.json"),
675 "Cost centers",
676 );
677 write_json_safe(
679 &result.master_data.profit_centers,
680 &md_dir.join("profit_centers.json"),
681 "Profit centres",
682 );
683 write_json_safe(
685 &result.master_data.organizational_profiles,
686 &md_dir.join("organizational_profiles.json"),
687 "Organizational profiles (v3.3.0)",
688 );
689 }
690
691 let df_dir = output_dir.join("document_flows");
695 let flat_mode = export_layout == datasynth_config::ExportLayout::Flat;
696 if !result.document_flows.purchase_orders.is_empty()
697 || !result.document_flows.sales_orders.is_empty()
698 {
699 std::fs::create_dir_all(&df_dir)?;
700 info!("Writing document flows...");
701
702 write_json_auto(
703 &result.document_flows.purchase_orders,
704 &df_dir.join("purchase_orders.json"),
705 "Purchase orders",
706 flat_mode,
707 );
708 write_json_auto(
709 &result.document_flows.goods_receipts,
710 &df_dir.join("goods_receipts.json"),
711 "Goods receipts",
712 flat_mode,
713 );
714 write_json_auto(
715 &result.document_flows.vendor_invoices,
716 &df_dir.join("vendor_invoices.json"),
717 "Vendor invoices",
718 flat_mode,
719 );
720 write_json_auto(
721 &result.document_flows.payments,
722 &df_dir.join("payments.json"),
723 "Payments",
724 flat_mode,
725 );
726 let customer_receipts: Vec<_> = result
727 .document_flows
728 .payments
729 .iter()
730 .filter(|p| p.payment_type == PaymentType::ArReceipt)
731 .collect();
732 write_json_auto(
733 &customer_receipts,
734 &df_dir.join("customer_receipts.json"),
735 "Customer receipts",
736 flat_mode,
737 );
738 write_json_auto(
739 &result.document_flows.sales_orders,
740 &df_dir.join("sales_orders.json"),
741 "Sales orders",
742 flat_mode,
743 );
744 write_json_auto(
745 &result.document_flows.deliveries,
746 &df_dir.join("deliveries.json"),
747 "Deliveries",
748 flat_mode,
749 );
750 write_json_auto(
751 &result.document_flows.customer_invoices,
752 &df_dir.join("customer_invoices.json"),
753 "Customer invoices",
754 flat_mode,
755 );
756
757 match serde_json::to_value(&result.document_flows.document_references) {
763 Ok(mut v) => {
764 if let Some(arr) = v.as_array_mut() {
765 for r in arr.iter_mut() {
766 if let Some(obj) = r.as_object_mut() {
767 if let Some(st) = obj.get("source_doc_type").cloned() {
768 obj.entry("from_type").or_insert(st);
769 }
770 if let Some(si) = obj.get("source_doc_id").cloned() {
771 obj.entry("from_id").or_insert(si);
772 }
773 if let Some(tt) = obj.get("target_doc_type").cloned() {
774 obj.entry("to_type").or_insert(tt);
775 }
776 if let Some(ti) = obj.get("target_doc_id").cloned() {
777 obj.entry("to_id").or_insert(ti);
778 }
779 }
780 }
781 }
782 match serde_json::to_string_pretty(&v) {
783 Ok(json) => {
784 let path = df_dir.join("document_references.json");
785 if let Err(e) = std::fs::write(&path, json) {
786 warn!("Failed to write document references: {}", e);
787 } else {
788 info!(
789 " Document references written: {} records -> {}",
790 result.document_flows.document_references.len(),
791 path.display()
792 );
793 }
794 }
795 Err(e) => warn!("Failed to serialize document references: {}", e),
796 }
797 }
798 Err(e) => warn!("Failed to build document references Value: {}", e),
799 }
800
801 if !result.document_flows.p2p_chains.is_empty() {
804 info!(
805 " P2P chains: {} (data exported via individual document files)",
806 result.document_flows.p2p_chains.len()
807 );
808 }
809 if !result.document_flows.o2c_chains.is_empty() {
810 info!(
811 " O2C chains: {} (data exported via individual document files)",
812 result.document_flows.o2c_chains.len()
813 );
814 }
815 }
816
817 let sl_dir = output_dir.join("subledger");
821 if !result.subledger.ap_invoices.is_empty()
822 || !result.subledger.ar_invoices.is_empty()
823 || !result.subledger.fa_records.is_empty()
824 || !result.subledger.inventory_positions.is_empty()
825 {
826 std::fs::create_dir_all(&sl_dir)?;
827 info!("Writing subledger data...");
828
829 write_json_safe(
830 &result.subledger.ap_invoices,
831 &sl_dir.join("ap_invoices.json"),
832 "AP invoices",
833 );
834 write_json_safe(
835 &result.subledger.ar_invoices,
836 &sl_dir.join("ar_invoices.json"),
837 "AR invoices",
838 );
839 write_json_safe(
840 &result.subledger.fa_records,
841 &sl_dir.join("fa_records.json"),
842 "FA records",
843 );
844 write_json_safe(
845 &result.subledger.inventory_positions,
846 &sl_dir.join("inventory_positions.json"),
847 "Inventory positions",
848 );
849 write_json_safe(
850 &result.subledger.inventory_movements,
851 &sl_dir.join("inventory_movements.json"),
852 "Inventory movements",
853 );
854 write_json_safe(
855 &result.subledger.ar_aging_reports,
856 &sl_dir.join("ar_aging.json"),
857 "AR aging reports",
858 );
859 write_json_safe(
860 &result.subledger.ap_aging_reports,
861 &sl_dir.join("ap_aging.json"),
862 "AP aging reports",
863 );
864 write_json_safe(
865 &result.subledger.depreciation_runs,
866 &sl_dir.join("depreciation_runs.json"),
867 "Depreciation runs",
868 );
869 write_json_safe(
870 &result.subledger.inventory_valuations,
871 &sl_dir.join("inventory_valuation.json"),
872 "Inventory valuations",
873 );
874 write_json_safe(
876 &result.subledger.dunning_runs,
877 &sl_dir.join("dunning_runs.json"),
878 "Dunning runs",
879 );
880 write_json_safe(
881 &result.subledger.dunning_letters,
882 &sl_dir.join("dunning_letters.json"),
883 "Dunning letters",
884 );
885 }
886
887 let audit_dir = output_dir.join("audit");
891 if !result.audit.engagements.is_empty() {
892 std::fs::create_dir_all(&audit_dir)?;
893 info!("Writing audit data...");
894
895 write_json_safe(
896 &result.audit.engagements,
897 &audit_dir.join("audit_engagements.json"),
898 "Audit engagements",
899 );
900 write_json_safe(
901 &result.audit.audit_scopes,
902 &audit_dir.join("audit_scopes.json"),
903 "Audit scopes (ISA 220 / ISA 300)",
904 );
905 write_json_safe(
906 &result.audit.workpapers,
907 &audit_dir.join("audit_workpapers.json"),
908 "Audit workpapers",
909 );
910 write_json_safe(
911 &result.audit.evidence,
912 &audit_dir.join("audit_evidence.json"),
913 "Audit evidence",
914 );
915 write_json_safe(
916 &result.audit.risk_assessments,
917 &audit_dir.join("audit_risk_assessments.json"),
918 "Audit risk assessments",
919 );
920 write_json_safe(
921 &result.audit.findings,
922 &audit_dir.join("audit_findings.json"),
923 "Audit findings",
924 );
925 write_json_safe(
926 &result.audit.judgments,
927 &audit_dir.join("audit_judgments.json"),
928 "Audit judgments",
929 );
930 write_json_safe(
931 &result.audit.confirmations,
932 &audit_dir.join("audit_confirmations.json"),
933 "Audit confirmations",
934 );
935 write_json_safe(
936 &result.audit.confirmation_responses,
937 &audit_dir.join("audit_confirmation_responses.json"),
938 "Audit confirmation responses",
939 );
940 write_json_safe(
941 &result.audit.procedure_steps,
942 &audit_dir.join("audit_procedure_steps.json"),
943 "Audit procedure steps",
944 );
945 write_json_safe(
946 &result.audit.samples,
947 &audit_dir.join("audit_samples.json"),
948 "Audit samples",
949 );
950 write_json_safe(
951 &result.audit.analytical_results,
952 &audit_dir.join("audit_analytical_results.json"),
953 "Audit analytical results",
954 );
955 write_json_safe(
956 &result.audit.ia_functions,
957 &audit_dir.join("audit_ia_functions.json"),
958 "Audit IA functions",
959 );
960 write_json_safe(
961 &result.audit.ia_reports,
962 &audit_dir.join("audit_ia_reports.json"),
963 "Audit IA reports",
964 );
965 write_json_safe(
966 &result.audit.related_parties,
967 &audit_dir.join("audit_related_parties.json"),
968 "Audit related parties",
969 );
970 write_json_safe(
971 &result.audit.related_party_transactions,
972 &audit_dir.join("audit_related_party_transactions.json"),
973 "Audit related party transactions",
974 );
975 if !result.audit.component_auditors.is_empty() {
977 write_json_safe(
978 &result.audit.component_auditors,
979 &audit_dir.join("component_auditors.json"),
980 "Component auditors (ISA 600)",
981 );
982 if let Some(plan) = &result.audit.group_audit_plan {
983 write_json_single_safe(
984 plan,
985 &audit_dir.join("group_audit_plan.json"),
986 "Group audit plan (ISA 600)",
987 );
988 }
989 write_json_safe(
990 &result.audit.component_instructions,
991 &audit_dir.join("component_instructions.json"),
992 "Component instructions (ISA 600)",
993 );
994 write_json_safe(
995 &result.audit.component_reports,
996 &audit_dir.join("component_reports.json"),
997 "Component auditor reports (ISA 600)",
998 );
999 }
1000 write_json_safe(
1002 &result.audit.engagement_letters,
1003 &audit_dir.join("engagement_letters.json"),
1004 "Engagement letters (ISA 210)",
1005 );
1006 write_json_safe(
1008 &result.audit.subsequent_events,
1009 &audit_dir.join("subsequent_events.json"),
1010 "Subsequent events (ISA 560 / IAS 10)",
1011 );
1012 write_json_safe(
1014 &result.audit.service_organizations,
1015 &audit_dir.join("service_organizations.json"),
1016 "Service organizations (ISA 402)",
1017 );
1018 write_json_safe(
1019 &result.audit.soc_reports,
1020 &audit_dir.join("soc_reports.json"),
1021 "SOC reports (ISA 402)",
1022 );
1023 write_json_safe(
1024 &result.audit.user_entity_controls,
1025 &audit_dir.join("user_entity_controls.json"),
1026 "User entity controls (ISA 402)",
1027 );
1028
1029 write_json_safe(
1031 &result.audit.going_concern_assessments,
1032 &audit_dir.join("going_concern_assessments.json"),
1033 "Going concern assessments (ISA 570)",
1034 );
1035
1036 write_json_safe(
1038 &result.audit.accounting_estimates,
1039 &audit_dir.join("accounting_estimates.json"),
1040 "Accounting estimates (ISA 540)",
1041 );
1042
1043 write_json_always(
1049 &result.audit.audit_opinions,
1050 &audit_dir.join("audit_opinions.json"),
1051 "Audit opinions (ISA 700/705/706)",
1052 );
1053 write_json_always(
1054 &result.audit.key_audit_matters,
1055 &audit_dir.join("key_audit_matters.json"),
1056 "Key Audit Matters (ISA 701)",
1057 );
1058
1059 if !result.audit.sox_302_certifications.is_empty() {
1061 write_json_safe(
1062 &result.audit.sox_302_certifications,
1063 &audit_dir.join("sox_302_certifications.json"),
1064 "SOX 302 certifications",
1065 );
1066 write_json_safe(
1067 &result.audit.sox_404_assessments,
1068 &audit_dir.join("sox_404_assessments.json"),
1069 "SOX 404 ICFR assessments",
1070 );
1071 }
1072
1073 if !result.audit.materiality_calculations.is_empty() {
1075 write_json_safe(
1076 &result.audit.materiality_calculations,
1077 &audit_dir.join("materiality_calculations.json"),
1078 "Materiality calculations (ISA 320)",
1079 );
1080 }
1081
1082 if !result.audit.combined_risk_assessments.is_empty() {
1084 write_json_safe(
1085 &result.audit.combined_risk_assessments,
1086 &audit_dir.join("combined_risk_assessments.json"),
1087 "Combined Risk Assessments (ISA 315)",
1088 );
1089 }
1090
1091 if !result.audit.sampling_plans.is_empty() {
1093 write_json_safe(
1094 &result.audit.sampling_plans,
1095 &audit_dir.join("sampling_plans.json"),
1096 "Sampling plans (ISA 530)",
1097 );
1098 write_json_safe(
1099 &result.audit.sampled_items,
1100 &audit_dir.join("sampled_items.json"),
1101 "Sampled items (ISA 530)",
1102 );
1103 }
1104
1105 if !result.audit.significant_transaction_classes.is_empty() {
1107 write_json_safe(
1108 &result.audit.significant_transaction_classes,
1109 &audit_dir.join("significant_transaction_classes.json"),
1110 "Significant Classes of Transactions / SCOTS (ISA 315)",
1111 );
1112 }
1113
1114 if !result.audit.unusual_items.is_empty() {
1116 write_json_safe(
1117 &result.audit.unusual_items,
1118 &audit_dir.join("unusual_items.json"),
1119 "Unusual item flags (ISA 520)",
1120 );
1121 }
1122
1123 if !result.audit.analytical_relationships.is_empty() {
1125 write_json_safe(
1126 &result.audit.analytical_relationships,
1127 &audit_dir.join("analytical_relationships.json"),
1128 "Analytical relationships (ISA 520)",
1129 );
1130 }
1131
1132 if !result.audit.isa_pcaob_mappings.is_empty() {
1134 write_json_safe(
1135 &result.audit.isa_pcaob_mappings,
1136 &audit_dir.join("isa_pcaob_mappings.json"),
1137 "PCAOB-ISA standard mappings",
1138 );
1139 }
1140
1141 if !result.audit.isa_mappings.is_empty() {
1143 write_json_safe(
1144 &result.audit.isa_mappings,
1145 &audit_dir.join("isa_mappings.json"),
1146 "ISA standard reference mappings",
1147 );
1148 }
1149
1150 if let Some(ref event_trail) = result.audit.fsm_event_trail {
1152 if !event_trail.is_empty() {
1153 write_json_safe(
1154 event_trail,
1155 &audit_dir.join("fsm_event_trail.json"),
1156 "FSM audit event trail",
1157 );
1158 }
1159 }
1160
1161 write_json_safe(
1163 &result.audit.legal_documents,
1164 &audit_dir.join("legal_documents.json"),
1165 "Legal documents (v3.3.0)",
1166 );
1167
1168 write_json_safe(
1170 &result.audit.it_controls_access_logs,
1171 &audit_dir.join("it_controls_access_logs.json"),
1172 "IT general controls — access logs (v3.3.0)",
1173 );
1174 write_json_safe(
1175 &result.audit.it_controls_change_records,
1176 &audit_dir.join("it_controls_change_records.json"),
1177 "IT general controls — change management records (v3.3.0)",
1178 );
1179 } else {
1180 std::fs::create_dir_all(&audit_dir)?;
1186 write_json_always(
1187 &result.audit.audit_opinions,
1188 &audit_dir.join("audit_opinions.json"),
1189 "Audit opinions (ISA 700/705/706) — empty (audit phase disabled)",
1190 );
1191 write_json_always(
1192 &result.audit.key_audit_matters,
1193 &audit_dir.join("key_audit_matters.json"),
1194 "Key Audit Matters (ISA 701) — empty (audit phase disabled)",
1195 );
1196 }
1197
1198 let banking_dir = output_dir.join("banking");
1202 if !result.banking.customers.is_empty() {
1203 std::fs::create_dir_all(&banking_dir)?;
1204 info!("Writing banking data...");
1205
1206 match serde_json::to_value(&result.banking.customers) {
1212 Ok(mut v) => {
1213 if let Some(arr) = v.as_array_mut() {
1214 for c in arr.iter_mut() {
1215 if let Some(obj) = c.as_object_mut() {
1216 if let Some(rt) = obj.get("risk_tier").cloned() {
1217 obj.entry("risk_level").or_insert(rt);
1218 }
1219 }
1220 }
1221 }
1222 match serde_json::to_string_pretty(&v) {
1223 Ok(json) => {
1224 let path = banking_dir.join("banking_customers.json");
1225 if let Err(e) = std::fs::write(&path, json) {
1226 warn!("Failed to write banking_customers.json: {}", e);
1227 } else {
1228 info!(
1229 " Banking customers written: {} records -> {}",
1230 result.banking.customers.len(),
1231 path.display()
1232 );
1233 }
1234 }
1235 Err(e) => warn!("Failed to serialize banking customers: {}", e),
1236 }
1237 }
1238 Err(e) => warn!("Failed to build banking customers Value: {}", e),
1239 }
1240 write_json_safe(
1241 &result.banking.accounts,
1242 &banking_dir.join("banking_accounts.json"),
1243 "Banking accounts",
1244 );
1245 write_json_safe(
1246 &result.banking.transactions,
1247 &banking_dir.join("banking_transactions.json"),
1248 "Banking transactions",
1249 );
1250 write_json_safe(
1251 &result.banking.transaction_labels,
1252 &banking_dir.join("aml_transaction_labels.json"),
1253 "AML transaction labels",
1254 );
1255 write_json_safe(
1256 &result.banking.customer_labels,
1257 &banking_dir.join("aml_customer_labels.json"),
1258 "AML customer labels",
1259 );
1260 write_json_safe(
1261 &result.banking.account_labels,
1262 &banking_dir.join("aml_account_labels.json"),
1263 "AML account labels",
1264 );
1265 write_json_safe(
1266 &result.banking.relationship_labels,
1267 &banking_dir.join("aml_relationship_labels.json"),
1268 "AML relationship labels",
1269 );
1270 write_json_safe(
1271 &result.banking.narratives,
1272 &banking_dir.join("aml_narratives.json"),
1273 "AML narratives",
1274 );
1275 }
1276
1277 let s2c_dir = output_dir.join("sourcing");
1281 if !result.sourcing.spend_analyses.is_empty() || !result.sourcing.sourcing_projects.is_empty() {
1282 std::fs::create_dir_all(&s2c_dir)?;
1283 info!("Writing sourcing (S2C) data...");
1284
1285 write_json_safe(
1286 &result.sourcing.spend_analyses,
1287 &s2c_dir.join("spend_analyses.json"),
1288 "Spend analyses",
1289 );
1290 write_json_safe(
1291 &result.sourcing.sourcing_projects,
1292 &s2c_dir.join("sourcing_projects.json"),
1293 "Sourcing projects",
1294 );
1295 write_json_safe(
1296 &result.sourcing.qualifications,
1297 &s2c_dir.join("supplier_qualifications.json"),
1298 "Supplier qualifications",
1299 );
1300 write_json_safe(
1301 &result.sourcing.rfx_events,
1302 &s2c_dir.join("rfx_events.json"),
1303 "RFx events",
1304 );
1305 write_json_safe(
1306 &result.sourcing.bids,
1307 &s2c_dir.join("supplier_bids.json"),
1308 "Supplier bids",
1309 );
1310 write_json_safe(
1311 &result.sourcing.bid_evaluations,
1312 &s2c_dir.join("bid_evaluations.json"),
1313 "Bid evaluations",
1314 );
1315 write_json_safe(
1316 &result.sourcing.contracts,
1317 &s2c_dir.join("procurement_contracts.json"),
1318 "Procurement contracts",
1319 );
1320 write_json_safe(
1321 &result.sourcing.catalog_items,
1322 &s2c_dir.join("catalog_items.json"),
1323 "Catalog items",
1324 );
1325 write_json_safe(
1326 &result.sourcing.scorecards,
1327 &s2c_dir.join("supplier_scorecards.json"),
1328 "Supplier scorecards",
1329 );
1330 }
1331
1332 let ic_dir = output_dir.join("intercompany");
1336 if result.intercompany.group_structure.is_some()
1337 || !result.intercompany.matched_pairs.is_empty()
1338 {
1339 std::fs::create_dir_all(&ic_dir)?;
1340 info!("Writing intercompany data...");
1341
1342 if let Some(gs) = &result.intercompany.group_structure {
1344 write_json_single_safe(gs, &ic_dir.join("group_structure.json"), "Group structure");
1345 }
1346
1347 write_json_safe(
1348 &result.intercompany.matched_pairs,
1349 &ic_dir.join("ic_matched_pairs.json"),
1350 "IC matched pairs",
1351 );
1352 write_json_safe(
1353 &result.intercompany.seller_journal_entries,
1354 &ic_dir.join("ic_seller_journal_entries.json"),
1355 "IC seller journal entries",
1356 );
1357 write_json_safe(
1358 &result.intercompany.buyer_journal_entries,
1359 &ic_dir.join("ic_buyer_journal_entries.json"),
1360 "IC buyer journal entries",
1361 );
1362 write_json_safe(
1363 &result.intercompany.elimination_entries,
1364 &ic_dir.join("ic_elimination_entries.json"),
1365 "IC elimination entries",
1366 );
1367
1368 if !result.intercompany.nci_measurements.is_empty() {
1370 write_json_safe(
1371 &result.intercompany.nci_measurements,
1372 &ic_dir.join("nci_measurements.json"),
1373 "NCI measurements",
1374 );
1375 }
1376 }
1377
1378 let fin_dir = output_dir.join("financial_reporting");
1382 if !result.financial_reporting.financial_statements.is_empty()
1383 || !result.financial_reporting.bank_reconciliations.is_empty()
1384 || !result
1385 .financial_reporting
1386 .consolidated_statements
1387 .is_empty()
1388 {
1389 std::fs::create_dir_all(&fin_dir)?;
1390 info!("Writing financial reporting data...");
1391
1392 write_json_safe(
1394 &result.financial_reporting.financial_statements,
1395 &fin_dir.join("financial_statements.json"),
1396 "Financial statements",
1397 );
1398
1399 if !result.financial_reporting.standalone_statements.is_empty() {
1401 let standalone_dir = fin_dir.join("standalone");
1402 std::fs::create_dir_all(&standalone_dir)?;
1403 for (entity_code, stmts) in &result.financial_reporting.standalone_statements {
1404 let file_name = format!("{}_financial_statements.json", entity_code);
1405 write_json_safe(
1406 stmts,
1407 &standalone_dir.join(&file_name),
1408 &format!("Standalone statements for {}", entity_code),
1409 );
1410 }
1411 }
1412
1413 if !result
1415 .financial_reporting
1416 .consolidated_statements
1417 .is_empty()
1418 || !result
1419 .financial_reporting
1420 .consolidation_schedules
1421 .is_empty()
1422 {
1423 let consolidated_dir = fin_dir.join("consolidated");
1424 std::fs::create_dir_all(&consolidated_dir)?;
1425 write_json_safe(
1426 &result.financial_reporting.consolidated_statements,
1427 &consolidated_dir.join("consolidated_financial_statements.json"),
1428 "Consolidated financial statements",
1429 );
1430 write_json_safe(
1431 &result.financial_reporting.consolidation_schedules,
1432 &consolidated_dir.join("consolidation_schedule.json"),
1433 "Consolidation schedule",
1434 );
1435 }
1436
1437 write_json_safe(
1438 &result.financial_reporting.bank_reconciliations,
1439 &fin_dir.join("bank_reconciliations.json"),
1440 "Bank reconciliations",
1441 );
1442
1443 if !result.financial_reporting.segment_reports.is_empty()
1445 || !result
1446 .financial_reporting
1447 .segment_reconciliations
1448 .is_empty()
1449 {
1450 let seg_dir = fin_dir.join("segment_reporting");
1451 std::fs::create_dir_all(&seg_dir)?;
1452 write_json_safe(
1453 &result.financial_reporting.segment_reports,
1454 &seg_dir.join("segment_reports.json"),
1455 "Segment reports",
1456 );
1457 write_json_safe(
1458 &result.financial_reporting.segment_reconciliations,
1459 &seg_dir.join("segment_reconciliations.json"),
1460 "Segment reconciliations",
1461 );
1462 }
1463
1464 write_json_safe(
1466 &result.financial_reporting.notes_to_financial_statements,
1467 &fin_dir.join("notes_to_financial_statements.json"),
1468 "Notes to financial statements",
1469 );
1470 }
1471
1472 if !result.financial_reporting.trial_balances.is_empty() {
1483 let pc_dir = output_dir.join("period_close");
1484 std::fs::create_dir_all(&pc_dir)?;
1485 info!(
1486 "Writing {} period-close trial balances...",
1487 result.financial_reporting.trial_balances.len()
1488 );
1489 let (company_code, currency) = result
1496 .journal_entries
1497 .first()
1498 .map(|je| (je.header.company_code.as_str(), je.header.currency.as_str()))
1499 .unwrap_or(("UNKNOWN", "USD"));
1500 let canonical: Vec<datasynth_core::models::balance::TrialBalance> = result
1501 .financial_reporting
1502 .trial_balances
1503 .iter()
1504 .cloned()
1505 .map(|tb| tb.into_canonical(company_code, currency))
1506 .collect();
1507 write_json_safe(
1508 &canonical,
1509 &pc_dir.join("trial_balances.json"),
1510 "Period-close trial balances (canonical)",
1511 );
1512 }
1513
1514 if !result.opening_balances.is_empty() || !result.subledger_reconciliation.is_empty() {
1518 let balance_dir = output_dir.join("balance");
1519 std::fs::create_dir_all(&balance_dir)?;
1520 info!("Writing balance data...");
1521
1522 write_json_safe(
1523 &result.opening_balances,
1524 &balance_dir.join("opening_balances.json"),
1525 "Opening balances",
1526 );
1527 write_json_safe(
1528 &result.subledger_reconciliation,
1529 &balance_dir.join("subledger_reconciliation.json"),
1530 "Subledger reconciliation",
1531 );
1532 }
1533
1534 let hr_dir = output_dir.join("hr");
1538 if !result.hr.payroll_runs.is_empty()
1539 || !result.hr.time_entries.is_empty()
1540 || !result.hr.expense_reports.is_empty()
1541 || !result.hr.benefit_enrollments.is_empty()
1542 || !result.hr.pension_plans.is_empty()
1543 || !result.hr.stock_grants.is_empty()
1544 || !result.master_data.employee_change_history.is_empty()
1545 {
1546 std::fs::create_dir_all(&hr_dir)?;
1547 info!("Writing HR data...");
1548
1549 write_json_safe(
1550 &result.hr.payroll_runs,
1551 &hr_dir.join("payroll_runs.json"),
1552 "Payroll runs",
1553 );
1554 write_json_safe(
1555 &result.hr.payroll_line_items,
1556 &hr_dir.join("payroll_line_items.json"),
1557 "Payroll line items",
1558 );
1559 write_json_safe(
1560 &result.hr.time_entries,
1561 &hr_dir.join("time_entries.json"),
1562 "Time entries",
1563 );
1564 write_json_safe(
1565 &result.hr.expense_reports,
1566 &hr_dir.join("expense_reports.json"),
1567 "Expense reports",
1568 );
1569 write_json_safe(
1570 &result.hr.benefit_enrollments,
1571 &hr_dir.join("benefit_enrollments.json"),
1572 "Benefit enrollments",
1573 );
1574 write_json_safe(
1575 &result.hr.pension_plans,
1576 &hr_dir.join("pension_plans.json"),
1577 "Pension plans",
1578 );
1579 write_json_safe(
1580 &result.hr.pension_obligations,
1581 &hr_dir.join("pension_obligations.json"),
1582 "Pension obligations",
1583 );
1584 write_json_safe(
1585 &result.hr.pension_plan_assets,
1586 &hr_dir.join("plan_assets.json"),
1587 "Plan assets",
1588 );
1589 write_json_safe(
1590 &result.hr.pension_disclosures,
1591 &hr_dir.join("pension_disclosures.json"),
1592 "Pension disclosures",
1593 );
1594 write_json_safe(
1595 &result.hr.stock_grants,
1596 &hr_dir.join("stock_grants.json"),
1597 "Stock grants",
1598 );
1599 write_json_safe(
1600 &result.hr.stock_comp_expenses,
1601 &hr_dir.join("stock_comp_expense.json"),
1602 "Stock comp expense",
1603 );
1604 write_json_safe(
1605 &result.master_data.employee_change_history,
1606 &hr_dir.join("employee_change_history.json"),
1607 "Employee change history",
1608 );
1609 }
1610
1611 let mfg_dir = output_dir.join("manufacturing");
1615 if !result.manufacturing.production_orders.is_empty()
1616 || !result.manufacturing.quality_inspections.is_empty()
1617 || !result.manufacturing.cycle_counts.is_empty()
1618 || !result.manufacturing.bom_components.is_empty()
1619 || !result.manufacturing.inventory_movements.is_empty()
1620 {
1621 std::fs::create_dir_all(&mfg_dir)?;
1622 info!("Writing manufacturing data...");
1623
1624 write_json_safe(
1625 &result.manufacturing.production_orders,
1626 &mfg_dir.join("production_orders.json"),
1627 "Production orders",
1628 );
1629 write_json_safe(
1630 &result.manufacturing.quality_inspections,
1631 &mfg_dir.join("quality_inspections.json"),
1632 "Quality inspections",
1633 );
1634 write_json_safe(
1635 &result.manufacturing.cycle_counts,
1636 &mfg_dir.join("cycle_counts.json"),
1637 "Cycle counts",
1638 );
1639 write_json_safe(
1640 &result.manufacturing.bom_components,
1641 &mfg_dir.join("bom_components.json"),
1642 "BOM components",
1643 );
1644 write_json_safe(
1645 &result.manufacturing.inventory_movements,
1646 &mfg_dir.join("inventory_movements.json"),
1647 "Inventory movements",
1648 );
1649 }
1650
1651 let sales_dir = output_dir.join("sales_kpi_budgets");
1655 if !result.sales_kpi_budgets.sales_quotes.is_empty()
1656 || !result.sales_kpi_budgets.kpis.is_empty()
1657 || !result.sales_kpi_budgets.budgets.is_empty()
1658 || !result.sales_kpi_budgets.external_expectations.is_empty()
1659 || !result.sales_kpi_budgets.evidence_anchors.is_empty()
1660 {
1661 std::fs::create_dir_all(&sales_dir)?;
1662 info!("Writing sales, KPI, and budget data...");
1663
1664 write_json_safe(
1665 &result.sales_kpi_budgets.sales_quotes,
1666 &sales_dir.join("sales_quotes.json"),
1667 "Sales quotes",
1668 );
1669 write_json_safe(
1670 &result.sales_kpi_budgets.kpis,
1671 &sales_dir.join("management_kpis.json"),
1672 "Management KPIs",
1673 );
1674 write_json_safe(
1675 &result.sales_kpi_budgets.budgets,
1676 &sales_dir.join("budgets.json"),
1677 "Budgets",
1678 );
1679 write_json_safe(
1680 &result.sales_kpi_budgets.external_expectations,
1681 &sales_dir.join("external_expectations.json"),
1682 "External expectations",
1683 );
1684 write_json_safe(
1685 &result.sales_kpi_budgets.evidence_anchors,
1686 &sales_dir.join("evidence_anchors.json"),
1687 "Evidence anchors",
1688 );
1689 }
1690
1691 let tax_dir = output_dir.join("tax");
1695 if !result.tax.jurisdictions.is_empty()
1696 || !result.tax.codes.is_empty()
1697 || !result.tax.tax_provisions.is_empty()
1698 {
1699 std::fs::create_dir_all(&tax_dir)?;
1700 info!("Writing tax data...");
1701
1702 write_json_safe(
1703 &result.tax.jurisdictions,
1704 &tax_dir.join("tax_jurisdictions.json"),
1705 "Tax jurisdictions",
1706 );
1707 write_json_safe(
1708 &result.tax.codes,
1709 &tax_dir.join("tax_codes.json"),
1710 "Tax codes",
1711 );
1712 write_json_safe(
1713 &result.tax.tax_provisions,
1714 &tax_dir.join("tax_provisions.json"),
1715 "Tax provisions",
1716 );
1717 write_json_safe(
1718 &result.tax.tax_lines,
1719 &tax_dir.join("tax_lines.json"),
1720 "Tax lines",
1721 );
1722 write_json_safe(
1723 &result.tax.tax_returns,
1724 &tax_dir.join("tax_returns.json"),
1725 "Tax returns",
1726 );
1727 write_json_safe(
1728 &result.tax.withholding_records,
1729 &tax_dir.join("withholding_records.json"),
1730 "Withholding tax records",
1731 );
1732 if !result.tax.tax_anomaly_labels.is_empty() {
1733 write_json_safe(
1734 &result.tax.tax_anomaly_labels,
1735 &tax_dir.join("tax_anomaly_labels.json"),
1736 "Tax anomaly labels",
1737 );
1738 }
1739 if !result.tax.deferred_tax.temporary_differences.is_empty() {
1741 write_json_safe(
1742 &result.tax.deferred_tax.temporary_differences,
1743 &tax_dir.join("temporary_differences.json"),
1744 "Temporary differences",
1745 );
1746 write_json_safe(
1747 &result.tax.deferred_tax.etr_reconciliations,
1748 &tax_dir.join("etr_reconciliation.json"),
1749 "ETR reconciliation",
1750 );
1751 write_json_safe(
1752 &result.tax.deferred_tax.rollforwards,
1753 &tax_dir.join("deferred_tax_rollforward.json"),
1754 "Deferred tax rollforward",
1755 );
1756 write_json_safe(
1757 &result.tax.deferred_tax.journal_entries,
1758 &tax_dir.join("deferred_tax_journal_entries.json"),
1759 "Deferred tax journal entries",
1760 );
1761 }
1762 }
1763
1764 let esg_dir = output_dir.join("esg");
1768 if !result.esg.emissions.is_empty()
1769 || !result.esg.energy.is_empty()
1770 || !result.esg.diversity.is_empty()
1771 || !result.esg.governance.is_empty()
1772 {
1773 std::fs::create_dir_all(&esg_dir)?;
1774 info!("Writing ESG data...");
1775
1776 write_json_safe(
1777 &result.esg.emissions,
1778 &esg_dir.join("emission_records.json"),
1779 "Emission records",
1780 );
1781 write_json_safe(
1782 &result.esg.energy,
1783 &esg_dir.join("energy_consumption.json"),
1784 "Energy consumption",
1785 );
1786 write_json_safe(
1787 &result.esg.water,
1788 &esg_dir.join("water_usage.json"),
1789 "Water usage",
1790 );
1791 write_json_safe(
1792 &result.esg.waste,
1793 &esg_dir.join("waste_records.json"),
1794 "Waste records",
1795 );
1796 write_json_safe(
1797 &result.esg.diversity,
1798 &esg_dir.join("workforce_diversity.json"),
1799 "Workforce diversity",
1800 );
1801 write_json_safe(
1802 &result.esg.pay_equity,
1803 &esg_dir.join("pay_equity.json"),
1804 "Pay equity",
1805 );
1806 write_json_safe(
1807 &result.esg.safety_incidents,
1808 &esg_dir.join("safety_incidents.json"),
1809 "Safety incidents",
1810 );
1811 write_json_safe(
1812 &result.esg.safety_metrics,
1813 &esg_dir.join("safety_metrics.json"),
1814 "Safety metrics",
1815 );
1816 write_json_safe(
1817 &result.esg.governance,
1818 &esg_dir.join("governance_metrics.json"),
1819 "Governance metrics",
1820 );
1821 write_json_safe(
1822 &result.esg.supplier_assessments,
1823 &esg_dir.join("supplier_esg_assessments.json"),
1824 "Supplier ESG assessments",
1825 );
1826 write_json_safe(
1827 &result.esg.materiality,
1828 &esg_dir.join("materiality_assessments.json"),
1829 "Materiality assessments",
1830 );
1831 write_json_safe(
1832 &result.esg.disclosures,
1833 &esg_dir.join("esg_disclosures.json"),
1834 "ESG disclosures",
1835 );
1836 write_json_safe(
1837 &result.esg.climate_scenarios,
1838 &esg_dir.join("climate_scenarios.json"),
1839 "Climate scenarios",
1840 );
1841 write_json_safe(
1842 &result.esg.anomaly_labels,
1843 &esg_dir.join("esg_anomaly_labels.json"),
1844 "ESG anomaly labels",
1845 );
1846 }
1847
1848 if let Some(ref event_log) = result.ocpm.event_log {
1852 if !event_log.events.is_empty() || !event_log.objects.is_empty() {
1853 let pm_dir = output_dir.join("process_mining");
1854 std::fs::create_dir_all(&pm_dir)?;
1855 info!("Writing process mining (OCPM) data...");
1856
1857 match serde_json::to_value(event_log) {
1863 Ok(mut v) => {
1864 add_ocel_object_type_alias(&mut v);
1865 match serde_json::to_string_pretty(&v) {
1866 Ok(json) => {
1867 if let Err(e) = std::fs::write(pm_dir.join("event_log.json"), json) {
1868 warn!("Failed to write OCPM event log: {}", e);
1869 } else {
1870 info!(
1871 " Event log written: {} events, {} objects",
1872 result.ocpm.event_count, result.ocpm.object_count
1873 );
1874 }
1875 }
1876 Err(e) => warn!("Failed to serialize OCPM event log: {}", e),
1877 }
1878 }
1879 Err(e) => warn!("Failed to build OCPM event log Value: {}", e),
1880 }
1881
1882 if !event_log.events.is_empty() {
1884 match serde_json::to_string_pretty(&event_log.events) {
1885 Ok(json) => {
1886 if let Err(e) = std::fs::write(pm_dir.join("events.json"), json) {
1887 warn!("Failed to write OCPM events: {}", e);
1888 } else {
1889 info!(" Events written: {} records", event_log.events.len());
1890 }
1891 }
1892 Err(e) => warn!("Failed to serialize OCPM events: {}", e),
1893 }
1894 }
1895
1896 if !event_log.objects.is_empty() {
1898 let objects: Vec<&_> = event_log.objects.iter().collect();
1899 match serde_json::to_string_pretty(&objects) {
1900 Ok(json) => {
1901 if let Err(e) = std::fs::write(pm_dir.join("objects.json"), json) {
1902 warn!("Failed to write OCPM objects: {}", e);
1903 } else {
1904 info!(" Objects written: {} records", event_log.objects.len());
1905 }
1906 }
1907 Err(e) => warn!("Failed to serialize OCPM objects: {}", e),
1908 }
1909 }
1910
1911 if !event_log.variants.is_empty() {
1913 let variants: Vec<&_> = event_log.variants.values().collect();
1914 match serde_json::to_string_pretty(&variants) {
1915 Ok(json) => {
1916 if let Err(e) = std::fs::write(pm_dir.join("process_variants.json"), json) {
1917 warn!("Failed to write process variants: {}", e);
1918 } else {
1919 info!(
1920 " Process variants written: {} variants",
1921 event_log.variants.len()
1922 );
1923 }
1924 }
1925 Err(e) => warn!("Failed to serialize process variants: {}", e),
1926 }
1927 }
1928 }
1929 }
1930
1931 match serde_json::to_string_pretty(&result.chart_of_accounts.accounts) {
1937 Ok(json) => {
1938 if let Err(e) = std::fs::write(output_dir.join("chart_of_accounts.json"), json) {
1939 warn!("Failed to write chart of accounts: {}", e);
1940 } else {
1941 info!(" Chart of accounts written");
1942 }
1943 }
1944 Err(e) => warn!("Failed to serialize chart of accounts: {}", e),
1945 }
1946 let coa_meta = serde_json::json!({
1952 "coa_id": result.chart_of_accounts.coa_id,
1953 "name": result.chart_of_accounts.name,
1954 "country": result.chart_of_accounts.country,
1955 "industry": result.chart_of_accounts.industry,
1956 "complexity": result.chart_of_accounts.complexity,
1957 "account_format": result.chart_of_accounts.account_format,
1958 "accounting_framework": result.chart_of_accounts.accounting_framework,
1959 "account_count": result.chart_of_accounts.accounts.len(),
1960 });
1961 match serde_json::to_string_pretty(&coa_meta) {
1962 Ok(json) => {
1963 if let Err(e) = std::fs::write(output_dir.join("chart_of_accounts_meta.json"), json) {
1964 warn!("Failed to write CoA metadata: {}", e);
1965 } else {
1966 info!(
1967 " Chart of accounts metadata written (accounting_framework: {:?})",
1968 result.chart_of_accounts.accounting_framework
1969 );
1970 }
1971 }
1972 Err(e) => warn!("Failed to serialize CoA metadata: {}", e),
1973 }
1974
1975 if result.balance_validation.validated {
1979 match serde_json::to_string_pretty(&BalanceValidationSummary::from(
1980 &result.balance_validation,
1981 )) {
1982 Ok(json) => {
1983 if let Err(e) = std::fs::write(output_dir.join("balance_validation.json"), json) {
1984 warn!("Failed to write balance validation: {}", e);
1985 } else {
1986 info!(" Balance validation summary written");
1987 }
1988 }
1989 Err(e) => warn!("Failed to serialize balance validation: {}", e),
1990 }
1991 }
1992
1993 {
1997 match serde_json::to_string_pretty(&result.data_quality_stats) {
1998 Ok(json) => {
1999 if let Err(e) = std::fs::write(output_dir.join("data_quality_stats.json"), json) {
2000 warn!("Failed to write data quality stats: {}", e);
2001 } else {
2002 info!(" Data quality stats written (full detail)");
2003 }
2004 }
2005 Err(e) => warn!("Failed to serialize data quality stats: {}", e),
2006 }
2007 }
2008
2009 {
2014 let am = &result.analytics_metadata;
2015 if !am.prior_year_comparatives.is_empty()
2016 || !am.industry_benchmarks.is_empty()
2017 || !am.management_reports.is_empty()
2018 || !am.drift_events.is_empty()
2019 {
2020 let analytics_dir = output_dir.join("analytics");
2021 std::fs::create_dir_all(&analytics_dir)?;
2022 write_json_safe(
2023 &am.prior_year_comparatives,
2024 &analytics_dir.join("prior_year_comparatives.json"),
2025 "Prior-year comparatives (v3.3.0)",
2026 );
2027 write_json_safe(
2028 &am.industry_benchmarks,
2029 &analytics_dir.join("industry_benchmarks.json"),
2030 "Industry benchmarks (v3.3.0)",
2031 );
2032 write_json_safe(
2033 &am.management_reports,
2034 &analytics_dir.join("management_reports.json"),
2035 "Management reports (v3.3.0)",
2036 );
2037 write_json_safe(
2038 &am.drift_events,
2039 &analytics_dir.join("drift_events.json"),
2040 "Drift event labels (v3.3.0)",
2041 );
2042 }
2043 }
2044
2045 {
2049 let analytics_dir = output_dir.join("analytics");
2050
2051 let amounts: Vec<_> = result
2053 .journal_entries
2054 .iter()
2055 .flat_map(|je| je.lines.iter())
2056 .flat_map(|line| {
2057 let d = (!line.debit_amount.is_zero()).then_some(line.debit_amount);
2058 let c = (!line.credit_amount.is_zero()).then_some(line.credit_amount);
2059 d.into_iter().chain(c)
2060 })
2061 .collect();
2062
2063 if amounts.len() >= 10 {
2064 std::fs::create_dir_all(&analytics_dir)?;
2065 info!("Writing pre-built analytics ({} amounts)...", amounts.len());
2066
2067 let benford_analyzer = datasynth_eval::BenfordAnalyzer::default();
2069 match benford_analyzer.analyze(&amounts) {
2070 Ok(ref benford_result) => {
2071 if let Ok(json) = serde_json::to_string_pretty(benford_result) {
2072 if let Err(e) =
2073 std::fs::write(analytics_dir.join("benford_analysis.json"), json)
2074 {
2075 warn!("Failed to write Benford analysis: {}", e);
2076 } else {
2077 info!(
2078 " Benford analysis written (conformity: {:?}, MAD: {:.4})",
2079 benford_result.conformity, benford_result.mad
2080 );
2081 }
2082 }
2083 }
2084 Err(e) => warn!("Benford analysis skipped: {}", e),
2085 }
2086
2087 let amount_analyzer = datasynth_eval::AmountDistributionAnalyzer::new();
2089 match amount_analyzer.analyze(&amounts) {
2090 Ok(ref dist_result) => {
2091 if let Ok(json) = serde_json::to_string_pretty(dist_result) {
2092 if let Err(e) =
2093 std::fs::write(analytics_dir.join("amount_distribution.json"), json)
2094 {
2095 warn!("Failed to write amount distribution: {}", e);
2096 } else {
2097 info!(
2098 " Amount distribution written (skewness: {:.2}, kurtosis: {:.2})",
2099 dist_result.skewness, dist_result.kurtosis
2100 );
2101 }
2102 }
2103 }
2104 Err(e) => warn!("Amount distribution analysis skipped: {}", e),
2105 }
2106 }
2107
2108 if let Some(ref event_log) = result.ocpm.event_log {
2118 std::fs::create_dir_all(&analytics_dir)?;
2119 let variant_data: Vec<datasynth_eval::VariantData> = if !event_log.variants.is_empty() {
2120 event_log
2121 .variants
2122 .values()
2123 .map(|v| datasynth_eval::VariantData {
2124 variant_id: v.variant_id.clone(),
2125 case_count: v.frequency as usize,
2126 is_happy_path: v.is_happy_path,
2127 })
2128 .collect()
2129 } else {
2130 use std::collections::HashMap;
2136 let mut per_case: HashMap<String, Vec<String>> = HashMap::new();
2139 for ev in &event_log.events {
2140 if let Some(case_id) = ev.case_id {
2141 per_case
2142 .entry(case_id.to_string())
2143 .or_default()
2144 .push(ev.activity_id.clone());
2145 }
2146 }
2147 let mut variant_counts: HashMap<Vec<String>, usize> = HashMap::new();
2148 for activities in per_case.into_values() {
2149 *variant_counts.entry(activities).or_insert(0) += 1;
2150 }
2151 let max_count = variant_counts.values().copied().max().unwrap_or(0);
2153 variant_counts
2154 .into_iter()
2155 .enumerate()
2156 .map(|(i, (seq, count))| datasynth_eval::VariantData {
2157 variant_id: format!("V{i:04}:{}", seq.join("->")),
2158 case_count: count,
2159 is_happy_path: count == max_count && max_count > 0,
2160 })
2161 .collect()
2162 };
2163
2164 let variant_analyzer = datasynth_eval::VariantAnalyzer::new();
2165 match variant_analyzer.analyze(&variant_data) {
2166 Ok(ref variant_result) => {
2167 if let Ok(json) = serde_json::to_string_pretty(variant_result) {
2168 if let Err(e) =
2169 std::fs::write(analytics_dir.join("process_variant_summary.json"), json)
2170 {
2171 warn!("Failed to write variant summary: {}", e);
2172 } else {
2173 info!(
2174 " Process variant summary written ({} variants, entropy: {:.2})",
2175 variant_result.variant_count, variant_result.variant_entropy
2176 );
2177 }
2178 }
2179 }
2180 Err(e) => {
2181 warn!("Variant analysis failed: {}; emitting empty summary", e);
2184 let placeholder = serde_json::json!({
2185 "variant_count": 0,
2186 "variant_entropy": null,
2187 "happy_path_concentration": null,
2188 "top_variants": [],
2189 "passes": false,
2190 "issues": [format!("analyzer error: {e}")],
2191 });
2192 if let Ok(json) = serde_json::to_string_pretty(&placeholder) {
2193 let _ = std::fs::write(
2194 analytics_dir.join("process_variant_summary.json"),
2195 json,
2196 );
2197 }
2198 }
2199 }
2200 }
2201
2202 if !result.banking.customers.is_empty() {
2206 use datasynth_core::models::banking::BankingCustomerType;
2207 use datasynth_eval::banking::{
2208 AmlDetectabilityAnalyzer, AmlTransactionData, BankingEvaluation,
2209 KycCompletenessAnalyzer, KycProfileData, TypologyData,
2210 };
2211 use std::collections::HashMap;
2212 std::fs::create_dir_all(&analytics_dir)?;
2213
2214 let kyc_data: Vec<KycProfileData> = result
2215 .banking
2216 .customers
2217 .iter()
2218 .map(|c| KycProfileData {
2219 profile_id: c.customer_id.to_string(),
2220 has_name: true,
2221 has_dob: c.date_of_birth.is_some(),
2222 has_address: c.address_line1.is_some(),
2223 has_id_document: c.national_id.is_some() || c.passport_number.is_some(),
2224 has_risk_rating: true,
2225 has_beneficial_owner: !c.beneficial_owners.is_empty(),
2226 is_entity: c.customer_type == BankingCustomerType::Business,
2227 is_verified: c.kyc_truthful,
2228 })
2229 .collect();
2230
2231 let mut banking_eval = BankingEvaluation::new();
2232 if let Ok(kyc_res) = KycCompletenessAnalyzer::new().analyze(&kyc_data) {
2233 banking_eval.kyc = Some(kyc_res);
2234 }
2235
2236 let suspicious: Vec<&_> = result
2237 .banking
2238 .transactions
2239 .iter()
2240 .filter(|t| t.is_suspicious)
2241 .collect();
2242 if !suspicious.is_empty() {
2243 let aml_data: Vec<AmlTransactionData> = suspicious
2250 .iter()
2251 .map(|t| AmlTransactionData {
2252 transaction_id: t.transaction_id.to_string(),
2253 typology: t
2254 .suspicion_reason
2255 .as_ref()
2256 .map(|r| r.canonical_name().to_string())
2257 .unwrap_or_default(),
2258 case_id: t.case_id.clone().unwrap_or_default(),
2259 amount: t.amount.try_into().unwrap_or(0.0),
2260 is_flagged: t.is_suspicious,
2261 })
2262 .collect();
2263
2264 let mut typology_map: HashMap<String, (usize, HashMap<String, bool>)> =
2265 HashMap::new();
2266 for txn in &aml_data {
2267 if !txn.typology.is_empty() {
2268 let entry = typology_map
2269 .entry(txn.typology.clone())
2270 .or_insert_with(|| (0, HashMap::new()));
2271 entry.0 += 1;
2272 entry.1.insert(txn.case_id.clone(), true);
2273 }
2274 }
2275 let typology_data: Vec<TypologyData> = typology_map
2276 .iter()
2277 .map(|(name, (count, cases))| TypologyData {
2278 name: name.clone(),
2279 scenario_count: *count,
2280 case_ids_consistent: cases.len() <= *count,
2281 })
2282 .collect();
2283
2284 if let Ok(aml_res) =
2285 AmlDetectabilityAnalyzer::new().analyze(&aml_data, &typology_data)
2286 {
2287 banking_eval.aml = Some(aml_res);
2288 }
2289 }
2290 banking_eval.check_thresholds();
2291
2292 match serde_json::to_string_pretty(&banking_eval) {
2293 Ok(json) => {
2294 if let Err(e) =
2295 std::fs::write(analytics_dir.join("banking_evaluation.json"), json)
2296 {
2297 warn!("Failed to write banking evaluation: {}", e);
2298 } else {
2299 info!(
2300 " Banking evaluation written ({} profiles, {} issues, passes={})",
2301 result.banking.customers.len(),
2302 banking_eval.issues.len(),
2303 banking_eval.passes
2304 );
2305 }
2306 }
2307 Err(e) => warn!("Failed to serialize banking evaluation: {}", e),
2308 }
2309 }
2310 }
2311
2312 if !result.quality_issues.is_empty() {
2316 let labels_dir = output_dir.join("labels");
2317 std::fs::create_dir_all(&labels_dir)?;
2318 info!("Writing data quality issue records...");
2319 write_json_safe(
2320 &result.quality_issues,
2321 &labels_dir.join("quality_issues.json"),
2322 "Data quality issues",
2323 );
2324
2325 use datasynth_generators::{
2328 LabeledIssueType, QualityIssueLabel, QualityIssueType, QualityLabels,
2329 };
2330 let mut quality_labels = QualityLabels::with_capacity(result.quality_issues.len());
2331 for issue in &result.quality_issues {
2332 let labeled_type = match issue.issue_type {
2333 QualityIssueType::MissingValue => LabeledIssueType::MissingValue,
2334 QualityIssueType::Typo => LabeledIssueType::Typo,
2335 QualityIssueType::DateFormatVariation
2336 | QualityIssueType::AmountFormatVariation
2337 | QualityIssueType::IdentifierFormatVariation
2338 | QualityIssueType::TextFormatVariation => LabeledIssueType::FormatVariation,
2339 QualityIssueType::ExactDuplicate
2340 | QualityIssueType::NearDuplicate
2341 | QualityIssueType::FuzzyDuplicate => LabeledIssueType::Duplicate,
2342 QualityIssueType::EncodingIssue => LabeledIssueType::EncodingIssue,
2343 };
2344 let mut label = QualityIssueLabel::new(
2345 labeled_type,
2346 issue.record_id.clone(),
2347 issue.field.clone().unwrap_or_else(|| "_record".to_string()),
2348 "data_quality_injector",
2349 );
2350 if let Some(ref orig) = issue.original_value {
2351 label = label.with_original(orig.clone());
2352 }
2353 if let Some(ref modified) = issue.modified_value {
2354 label = label.with_modified(modified.clone());
2355 }
2356 quality_labels.add(label);
2357 }
2358 if let Ok(json) = serde_json::to_string_pretty(&quality_labels) {
2359 if let Err(e) = std::fs::write(labels_dir.join("quality_labels.json"), json.as_bytes())
2360 {
2361 warn!("Failed to write quality labels: {}", e);
2362 } else {
2363 info!(
2364 " Quality labels written: {} labels -> labels/quality_labels.json",
2365 quality_labels.len()
2366 );
2367 }
2368 }
2369 }
2370
2371 if !result.internal_controls.is_empty() || !result.sod_violations.is_empty() {
2375 let ctrl_dir = output_dir.join("internal_controls");
2376 std::fs::create_dir_all(&ctrl_dir)?;
2377 info!("Writing internal controls data...");
2378
2379 write_json_safe(
2380 &result.internal_controls,
2381 &ctrl_dir.join("internal_controls.json"),
2382 "Internal controls",
2383 );
2384 write_json_safe(
2386 &result.sod_violations,
2387 &ctrl_dir.join("sod_violations.json"),
2388 "SoD violations",
2389 );
2390
2391 let exporter = datasynth_output::ControlExporter::new(&ctrl_dir);
2395 match exporter.export_standard() {
2396 Ok(summary) => {
2397 info!(
2398 " Control master data written: {} controls, {} SoD conflicts, {} SoD rules, {} COSO mappings, {} account mappings",
2399 summary.controls_count,
2400 summary.sod_conflicts_count,
2401 summary.sod_rules_count,
2402 summary.coso_mappings_count,
2403 summary.account_mappings_count,
2404 );
2405 }
2406 Err(e) => warn!("Failed to write control master data: {}", e),
2407 }
2408 }
2409
2410 if !result.accounting_standards.contracts.is_empty()
2414 || !result.accounting_standards.impairment_tests.is_empty()
2415 || !result.accounting_standards.business_combinations.is_empty()
2416 || !result.accounting_standards.ecl_models.is_empty()
2417 || !result.accounting_standards.provisions.is_empty()
2418 || !result
2419 .accounting_standards
2420 .currency_translation_results
2421 .is_empty()
2422 {
2423 let acct_dir = output_dir.join("accounting_standards");
2424 std::fs::create_dir_all(&acct_dir)?;
2425 info!("Writing accounting standards data...");
2426
2427 write_json_safe(
2428 &result.accounting_standards.contracts,
2429 &acct_dir.join("customer_contracts.json"),
2430 "Customer contracts",
2431 );
2432 write_json_safe(
2433 &result.accounting_standards.impairment_tests,
2434 &acct_dir.join("impairment_tests.json"),
2435 "Impairment tests",
2436 );
2437 write_json_safe(
2438 &result.accounting_standards.business_combinations,
2439 &acct_dir.join("business_combinations.json"),
2440 "Business combinations",
2441 );
2442 write_json_safe(
2443 &result
2444 .accounting_standards
2445 .business_combination_journal_entries,
2446 &acct_dir.join("business_combination_journal_entries.json"),
2447 "Business combination journal entries",
2448 );
2449 write_json_safe(
2450 &result.accounting_standards.ecl_models,
2451 &acct_dir.join("ecl_models.json"),
2452 "ECL models",
2453 );
2454 write_json_safe(
2455 &result.accounting_standards.ecl_provision_movements,
2456 &acct_dir.join("ecl_provision_movements.json"),
2457 "ECL provision movements",
2458 );
2459 write_json_safe(
2460 &result.accounting_standards.ecl_journal_entries,
2461 &acct_dir.join("ecl_journal_entries.json"),
2462 "ECL journal entries",
2463 );
2464 write_json_safe(
2465 &result.accounting_standards.provisions,
2466 &acct_dir.join("provisions.json"),
2467 "Provisions (IAS 37 / ASC 450)",
2468 );
2469 write_json_safe(
2470 &result.accounting_standards.provision_movements,
2471 &acct_dir.join("provision_movements.json"),
2472 "Provision movements",
2473 );
2474 write_json_safe(
2475 &result.accounting_standards.contingent_liabilities,
2476 &acct_dir.join("contingent_liabilities.json"),
2477 "Contingent liabilities",
2478 );
2479 write_json_safe(
2480 &result.accounting_standards.provision_journal_entries,
2481 &acct_dir.join("provision_journal_entries.json"),
2482 "Provision journal entries",
2483 );
2484
2485 if !result
2487 .accounting_standards
2488 .currency_translation_results
2489 .is_empty()
2490 {
2491 let fx_dir = acct_dir.join("fx");
2492 std::fs::create_dir_all(&fx_dir)?;
2493 write_json_safe(
2494 &result.accounting_standards.currency_translation_results,
2495 &fx_dir.join("currency_translation_results.json"),
2496 "IAS 21 currency translation results",
2497 );
2498 }
2499
2500 if !result.accounting_standards.leases.is_empty() {
2502 let leases_dir = acct_dir.join("leases");
2503 std::fs::create_dir_all(&leases_dir)?;
2504 write_json_safe(
2505 &result.accounting_standards.leases,
2506 &leases_dir.join("leases.json"),
2507 "Leases (IFRS 16 / ASC 842) — v3.3.1",
2508 );
2509 }
2510
2511 if !result
2513 .accounting_standards
2514 .fair_value_measurements
2515 .is_empty()
2516 {
2517 let fv_dir = acct_dir.join("fair_value");
2518 std::fs::create_dir_all(&fv_dir)?;
2519 write_json_safe(
2520 &result.accounting_standards.fair_value_measurements,
2521 &fv_dir.join("fair_value_measurements.json"),
2522 "Fair value measurements (IFRS 13 / ASC 820) — v3.3.1",
2523 );
2524 }
2525
2526 if !result.accounting_standards.framework_differences.is_empty() {
2528 let diff_dir = acct_dir.join("framework_differences");
2529 std::fs::create_dir_all(&diff_dir)?;
2530 write_json_safe(
2531 &result.accounting_standards.framework_differences,
2532 &diff_dir.join("framework_differences.json"),
2533 "Framework differences (US GAAP vs IFRS) — v3.3.1",
2534 );
2535 write_json_safe(
2536 &result.accounting_standards.framework_reconciliations,
2537 &diff_dir.join("framework_reconciliations.json"),
2538 "Per-entity framework reconciliation — v3.3.1",
2539 );
2540 }
2541 }
2542
2543 if let Some(ref gate_result) = result.gate_result {
2547 match serde_json::to_string_pretty(gate_result) {
2548 Ok(json) => {
2549 if let Err(e) = std::fs::write(output_dir.join("quality_gate_result.json"), json) {
2550 warn!("Failed to write quality gate result: {}", e);
2551 } else {
2552 info!(
2553 " Quality gate result written (passed={})",
2554 gate_result.passed
2555 );
2556 }
2557 }
2558 Err(e) => warn!("Failed to serialize quality gate result: {}", e),
2559 }
2560 }
2561
2562 if !result.treasury.debt_instruments.is_empty()
2566 || !result.treasury.cash_positions.is_empty()
2567 || !result.treasury.hedging_instruments.is_empty()
2568 {
2569 let treasury_dir = output_dir.join("treasury");
2570 std::fs::create_dir_all(&treasury_dir)?;
2571 info!("Writing treasury data...");
2572
2573 write_json_safe(
2574 &result.treasury.debt_instruments,
2575 &treasury_dir.join("debt_instruments.json"),
2576 "Debt instruments",
2577 );
2578 write_json_safe(
2579 &result.treasury.hedging_instruments,
2580 &treasury_dir.join("hedging_instruments.json"),
2581 "Hedging instruments",
2582 );
2583 write_json_safe(
2584 &result.treasury.hedge_relationships,
2585 &treasury_dir.join("hedge_relationships.json"),
2586 "Hedge relationships",
2587 );
2588 write_json_safe(
2589 &result.treasury.cash_positions,
2590 &treasury_dir.join("cash_positions.json"),
2591 "Cash positions",
2592 );
2593 write_json_safe(
2594 &result.treasury.cash_forecasts,
2595 &treasury_dir.join("cash_forecasts.json"),
2596 "Cash forecasts",
2597 );
2598 write_json_safe(
2599 &result.treasury.cash_pools,
2600 &treasury_dir.join("cash_pools.json"),
2601 "Cash pools",
2602 );
2603 write_json_safe(
2604 &result.treasury.cash_pool_sweeps,
2605 &treasury_dir.join("cash_pool_sweeps.json"),
2606 "Cash pool sweeps",
2607 );
2608 write_json_safe(
2609 &result.treasury.bank_guarantees,
2610 &treasury_dir.join("bank_guarantees.json"),
2611 "Bank guarantees",
2612 );
2613 write_json_safe(
2614 &result.treasury.netting_runs,
2615 &treasury_dir.join("netting_runs.json"),
2616 "Netting runs",
2617 );
2618 if !result.treasury.treasury_anomaly_labels.is_empty() {
2619 write_json_safe(
2620 &result.treasury.treasury_anomaly_labels,
2621 &treasury_dir.join("treasury_anomaly_labels.json"),
2622 "Treasury anomaly labels",
2623 );
2624 }
2625 }
2626
2627 if !result.project_accounting.projects.is_empty() {
2631 let pa_dir = output_dir.join("project_accounting");
2632 std::fs::create_dir_all(&pa_dir)?;
2633 info!("Writing project accounting data...");
2634
2635 write_json_safe(
2636 &result.project_accounting.projects,
2637 &pa_dir.join("projects.json"),
2638 "Projects",
2639 );
2640 write_json_safe(
2641 &result.project_accounting.cost_lines,
2642 &pa_dir.join("cost_lines.json"),
2643 "Project cost lines",
2644 );
2645 write_json_safe(
2646 &result.project_accounting.revenue_records,
2647 &pa_dir.join("revenue_records.json"),
2648 "Project revenue records",
2649 );
2650 write_json_safe(
2651 &result.project_accounting.earned_value_metrics,
2652 &pa_dir.join("earned_value_metrics.json"),
2653 "Earned value metrics",
2654 );
2655 write_json_safe(
2656 &result.project_accounting.change_orders,
2657 &pa_dir.join("change_orders.json"),
2658 "Change orders",
2659 );
2660 write_json_safe(
2661 &result.project_accounting.milestones,
2662 &pa_dir.join("milestones.json"),
2663 "Project milestones",
2664 );
2665 }
2666
2667 if !result.process_evolution.is_empty()
2671 || !result.organizational_events.is_empty()
2672 || !result.disruption_events.is_empty()
2673 {
2674 let events_dir = output_dir.join("events");
2675 std::fs::create_dir_all(&events_dir)?;
2676 info!("Writing evolution events...");
2677
2678 write_json_safe(
2679 &result.process_evolution,
2680 &events_dir.join("process_evolution_events.json"),
2681 "Process evolution events",
2682 );
2683 write_json_safe(
2684 &result.organizational_events,
2685 &events_dir.join("organizational_events.json"),
2686 "Organizational events",
2687 );
2688 write_json_safe(
2689 &result.disruption_events,
2690 &events_dir.join("disruption_events.json"),
2691 "Disruption events",
2692 );
2693 }
2694
2695 if !result.counterfactual_pairs.is_empty() {
2699 let ml_dir = output_dir.join("ml_training");
2700 std::fs::create_dir_all(&ml_dir)?;
2701 info!("Writing ML training data...");
2702
2703 write_json_safe(
2704 &result.counterfactual_pairs,
2705 &ml_dir.join("counterfactual_pairs.json"),
2706 "Counterfactual pairs",
2707 );
2708 }
2709
2710 if !result.red_flags.is_empty() {
2714 let labels_dir = output_dir.join("labels");
2715 std::fs::create_dir_all(&labels_dir)?;
2716 info!("Writing fraud red-flag indicators...");
2717
2718 write_json_safe(
2719 &result.red_flags,
2720 &labels_dir.join("fraud_red_flags.json"),
2721 "Fraud red flags",
2722 );
2723 }
2724
2725 if !result.collusion_rings.is_empty() {
2729 let labels_dir = output_dir.join("labels");
2730 std::fs::create_dir_all(&labels_dir)?;
2731 info!("Writing collusion rings...");
2732
2733 write_json_safe(
2734 &result.collusion_rings,
2735 &labels_dir.join("collusion_rings.json"),
2736 "Collusion rings",
2737 );
2738 }
2739
2740 if !result.temporal_vendor_chains.is_empty() {
2744 let temporal_dir = output_dir.join("temporal");
2745 std::fs::create_dir_all(&temporal_dir)?;
2746 info!("Writing temporal vendor version chains...");
2747
2748 write_json_safe(
2749 &result.temporal_vendor_chains,
2750 &temporal_dir.join("vendor_version_chains.json"),
2751 "Vendor version chains",
2752 );
2753 }
2754
2755 if result.entity_relationship_graph.is_some() || !result.cross_process_links.is_empty() {
2759 let rel_dir = output_dir.join("relationships");
2760 std::fs::create_dir_all(&rel_dir)?;
2761 info!("Writing entity relationship data...");
2762
2763 if let Some(ref graph) = result.entity_relationship_graph {
2764 match serde_json::to_string_pretty(graph) {
2765 Ok(json) => {
2766 let path = rel_dir.join("entity_relationship_graph.json");
2767 if let Err(e) = std::fs::write(&path, json) {
2768 warn!("Failed to write entity relationship graph: {}", e);
2769 } else {
2770 info!(
2771 " Entity relationship graph written: {} nodes, {} edges -> {}",
2772 graph.nodes.len(),
2773 graph.edges.len(),
2774 path.display()
2775 );
2776 }
2777 }
2778 Err(e) => warn!("Failed to serialize entity relationship graph: {}", e),
2779 }
2780 }
2781
2782 write_json_safe(
2783 &result.cross_process_links,
2784 &rel_dir.join("cross_process_links.json"),
2785 "Cross-process links",
2786 );
2787 }
2788
2789 if let Some(ref industry_output) = result.industry_output {
2793 if !industry_output.gl_accounts.is_empty() {
2794 let industry_dir = output_dir.join("industry");
2795 std::fs::create_dir_all(&industry_dir).ok();
2796 info!("Writing industry-specific data...");
2797 match serde_json::to_string_pretty(industry_output) {
2798 Ok(json) => {
2799 if let Err(e) = std::fs::write(industry_dir.join("industry_data.json"), json) {
2800 warn!("Failed to write industry data: {}", e);
2801 } else {
2802 info!(
2803 " Industry data written: {} GL accounts for {}",
2804 industry_output.gl_accounts.len(),
2805 industry_output.industry
2806 );
2807 }
2808 }
2809 Err(e) => warn!("Failed to serialize industry data: {}", e),
2810 }
2811 }
2812 }
2813
2814 if result.graph_export.exported {
2818 let graph_dir = output_dir.join("graph_export");
2819 std::fs::create_dir_all(&graph_dir).ok();
2820 match serde_json::to_string_pretty(&result.graph_export) {
2821 Ok(json) => {
2822 if let Err(e) = std::fs::write(graph_dir.join("graph_export_summary.json"), json) {
2823 warn!("Failed to write graph export summary: {}", e);
2824 } else {
2825 info!(" Graph export summary written");
2826 }
2827 }
2828 Err(e) => warn!("Failed to serialize graph export summary: {}", e),
2829 }
2830 }
2831
2832 let cr = &result.compliance_regulations;
2836 let has_compliance_data = !cr.standard_records.is_empty()
2837 || !cr.audit_procedures.is_empty()
2838 || !cr.findings.is_empty()
2839 || !cr.filings.is_empty();
2840 if has_compliance_data {
2841 let cr_dir = output_dir.join("compliance_regulations");
2842 std::fs::create_dir_all(&cr_dir)?;
2843 info!("Writing compliance regulations data...");
2844
2845 write_json_safe(
2846 &cr.standard_records,
2847 &cr_dir.join("compliance_standards.json"),
2848 "Compliance standards",
2849 );
2850 write_json_safe(
2851 &cr.cross_reference_records,
2852 &cr_dir.join("cross_references.json"),
2853 "Cross-references",
2854 );
2855 write_json_safe(
2856 &cr.jurisdiction_records,
2857 &cr_dir.join("jurisdiction_profiles.json"),
2858 "Jurisdiction profiles",
2859 );
2860 write_json_safe(
2861 &cr.audit_procedures,
2862 &cr_dir.join("audit_procedures.json"),
2863 "Audit procedures",
2864 );
2865 write_json_safe(
2866 &cr.findings,
2867 &cr_dir.join("compliance_findings.json"),
2868 "Compliance findings",
2869 );
2870 write_json_safe(
2871 &cr.filings,
2872 &cr_dir.join("regulatory_filings.json"),
2873 "Regulatory filings",
2874 );
2875
2876 if let Some(ref graph) = cr.compliance_graph {
2877 match serde_json::to_string_pretty(graph) {
2878 Ok(json) => {
2879 if let Err(e) = std::fs::write(cr_dir.join("compliance_graph.json"), json) {
2880 warn!("Failed to write compliance graph: {}", e);
2881 } else {
2882 info!(
2883 " Compliance graph written: {} nodes, {} edges",
2884 graph.nodes.len(),
2885 graph.edges.len()
2886 );
2887 }
2888 }
2889 Err(e) => warn!("Failed to serialize compliance graph: {}", e),
2890 }
2891 }
2892 }
2893
2894 match serde_json::to_string_pretty(&result.statistics) {
2898 Ok(json) => {
2899 if let Err(e) = std::fs::write(output_dir.join("generation_statistics.json"), json) {
2900 warn!("Failed to write generation statistics: {}", e);
2901 } else {
2902 info!(" Generation statistics written");
2903 }
2904 }
2905 Err(e) => warn!("Failed to serialize generation statistics: {}", e),
2906 }
2907
2908 info!("Output writing complete.");
2909 Ok(())
2910}
2911
2912fn write_json_safe<T: serde::Serialize>(data: &[T], path: &Path, label: &str) {
2920 if SKIP_JSON.with(|c| c.get()) {
2922 return;
2923 }
2924 if FLAT_LAYOUT_ACTIVE.with(|c| c.get()) {
2925 write_json_flat(data, path, label);
2926 } else if let Err(e) = write_json(data, path, label) {
2927 warn!("Failed to write {}: {}", label, e);
2928 }
2929}
2930
2931fn write_json_auto<T: serde::Serialize>(data: &[T], path: &Path, label: &str, flat: bool) {
2933 if flat {
2934 write_json_flat(data, path, label);
2935 } else {
2936 write_json_safe(data, path, label);
2937 }
2938}
2939
2940fn write_json_always<T: serde::Serialize>(data: &[T], path: &Path, label: &str) {
2948 if SKIP_JSON.with(|c| c.get()) {
2949 return;
2950 }
2951 match std::fs::File::create(path) {
2952 Ok(file) => {
2953 let mut writer = std::io::BufWriter::with_capacity(64 * 1024, file);
2954 if let Err(e) = (|| -> Result<(), Box<dyn std::error::Error>> {
2955 writer.write_all(b"[\n")?;
2956 for (i, item) in data.iter().enumerate() {
2957 if i > 0 {
2958 writer.write_all(b",\n")?;
2959 }
2960 serde_json::to_writer_pretty(&mut writer, item)?;
2961 }
2962 if !data.is_empty() {
2963 writer.write_all(b"\n")?;
2964 }
2965 writer.write_all(b"]\n")?;
2966 writer.flush()?;
2967 Ok(())
2968 })() {
2969 warn!("Failed to write {}: {}", label, e);
2970 } else {
2971 info!(
2972 " {} written: {} records -> {}",
2973 label,
2974 data.len(),
2975 path.display()
2976 );
2977 }
2978 }
2979 Err(e) => {
2980 warn!("Failed to create {}: {}", path.display(), e);
2981 }
2982 }
2983}
2984
2985fn write_json_flat<T: serde::Serialize>(data: &[T], path: &Path, label: &str) {
3004 if data.is_empty() {
3005 return;
3006 }
3007
3008 let mut flat: Vec<serde_json::Value> = Vec::with_capacity(data.len());
3010
3011 for item in data {
3012 let val = match serde_json::to_value(item) {
3013 Ok(v) => v,
3014 Err(e) => {
3015 warn!("Failed to serialize record for flat export: {}", e);
3016 continue;
3017 }
3018 };
3019
3020 let serde_json::Value::Object(map) = val else {
3021 flat.push(val);
3022 continue;
3023 };
3024
3025 let items_key = ["items", "lines", "allocations", "line_items"]
3027 .iter()
3028 .find(|k| map.contains_key(**k))
3029 .copied();
3030
3031 let header_map = match map.get("header") {
3033 Some(serde_json::Value::Object(h)) => Some(h),
3034 _ => None,
3035 };
3036
3037 let Some(items_key) = items_key else {
3038 if let Some(header_map) = header_map {
3043 let mut merged = map.clone();
3044 merged.remove("header");
3045 for (k, v) in header_map {
3046 merged.entry(k.clone()).or_insert_with(|| v.clone());
3047 }
3048 flat.push(serde_json::Value::Object(merged));
3049 } else {
3050 flat.push(serde_json::Value::Object(map));
3051 }
3052 continue;
3053 };
3054
3055 let Some(serde_json::Value::Array(items)) = map.get(items_key) else {
3056 flat.push(serde_json::Value::Object(map));
3058 continue;
3059 };
3060
3061 if items.is_empty() {
3065 let mut merged = map.clone();
3066 merged.remove(items_key);
3067 if let Some(header_map) = header_map {
3068 merged.remove("header");
3069 for (k, v) in header_map {
3070 merged.entry(k.clone()).or_insert_with(|| v.clone());
3071 }
3072 }
3073 flat.push(serde_json::Value::Object(merged));
3074 continue;
3075 }
3076
3077 let top_fields: Vec<(&String, &serde_json::Value)> = map
3083 .iter()
3084 .filter(|(k, _)| k.as_str() != "header" && k.as_str() != items_key)
3085 .collect();
3086
3087 flat.reserve(items.len());
3088 for item_val in items {
3089 let mut merged = serde_json::Map::new();
3090 if let serde_json::Value::Object(m) = item_val {
3092 merged.extend(m.iter().map(|(k, v)| (k.clone(), v.clone())));
3093 }
3094 if let Some(header_map) = header_map {
3096 for (k, v) in header_map {
3097 merged.entry(k.clone()).or_insert_with(|| v.clone());
3098 }
3099 }
3100 for &(k, v) in &top_fields {
3102 merged.entry(k.clone()).or_insert_with(|| v.clone());
3103 }
3104 flat.push(serde_json::Value::Object(merged));
3105 }
3106 }
3107
3108 if flat.is_empty() {
3109 return;
3110 }
3111
3112 let count = flat.len();
3114 match std::fs::File::create(path) {
3115 Ok(file) => {
3116 use std::io::Write;
3117 let mut writer = std::io::BufWriter::with_capacity(512 * 1024, file);
3118 if let Err(e) = (|| -> Result<(), Box<dyn std::error::Error>> {
3119 writer.write_all(b"[\n")?;
3120 for (i, item) in flat.iter().enumerate() {
3121 if i > 0 {
3122 writer.write_all(b",\n")?;
3123 }
3124 serde_json::to_writer_pretty(&mut writer, item)?;
3125 }
3126 writer.write_all(b"\n]\n")?;
3127 writer.flush()?;
3128 Ok(())
3129 })() {
3130 warn!("Failed to write {}: {}", label, e);
3131 } else {
3132 info!(
3133 " {} written (flat): {} records -> {}",
3134 label,
3135 count,
3136 path.display()
3137 );
3138 }
3139 }
3140 Err(e) => warn!("Failed to create {}: {}", label, e),
3141 }
3142}
3143
3144fn write_json_single<T: serde::Serialize>(
3146 data: &T,
3147 path: &Path,
3148 label: &str,
3149) -> Result<(), Box<dyn std::error::Error>> {
3150 let file = std::fs::File::create(path)?;
3151 let writer = std::io::BufWriter::with_capacity(256 * 1024, file);
3152 serde_json::to_writer_pretty(writer, data)?;
3153 info!(" {} written -> {}", label, path.display());
3154 Ok(())
3155}
3156
3157fn write_json_single_safe<T: serde::Serialize>(data: &T, path: &Path, label: &str) {
3159 if SKIP_JSON.with(|c| c.get()) {
3160 return;
3161 }
3162 if let Err(e) = write_json_single(data, path, label) {
3163 warn!("Failed to write {}: {}", label, e);
3164 }
3165}
3166
3167#[derive(serde::Serialize)]
3170struct BalanceValidationSummary {
3171 validated: bool,
3172 is_balanced: bool,
3173 entries_processed: u64,
3174 total_debits: String,
3175 total_credits: String,
3176 accounts_tracked: usize,
3177 companies_tracked: usize,
3178 has_unbalanced_entries: bool,
3179 validation_error_count: usize,
3180}
3181
3182impl BalanceValidationSummary {
3183 fn from(v: &crate::enhanced_orchestrator::BalanceValidationResult) -> Self {
3184 Self {
3185 validated: v.validated,
3186 is_balanced: v.is_balanced,
3187 entries_processed: v.entries_processed,
3188 total_debits: v.total_debits.to_string(),
3189 total_credits: v.total_credits.to_string(),
3190 accounts_tracked: v.accounts_tracked,
3191 companies_tracked: v.companies_tracked,
3192 has_unbalanced_entries: v.has_unbalanced_entries,
3193 validation_error_count: v.validation_errors.len(),
3194 }
3195 }
3196}
3197
3198#[cfg(test)]
3199mod tests {
3200 #[test]
3205 fn journal_entries_csv_header_has_46_columns() {
3206 let header =
3207 "document_id,company_code,fiscal_year,fiscal_period,posting_date,document_date,\
3208 document_type,currency,exchange_rate,reference,header_text,created_by,source,\
3209 business_process,ledger,is_fraud,is_anomaly,\
3210 line_number,gl_account,debit_amount,credit_amount,local_amount,transaction_amount,\
3211 cost_center,profit_center,business_unit,line_text,\
3212 auxiliary_account_number,auxiliary_account_label,lettrage,lettrage_date,\
3213 is_manual,is_post_close,source_system,\
3214 account_description,financial_statement_category,\
3215 assignment,value_date,tax_code,transaction_id,\
3216 account_class,account_class_name,account_sub_class,account_sub_class_name,\
3217 predecessor_line_id,trading_partner,fraud_type,anomaly_type";
3218 let normalized: String = header.chars().filter(|c| !c.is_whitespace()).collect();
3220 let n_cols = normalized.split(',').count();
3221 assert_eq!(
3222 n_cols, 48,
3223 "expected 48 columns in journal_entries.csv header, got {n_cols}"
3224 );
3225 }
3226
3227 #[test]
3229 fn journal_entries_csv_fraud_type_column_populated() {
3230 use datasynth_core::models::FraudType;
3231 use datasynth_core::models::{JournalEntry, JournalEntryHeader};
3232
3233 let posting_date = chrono::NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
3235 let mut header = JournalEntryHeader::new("DE10".to_string(), posting_date);
3236 header.is_fraud = true;
3237 header.fraud_type = Some(FraudType::GhostEmployee);
3238 let je = JournalEntry::new(header);
3239
3240 let h = &je.header;
3244 let fraud_type_str = h.fraud_type.map(|ft| format!("{ft:?}")).unwrap_or_default();
3245 let anomaly_type_str = h.anomaly_type.as_deref().unwrap_or("").to_string();
3246
3247 assert_eq!(
3249 fraud_type_str, "GhostEmployee",
3250 "expected 'GhostEmployee' for FraudType::GhostEmployee; got: {fraud_type_str}"
3251 );
3252 assert!(
3254 anomaly_type_str.is_empty(),
3255 "expected empty anomaly_type when None; got: {anomaly_type_str}"
3256 );
3257 }
3258
3259 #[test]
3261 fn journal_entries_csv_fraud_type_none_is_empty() {
3262 use datasynth_core::models::{JournalEntry, JournalEntryHeader};
3263
3264 let posting_date = chrono::NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
3265 let header = JournalEntryHeader::new("DE10".to_string(), posting_date);
3266 let je = JournalEntry::new(header);
3267
3268 let h = &je.header;
3269 let fraud_type_str = h.fraud_type.map(|ft| format!("{ft:?}")).unwrap_or_default();
3271 let anomaly_type_str = h.anomaly_type.as_deref().unwrap_or("").to_string();
3273
3274 assert!(
3275 fraud_type_str.is_empty(),
3276 "expected empty fraud_type for None; got: {fraud_type_str}"
3277 );
3278 assert!(
3279 anomaly_type_str.is_empty(),
3280 "expected empty anomaly_type for None; got: {anomaly_type_str}"
3281 );
3282 }
3283}