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:
@@ -82,10 +82,140 @@ def post_init_hook(env):
|
|||||||
_archive_unused_taxes(env)
|
_archive_unused_taxes(env)
|
||||||
_migrate_tax_repartition_accounts(env)
|
_migrate_tax_repartition_accounts(env)
|
||||||
_configure_fiscal_position_tax_maps(env)
|
_configure_fiscal_position_tax_maps(env)
|
||||||
|
_delete_unused_accounts(env)
|
||||||
_lock_fiscal_year_2025(env)
|
_lock_fiscal_year_2025(env)
|
||||||
_logger.info("nexa_coa_setup: post_init_hook complete")
|
_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
|
# Map of legacy l10n_ca account codes (some now suffixed '.OLD', some still
|
||||||
# raw codes that were archived) → Nexa consolidated tax accounts.
|
# raw codes that were archived) → Nexa consolidated tax accounts.
|
||||||
# Used by _migrate_tax_repartition_accounts to repoint tax repartition lines
|
# Used by _migrate_tax_repartition_accounts to repoint tax repartition lines
|
||||||
|
|||||||
Reference in New Issue
Block a user