use std::collections::BTreeMap;
use rust_decimal::prelude::FromPrimitive;
use rust_decimal::Decimal;
use crate::config::{AuditEngagementConfig, MaterialityBasis};
use crate::errors::{GroupError, GroupResult};
use crate::manifest::expansion::ExpandedEntity;
use serde::{Deserialize, Serialize};
const FALLBACK_ENTITY_ROWS: u64 = 1_000_000;
const REVENUE_PER_ROW_USD: u64 = 1_000;
const DEFAULT_FULL_SCOPE: f64 = 0.15;
const DEFAULT_SPECIFIC_SCOPE: f64 = 0.05;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ComponentScope {
Full,
Specific,
Analytical,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComponentMaterialityAllocation {
pub entity_code: String,
pub materiality: Decimal,
pub scope: ComponentScope,
pub revenue_share_estimate: Decimal,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComponentAuditor {
pub id: String,
pub firm: String,
pub jurisdiction: String,
pub entities: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditEngagementPlan {
pub engagement_id: String,
pub lead_auditor: String,
pub framework: String,
pub fsm_blueprint: String,
pub group_materiality: Decimal,
pub performance_materiality: Decimal,
pub clearly_trivial: Decimal,
pub component_materiality_allocations: Vec<ComponentMaterialityAllocation>,
pub component_auditors: Vec<ComponentAuditor>,
}
pub fn build_audit_engagement_plan(
cfg: &AuditEngagementConfig,
entities: &[ExpandedEntity],
group_id: &str,
_aggregate_seed: &[u8; 32],
) -> GroupResult<AuditEngagementPlan> {
let engagement_id = cfg
.engagement_id
.clone()
.unwrap_or_else(|| format!("{group_id}_ENGAGEMENT"));
let lead_auditor = cfg
.lead_auditor
.clone()
.unwrap_or_else(|| "UNDESIGNATED".to_string());
let framework = cfg.framework.clone().unwrap_or_else(|| "isa".to_string());
let fsm_blueprint = cfg
.fsm_blueprint
.clone()
.unwrap_or_else(|| "builtin:group_fsa".to_string());
let mat_cfg = cfg.group_materiality.as_ref().ok_or_else(|| {
GroupError::Config(
"audit.group_materiality is required for v5.0 but is not set".to_string(),
)
})?;
let entity_rows: Vec<u64> = entities
.iter()
.map(|e| e.rows.unwrap_or(FALLBACK_ENTITY_ROWS))
.collect();
let total_rows: u64 = entity_rows.iter().sum();
let total_rows_nonzero = total_rows.max(1);
let revenue_proxy_usd = Decimal::from(total_rows_nonzero) * Decimal::from(REVENUE_PER_ROW_USD);
let basis_value = match mat_cfg.basis {
MaterialityBasis::Revenue => revenue_proxy_usd,
MaterialityBasis::Assets => revenue_proxy_usd,
MaterialityBasis::PretaxIncome => revenue_proxy_usd / Decimal::from(10u32),
MaterialityBasis::Equity => {
revenue_proxy_usd
* Decimal::from_f64(0.30)
.ok_or_else(|| GroupError::Config("decimal conversion error".to_string()))?
}
};
let group_materiality = basis_value * mat_cfg.percent;
let performance_materiality = group_materiality
* Decimal::from_f64(0.75)
.ok_or_else(|| GroupError::Config("decimal conversion error".to_string()))?;
let clearly_trivial = group_materiality
* Decimal::from_f64(0.05)
.ok_or_else(|| GroupError::Config("decimal conversion error".to_string()))?;
let full_scope_threshold = cfg
.component_scope_thresholds
.as_ref()
.map(|t| t.full_scope)
.unwrap_or_else(|| Decimal::from_f64(DEFAULT_FULL_SCOPE).unwrap());
let specific_scope_threshold = cfg
.component_scope_thresholds
.as_ref()
.map(|t| t.specific_scope)
.unwrap_or_else(|| Decimal::from_f64(DEFAULT_SPECIFIC_SCOPE).unwrap());
let mut component_materiality_allocations: Vec<ComponentMaterialityAllocation> =
Vec::with_capacity(entities.len());
for (entity, &rows) in entities.iter().zip(entity_rows.iter()) {
let revenue_share_f64 = rows as f64 / total_rows_nonzero as f64;
let clamped = revenue_share_f64.clamp(0.0, 1.0);
let allocation_factor = 1.0 - (1.0 - clamped).sqrt();
let revenue_share_estimate = Decimal::from_f64(revenue_share_f64)
.ok_or_else(|| GroupError::Config("revenue_share conversion error".to_string()))?;
let allocation_decimal = Decimal::from_f64(allocation_factor)
.ok_or_else(|| GroupError::Config("allocation_factor conversion error".to_string()))?;
let materiality = group_materiality * allocation_decimal;
let scope = if revenue_share_estimate >= full_scope_threshold {
ComponentScope::Full
} else if revenue_share_estimate >= specific_scope_threshold {
ComponentScope::Specific
} else {
ComponentScope::Analytical
};
component_materiality_allocations.push(ComponentMaterialityAllocation {
entity_code: entity.code.clone(),
materiality,
scope,
revenue_share_estimate,
});
}
let lead_country: Option<String> = entities.first().map(|e| e.country.clone());
let mut country_entities: BTreeMap<String, Vec<String>> = BTreeMap::new();
for entity in entities {
country_entities
.entry(entity.country.clone())
.or_default()
.push(entity.code.clone());
}
let mut component_auditors: Vec<ComponentAuditor> = Vec::new();
for (country, mut codes) in country_entities {
codes.sort(); let is_lead = lead_country.as_deref() == Some(country.as_str());
let firm = if is_lead {
"UNDESIGNATED-LEAD".to_string()
} else {
"UNDESIGNATED".to_string()
};
let id = format!("CA_{firm}_{country}");
component_auditors.push(ComponentAuditor {
id,
firm,
jurisdiction: country,
entities: codes,
});
}
Ok(AuditEngagementPlan {
engagement_id,
lead_auditor,
framework,
fsm_blueprint,
group_materiality,
performance_materiality,
clearly_trivial,
component_materiality_allocations,
component_auditors,
})
}