use std::fs;
use std::path::{Path, PathBuf};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use crate::aggregate::driver::{run_aggregate, AggregateOptions, AggregateSummary};
use crate::config::GroupConfig;
use crate::errors::{GroupError, GroupResult};
use crate::manifest::builder::build_manifest;
use crate::shard::runner::{run_shard_with_opening_balances, ShardSummary};
#[derive(Debug, Clone)]
pub struct StandaloneOptions {
pub prior_period_aggregate: Option<PathBuf>,
pub tolerate_missing_shards: bool,
pub parallel_shards: bool,
pub cgu_test_inputs: Vec<crate::aggregate::cgu_impairment::CguTestInputs>,
pub cpi_series_by_currency: std::collections::BTreeMap<
String,
datasynth_core::models::hyperinflation::GeneralPriceIndex,
>,
pub entity_opening_balances: std::collections::BTreeMap<
String,
Vec<datasynth_core::models::balance::EntityOpeningBalance>,
>,
pub closing_to_opening_framework: String,
}
impl Default for StandaloneOptions {
fn default() -> Self {
Self {
prior_period_aggregate: None,
tolerate_missing_shards: false,
parallel_shards: true,
cgu_test_inputs: Vec::new(),
entity_opening_balances: std::collections::BTreeMap::new(),
cpi_series_by_currency: std::collections::BTreeMap::new(),
closing_to_opening_framework: "us_gaap".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StandaloneSummary {
pub manifest_path: PathBuf,
pub shard_summaries: Vec<ShardSummary>,
pub aggregate: AggregateSummary,
}
pub fn generate_standalone(
cfg: &GroupConfig,
out_dir: &Path,
opts: &StandaloneOptions,
) -> GroupResult<StandaloneSummary> {
let manifest = build_manifest(cfg)?;
fs::create_dir_all(out_dir).map_err(GroupError::Io)?;
let manifest_path = out_dir.join("manifest.json");
let mut manifest_json = serde_json::to_string_pretty(&manifest)?;
manifest_json.push('\n');
fs::write(&manifest_path, manifest_json).map_err(GroupError::Io)?;
let shard_ids: Vec<String> = manifest
.shard_plan
.shards
.iter()
.map(|s| s.shard_id.clone())
.collect();
let shard_summaries: Vec<ShardSummary> = if opts.parallel_shards {
shard_ids
.par_iter()
.map(|sid| {
run_shard_with_opening_balances(
&manifest,
sid,
out_dir,
&opts.entity_opening_balances,
)
})
.collect::<GroupResult<Vec<_>>>()?
} else {
let mut out: Vec<ShardSummary> = Vec::with_capacity(shard_ids.len());
for sid in &shard_ids {
out.push(run_shard_with_opening_balances(
&manifest,
sid,
out_dir,
&opts.entity_opening_balances,
)?);
}
out
};
let agg_opts = AggregateOptions {
prior_period_aggregate: opts.prior_period_aggregate.clone(),
tolerate_missing_shards: opts.tolerate_missing_shards,
cgu_test_inputs: opts.cgu_test_inputs.clone(),
cpi_series_by_currency: opts.cpi_series_by_currency.clone(),
};
let aggregate = run_aggregate(&manifest, out_dir, out_dir, &agg_opts)?;
Ok(StandaloneSummary {
manifest_path,
shard_summaries,
aggregate,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeriodChainSpec {
pub period: crate::config::PeriodConfig,
pub out_subdir: String,
}
pub fn generate_standalone_chain(
base_cfg: &GroupConfig,
periods: Vec<PeriodChainSpec>,
base_out_dir: &Path,
opts: &StandaloneOptions,
) -> GroupResult<Vec<StandaloneSummary>> {
if periods.is_empty() {
return Err(GroupError::Config(
"generate_standalone_chain: periods must be non-empty".to_string(),
));
}
let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
for spec in &periods {
if !seen.insert(spec.out_subdir.as_str()) {
return Err(GroupError::Config(format!(
"generate_standalone_chain: duplicate out_subdir `{}` — every \
period must have a unique output directory",
spec.out_subdir,
)));
}
}
let mut summaries: Vec<StandaloneSummary> = Vec::with_capacity(periods.len());
let mut prior_out: Option<PathBuf> = None;
for (idx, spec) in periods.into_iter().enumerate() {
let mut cfg = base_cfg.clone();
cfg.period = spec.period;
let out_dir = base_out_dir.join(&spec.out_subdir);
let mut period_opts = opts.clone();
if idx > 0 {
period_opts.prior_period_aggregate = prior_out.clone();
if let Some(prior) = &prior_out {
let entity_codes: Vec<String> = base_cfg
.ownership
.entities
.iter()
.map(|e| e.code.clone())
.collect();
let closing_tbs = crate::aggregate::opening_balance::read_prior_period_closing_tbs(
prior,
&entity_codes,
)?;
let mut openings_by_entity: std::collections::BTreeMap<
String,
Vec<datasynth_core::models::balance::EntityOpeningBalance>,
> = std::collections::BTreeMap::new();
for (code, tb) in &closing_tbs {
let core_openings = datasynth_generators::balance::project_closing_to_opening(
tb,
&opts.closing_to_opening_framework,
);
if !core_openings.is_empty() {
openings_by_entity.insert(code.clone(), core_openings);
}
}
period_opts.entity_opening_balances = openings_by_entity;
}
}
let summary = generate_standalone(&cfg, &out_dir, &period_opts)?;
prior_out = Some(out_dir);
summaries.push(summary);
}
Ok(summaries)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use std::path::PathBuf;
#[test]
fn period_chain_spec_field_shape() {
let spec = PeriodChainSpec {
period: crate::config::PeriodConfig {
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
length: crate::config::PeriodLength::Quarterly,
fiscal_year_end: None,
},
out_subdir: "2024_Q1".to_string(),
};
assert_eq!(spec.out_subdir, "2024_Q1");
assert_eq!(spec.period.length, crate::config::PeriodLength::Quarterly);
}
#[test]
fn empty_periods_rejected() {
let cfg = sample_min_config();
let err = generate_standalone_chain(
&cfg,
Vec::new(),
&PathBuf::from("/tmp/never"),
&StandaloneOptions::default(),
)
.unwrap_err();
assert!(format!("{err}").contains("must be non-empty"));
}
#[test]
fn duplicate_out_subdir_rejected() {
let cfg = sample_min_config();
let p1 = PeriodChainSpec {
period: crate::config::PeriodConfig {
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
length: crate::config::PeriodLength::Quarterly,
fiscal_year_end: None,
},
out_subdir: "DUP".to_string(),
};
let p2 = PeriodChainSpec {
period: crate::config::PeriodConfig {
start_date: NaiveDate::from_ymd_opt(2024, 4, 1).unwrap(),
length: crate::config::PeriodLength::Quarterly,
fiscal_year_end: None,
},
out_subdir: "DUP".to_string(),
};
let err = generate_standalone_chain(
&cfg,
vec![p1, p2],
&PathBuf::from("/tmp/never"),
&StandaloneOptions::default(),
)
.unwrap_err();
assert!(format!("{err}").contains("duplicate out_subdir"));
}
#[test]
fn standalone_options_default_has_empty_entity_opening_balances() {
let opts = StandaloneOptions::default();
assert!(opts.entity_opening_balances.is_empty());
}
#[test]
fn standalone_options_can_carry_per_entity_openings() {
use datasynth_core::models::balance::{AccountType, EntityOpeningBalance};
use rust_decimal::Decimal;
let mut opts = StandaloneOptions::default();
opts.entity_opening_balances.insert(
"SUB".to_string(),
vec![EntityOpeningBalance {
account_code: "1000".to_string(),
account_type: AccountType::Asset,
debit: Decimal::from(50_000),
credit: Decimal::ZERO,
}],
);
assert_eq!(opts.entity_opening_balances.len(), 1);
assert_eq!(opts.entity_opening_balances["SUB"][0].account_code, "1000");
}
fn sample_min_config() -> GroupConfig {
GroupConfig {
id: "TEST".to_string(),
name: None,
presentation_currency: "CHF".to_string(),
period: crate::config::PeriodConfig {
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
length: crate::config::PeriodLength::Quarterly,
fiscal_year_end: None,
},
seed: 42,
defaults: serde_yaml::Value::Null,
scoping_profiles: Default::default(),
ownership: crate::config::OwnershipConfig {
parent_entity_code: "P".to_string(),
entities: Vec::new(),
generated: Vec::new(),
entities_from: None,
},
intercompany: Default::default(),
fx: crate::config::FxConfig {
base_currency: "CHF".to_string(),
rate_source: Default::default(),
rates: Default::default(),
policy: crate::config::FxPolicyConfig {
balance_sheet: crate::config::FxRateBasis::Closing,
income_statement: crate::config::FxRateBasis::Average,
equity: crate::config::FxRateBasis::Historical,
},
},
audit: Default::default(),
tax: Default::default(),
cgu: Default::default(),
output: Default::default(),
fleet: None,
}
}
}