use crate::config::{ConsolidationMethod, GeneratedEntityBlock, OwnershipConfig};
use crate::errors::{GroupError, GroupResult};
use crate::manifest::seeds::derive_manifest_seed;
use chrono::NaiveDate;
use rand::RngExt;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rust_decimal::Decimal;
#[derive(Debug, Clone, PartialEq)]
pub struct ExpandedEntity {
pub code: String,
pub name: Option<String>,
pub country: String,
pub functional_currency: String,
pub scoping_profile: String,
pub consolidation_method: ConsolidationMethod,
pub ownership_percent: Option<Decimal>,
pub parent_code: Option<String>,
pub accounting_framework: Option<String>,
pub industry: Option<String>,
pub hyperinflation_status: datasynth_core::models::HyperinflationStatus,
pub ownership_changes: Vec<datasynth_core::models::intercompany::OwnershipChangeEvent>,
pub source: EntitySource,
pub generated_block_index: Option<usize>,
pub rows: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntitySource {
Explicit,
Generated,
}
pub fn expand_ownership(
ownership: &OwnershipConfig,
group_seed: u64,
period_start: NaiveDate,
) -> GroupResult<Vec<ExpandedEntity>> {
let mut out = Vec::new();
let mut seen_codes = std::collections::BTreeSet::new();
for e in &ownership.entities {
if !seen_codes.insert(e.code.clone()) {
return Err(GroupError::Config(format!(
"entity code {} appears twice in ownership.entities",
e.code
)));
}
let ownership_changes = if e.ownership_changes.is_empty() {
Vec::new()
} else {
let parent_code = e.parent_code.clone().ok_or_else(|| {
GroupError::Config(format!(
"entity {} declares ownership_changes but has no parent_code — \
every ownership-change event implies a controlling parent",
e.code
))
})?;
e.ownership_changes
.iter()
.map(
|entry| datasynth_core::models::intercompany::OwnershipChangeEvent {
entity_code: e.code.clone(),
parent_entity_code: parent_code.clone(),
event_type: entry.event_type,
effective_date: entry.effective_date,
ownership_percent_before: entry.ownership_percent_before,
ownership_percent_after: entry.ownership_percent_after,
previously_held_interest_carrying: entry.previously_held_interest_carrying,
previously_held_interest_fair_value: entry
.previously_held_interest_fair_value,
consideration_paid_or_received: entry.consideration_paid_or_received,
acquisition_date_nci_fair_value: entry.acquisition_date_nci_fair_value,
nci_measurement_method: entry.nci_measurement_method,
currency: entry.currency.clone(),
},
)
.collect()
};
out.push(ExpandedEntity {
code: e.code.clone(),
name: e.name.clone(),
country: e.country.clone(),
functional_currency: e.functional_currency.clone(),
scoping_profile: e.scoping_profile.clone(),
consolidation_method: e.consolidation_method,
ownership_percent: e.ownership_percent,
parent_code: e.parent_code.clone(),
accounting_framework: e.accounting_framework.clone(),
industry: e.industry.clone(),
hyperinflation_status: e.hyperinflation_status,
ownership_changes,
source: EntitySource::Explicit,
generated_block_index: None,
rows: e.rows,
});
}
for (block_idx, block) in ownership.generated.iter().enumerate() {
expand_block(
block,
block_idx,
group_seed,
period_start,
&mut seen_codes,
&mut out,
)?;
}
Ok(out)
}
fn expand_block(
block: &GeneratedEntityBlock,
block_idx: usize,
group_seed: u64,
period_start: NaiveDate,
seen: &mut std::collections::BTreeSet<String>,
out: &mut Vec<ExpandedEntity>,
) -> GroupResult<()> {
let manifest_seed = derive_manifest_seed(group_seed, period_start);
let mut block_seed = manifest_seed;
let idx_bytes = (block_idx as u64).to_le_bytes();
for i in 0..8 {
block_seed[i] ^= idx_bytes[i];
}
let mut rng = ChaCha8Rng::from_seed(block_seed);
for i in 0..block.count {
let code = format!("{}{:07}", block.code_prefix, i + 1);
if !seen.insert(code.clone()) {
return Err(GroupError::Config(format!(
"generated entity code {code} collides with an existing explicit or generated code",
)));
}
let country = if block.country.is_empty() {
return Err(GroupError::Config(format!(
"generated block {block_idx} has empty country list — at least one required",
)));
} else {
let idx = rng.random_range(0..block.country.len());
block.country[idx].clone()
};
let functional_currency = block
.functional_currency
.clone()
.unwrap_or_else(|| country_to_currency_default(&country));
let ownership_percent = block.ownership_percent_range.map(|[lo, hi]| {
use rust_decimal::prelude::FromPrimitive;
let frac: f64 = rng.random();
let lo_f = lo.to_string().parse::<f64>().unwrap_or(0.0);
let hi_f = hi.to_string().parse::<f64>().unwrap_or(1.0);
let sampled = lo_f + frac * (hi_f - lo_f);
let rounded = (sampled * 10_000.0).round() / 10_000.0;
Decimal::from_f64(rounded).unwrap_or(lo)
});
out.push(ExpandedEntity {
code,
name: None,
country,
functional_currency,
scoping_profile: block.scoping_profile.clone(),
consolidation_method: block.consolidation_method,
ownership_percent,
parent_code: block.parent_code.clone(),
accounting_framework: block.accounting_framework.clone(),
industry: block.industry.clone(),
hyperinflation_status:
datasynth_core::models::HyperinflationStatus::NotHyperinflationary,
ownership_changes: Vec::new(),
source: EntitySource::Generated,
generated_block_index: Some(block_idx),
rows: None, });
}
Ok(())
}
fn country_to_currency_default(country: &str) -> String {
match country {
"CH" => "CHF",
"US" => "USD",
"CA" => "CAD",
"MX" => "MXN",
"BR" => "BRL",
"AR" => "ARS",
"GB" => "GBP",
"DE" | "FR" | "IT" | "ES" | "NL" | "BE" | "AT" | "IE" | "PT" | "FI" | "LU" => "EUR",
"PL" => "PLN",
"SE" => "SEK",
"NO" => "NOK",
"DK" => "DKK",
"CN" => "CNY",
"JP" => "JPY",
"KR" => "KRW",
"IN" => "INR",
"ID" => "IDR",
"SG" => "SGD",
"TH" => "THB",
"VN" => "VND",
"AU" => "AUD",
"NZ" => "NZD",
"ZA" => "ZAR",
"AE" => "AED",
"SA" => "SAR",
"TR" => "TRY",
"RU" => "RUB",
_ => country, }
.to_string()
}