use crate::domain::chart_dataset::{validate_dataset, ChartDataset, DatasetError};
use crate::domain::repositories::chart_install_repository::{
ChartAccountRow, ChartInstallRepository, UpsertOutcome,
};
use backbone_orm::org_scope;
use sqlx::PgPool;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use uuid::Uuid;
#[derive(Debug, Clone, serde::Serialize)]
pub struct ChartInfo {
pub code: String,
pub version: String,
pub name: String,
pub accounts: usize,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct InstallReport {
pub chart_code: String,
pub chart_version: String,
pub accounts_installed: usize,
pub accounts_updated: usize,
pub accounts_resurrected: usize,
pub account_ids: HashMap<String, Uuid>,
}
#[derive(Debug, thiserror::Error)]
pub enum ChartInstallError {
#[error("chart_has_postings: company {1} already has journal entries; refusing to install chart '{0}' onto posted books")]
ChartHasPostings(String, Uuid),
#[error("chart_account_number_conflict: existing accounts hold numbers/codes the chart '{0}' uses but are not rows this chart's current codes install onto (manual rows, or this chart's rows under earlier numbering) — (number, code): {1:?}")]
AccountNumberConflict(String, Vec<(String, String)>),
#[error("unknown chart '{0}' (registered: {1:?})")]
UnknownChart(String, Vec<String>),
#[error("invalid dataset '{0}': {1}")]
InvalidDataset(String, #[source] DatasetError),
#[error("chart install database error: {0}")]
Db(#[from] anyhow::Error),
#[error("chart install transaction error: {0}")]
Tx(#[from] sqlx::Error),
}
pub struct ChartInstallService {
repo: Arc<dyn ChartInstallRepository>,
pool: PgPool,
datasets: Vec<Arc<ChartDataset>>,
}
fn ambient_company() -> Uuid {
backbone_orm::org_scope::current_org_scope()
.and_then(|s| s.legacy_company_id())
.unwrap_or_default()
}
impl ChartInstallService {
pub fn new(
repo: Arc<dyn ChartInstallRepository>,
pool: PgPool,
datasets: Vec<Arc<ChartDataset>>,
) -> Self {
Self {
repo,
pool,
datasets,
}
}
pub fn list_charts(&self) -> Vec<ChartInfo> {
self.datasets
.iter()
.map(|ds| ChartInfo {
code: ds.code.clone(),
version: ds.version.clone(),
name: ds.name.clone(),
accounts: ds.accounts.len(),
})
.collect()
}
fn deterministic_id(company_id: Uuid, chart_code: &str, account_code: &str) -> Uuid {
Uuid::new_v5(
&Uuid::NAMESPACE_URL,
format!("account:{company_id}:{chart_code}:{account_code}").as_bytes(),
)
}
fn derive_rows(&self, company_id: Uuid, ds: &ChartDataset) -> Vec<ChartAccountRow> {
let parents: HashSet<&str> = ds
.accounts
.iter()
.filter_map(|a| a.parent_code.as_deref())
.collect();
let mut derived: HashMap<&str, (i32, String)> = HashMap::new();
let mut rows = Vec::with_capacity(ds.accounts.len());
for def in &ds.accounts {
let (level, path, parent_id) = match def.parent_code.as_deref() {
None => (0, def.number.clone(), None),
Some(p) => {
let (plvl, ppath) = derived.get(p).expect("validated dataset: parent known");
(
plvl + 1,
format!("{ppath}/{}", def.number),
Some(Self::deterministic_id(company_id, &ds.code, p)),
)
}
};
let is_header = parents.contains(def.code.as_str());
derived.insert(def.code.as_str(), (level, path.clone()));
rows.push(ChartAccountRow {
id: Self::deterministic_id(company_id, &ds.code, &def.code),
company_id,
def: def.clone(),
parent_id,
level,
path,
is_header,
is_detail: !is_header,
chart_code: ds.code.clone(),
chart_version: ds.version.clone(),
});
}
rows
}
pub async fn install(
&self,
chart_code: &str,
) -> Result<InstallReport, ChartInstallError> {
let company_id = ambient_company();
let ds = self
.datasets
.iter()
.find(|d| d.code == chart_code)
.ok_or_else(|| {
ChartInstallError::UnknownChart(
chart_code.to_string(),
self.datasets.iter().map(|d| d.code.clone()).collect(),
)
})?
.clone();
validate_dataset(&ds).map_err(|e| ChartInstallError::InvalidDataset(ds.code.clone(), e))?;
let rows = self.derive_rows(ambient_company(), &ds);
let mut tx = self.pool.begin().await?;
if let Some(scope) = org_scope::current_org_scope() {
org_scope::bind_org_scope_on(&mut tx, &scope)
.await
.map_err(anyhow::Error::from)?;
backbone_orm::audit_context::relay_ambient_audit_on(&mut tx)
.await
.map_err(anyhow::Error::from)?;
}
if self.repo.company_has_postings(&mut tx).await? {
return Err(ChartInstallError::ChartHasPostings(
ds.code.clone(),
company_id,
));
}
let overlaps = self
.repo
.overlapping_accounts(&mut tx, &ds)
.await?;
let conflicts: Vec<(String, String)> = overlaps
.into_iter()
.filter(|o| {
let ours = rows.iter().any(|r| {
r.def.number == o.account_number
&& r.id == o.id
&& o.chart_code.as_deref() == Some(ds.code.as_str())
}) || rows.iter().any(|r| {
r.def.code == o.account_code
&& r.id == o.id
&& o.chart_code.as_deref() == Some(ds.code.as_str())
});
!ours
})
.map(|o| (o.account_number, o.account_code))
.collect();
if !conflicts.is_empty() {
return Err(ChartInstallError::AccountNumberConflict(
ds.code.clone(),
conflicts,
));
}
let mut installed = 0usize;
let mut updated = 0usize;
let mut resurrected = 0usize;
let mut account_ids: HashMap<String, Uuid> = HashMap::with_capacity(rows.len());
for row in &rows {
match self.repo.upsert_account(&mut tx, row).await? {
UpsertOutcome::Inserted => installed += 1,
UpsertOutcome::Updated => updated += 1,
UpsertOutcome::Resurrected => resurrected += 1,
}
account_ids.insert(row.def.code.clone(), row.id);
}
tx.commit().await?;
Ok(InstallReport {
chart_code: ds.code.clone(),
chart_version: ds.version.clone(),
accounts_installed: installed,
accounts_updated: updated,
accounts_resurrected: resurrected,
account_ids,
})
}
}