diff --git a/nexa_coa_setup/hooks.py b/nexa_coa_setup/hooks.py index 0f6028de..b2fc52d8 100644 --- a/nexa_coa_setup/hooks.py +++ b/nexa_coa_setup/hooks.py @@ -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