feat(nexa_coa_setup): hard-delete unused accounts

Adds _delete_unused_accounts hook that hard-deletes (not archives) every
account that's safe to remove — not owned by nexa_coa_setup AND not
referenced by:
- account.move.line postings
- account.tax.repartition.line
- account.journal default/suspense/profit/loss accounts
- account.fiscal.position.account substitution maps
- product.category and product.template JSONB property_account_* fields
- res.partner JSONB property_account_payable_id/receivable_id
- res.company exchange/transfer/POS receivable accounts

Tries bulk unlink first; falls back to per-record if a batch fails so
the rest still get cleaned.

Result on staging: 554 -> 172 total accounts (deleted 382). The 31 still
archived are blocked by references (historical postings, tax repartition
links, bank journal defaults, etc.) — left as archived so they're hidden
from dropdowns but preserve audit history.

Verified all 4 test invoices still post correctly (ON 113, US 100, QC
114.98, Westin intercompany 169.50).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-12 19:49:00 -04:00
parent 9c52fac9ba
commit 092423d7de

View File

@@ -82,10 +82,140 @@ def post_init_hook(env):
_archive_unused_taxes(env)
_migrate_tax_repartition_accounts(env)
_configure_fiscal_position_tax_maps(env)
_delete_unused_accounts(env)
_lock_fiscal_year_2025(env)
_logger.info("nexa_coa_setup: post_init_hook complete")
def _delete_unused_accounts(env):
"""Hard-delete every account that's safe to remove.
'Safe' = not owned by nexa_coa_setup AND not referenced by:
- account.move.line (posted entries)
- account.tax.repartition.line
- account.journal default/suspense/profit/loss accounts
- account.fiscal.position.account maps
- product.category property_account_income_categ_id / property_account_expense_categ_id
- product.template property_account_income_id / property_account_expense_id
- res.partner property_account_payable_id / property_account_receivable_id
- res.company income_currency_exchange_account_id / expense_currency_exchange_account_id /
transfer_account_id
Properties in Odoo 19 are stored as JSONB (e.g. {"1": 1234}) per company.
Tries unlink in batches; if a batch fails, falls back to per-record so the
rest still get cleaned. Logs successes and skipped (still-referenced) ids.
"""
# Collect every account-id we must keep
keep = set()
# 1. Nexa-owned
env.cr.execute(
"SELECT res_id FROM ir_model_data WHERE model='account.account' AND module='nexa_coa_setup'"
)
keep.update(r[0] for r in env.cr.fetchall())
# 2. Anything with posted move lines
env.cr.execute(
"SELECT DISTINCT account_id FROM account_move_line WHERE account_id IS NOT NULL"
)
keep.update(r[0] for r in env.cr.fetchall())
# 3. Tax repartition lines
env.cr.execute(
"SELECT DISTINCT account_id FROM account_tax_repartition_line WHERE account_id IS NOT NULL"
)
keep.update(r[0] for r in env.cr.fetchall())
# 4. Journal default/suspense/profit/loss accounts
env.cr.execute("""
SELECT default_account_id FROM account_journal WHERE default_account_id IS NOT NULL
UNION SELECT suspense_account_id FROM account_journal WHERE suspense_account_id IS NOT NULL
UNION SELECT profit_account_id FROM account_journal WHERE profit_account_id IS NOT NULL
UNION SELECT loss_account_id FROM account_journal WHERE loss_account_id IS NOT NULL
""")
keep.update(r[0] for r in env.cr.fetchall())
# 5. Fiscal position account substitution maps
env.cr.execute(
"SELECT account_src_id FROM account_fiscal_position_account WHERE account_src_id IS NOT NULL "
"UNION SELECT account_dest_id FROM account_fiscal_position_account WHERE account_dest_id IS NOT NULL"
)
keep.update(r[0] for r in env.cr.fetchall())
# 6. JSONB property fields on product_category and product_template
for table, col in [
("product_category", "property_account_income_categ_id"),
("product_category", "property_account_expense_categ_id"),
("product_category", "property_stock_valuation_account_id"),
("product_category", "property_price_difference_account_id"),
("product_template", "property_account_income_id"),
("product_template", "property_account_expense_id"),
]:
env.cr.execute(
f"SELECT DISTINCT (jsonb_each_text({col})).value::int "
f"FROM {table} WHERE {col} IS NOT NULL"
)
keep.update(r[0] for r in env.cr.fetchall() if r[0])
# 7. JSONB property fields on res_partner
for col in ("property_account_payable_id", "property_account_receivable_id"):
env.cr.execute(
f"SELECT DISTINCT (jsonb_each_text({col})).value::int "
f"FROM res_partner WHERE {col} IS NOT NULL"
)
keep.update(r[0] for r in env.cr.fetchall() if r[0])
# 8. Company exchange/transfer accounts (regular int columns, not JSONB)
env.cr.execute("""
SELECT income_currency_exchange_account_id FROM res_company
WHERE income_currency_exchange_account_id IS NOT NULL
UNION SELECT expense_currency_exchange_account_id FROM res_company
WHERE expense_currency_exchange_account_id IS NOT NULL
UNION SELECT transfer_account_id FROM res_company
WHERE transfer_account_id IS NOT NULL
UNION SELECT account_default_pos_receivable_account_id FROM res_company
WHERE account_default_pos_receivable_account_id IS NOT NULL
""")
keep.update(r[0] for r in env.cr.fetchall())
# Pick deletion candidates
candidates = env["account.account"].with_context(active_test=False).search([
("id", "not in", list(keep) or [0]),
])
if not candidates:
_logger.info("nexa_coa_setup: no accounts to delete")
return
_logger.info(
"nexa_coa_setup: attempting to delete %d unused accounts (keeping %d protected)",
len(candidates), len(keep),
)
# Try bulk first, fall back to per-record on error
deleted = 0
skipped = 0
try:
with env.cr.savepoint():
candidates.unlink()
deleted = len(candidates)
except Exception:
# Per-record fallback
for acc in candidates:
try:
with env.cr.savepoint():
acc.unlink()
deleted += 1
except Exception as e:
_logger.debug(
"nexa_coa_setup: cannot delete %s (%s): %s",
acc.code, acc.name, e,
)
skipped += 1
_logger.info("nexa_coa_setup: deleted %d accounts, skipped %d (still referenced)", deleted, skipped)
# Map of legacy l10n_ca account codes (some now suffixed '.OLD', some still
# raw codes that were archived) → Nexa consolidated tax accounts.
# Used by _migrate_tax_repartition_accounts to repoint tax repartition lines