[FIX] l10n_fr_account: recreate missing property-account xml-ids in 18.0

The core script ``l10n_fr_account/migrations/2.1/end-migrate_update_taxes.py``
calls ``account.chart.template.try_loading('fr', company)``. During
``_post_load_data``, Odoo 18 resolves the French template's default accounts via
``self.ref()`` WITHOUT ``raise_if_not_found=False`` (chart_template.py:711/714
and the ``_get_property_accounts`` loop at :746). If any expected
``account.<company>_<key>`` xml-id is missing, it raises
``ValueError: External ID not found`` and aborts the registry load.

This happens when a database customized its chart of accounts: the generic PCG
account was deleted (e.g. 607000) or recreated without its xml-id (e.g. 707000).

Add a generic ORM ``post-migration`` script that, for every company with a
``chart_template``, reads the expected account xml-ids from the template API
(``_get_property_accounts`` / ``_get_chart_template_data`` /
``_get_account_account``) and recreates any missing ``account.<company>_<key>``
``ir.model.data`` pointing to the best matching account: exact code, else the
company account sharing the longest common code prefix, else a non-blocking
warning. It runs at stage ``post`` (before the native ``end`` script) and only
creates missing xml-ids — no account is modified. Idempotent, generic across
all migrated FR databases.

Remove the previous hardcoded 607/707 SQL block from
``versions/18.0/pre_upgrade.sh`` now superseded by this script.
This commit is contained in:
Stéphan Sainléger
2026-07-15 23:15:22 +02:00
parent b8e3168eaf
commit bc3907e1d8
2 changed files with 202 additions and 0 deletions

View File

@@ -131,6 +131,13 @@ EOF
echo "SQL command = $PRE_MIGRATE_SQL"
query_postgres_container "$PRE_MIGRATE_SQL" ou18 || exit 1
# NOTE: Missing generic PCG account xml-ids expected by l10n_fr_account (e.g.
# account.<company>_pcg_607_account / _pcg_707_account) are recreated generically
# by the ORM post-migration script
# versions/18.0/scripts/l10n_fr_account/18.0.2.2/post-migration-0k-fix-property-account-xmlids.py
# which runs at stage "post" of l10n_fr_account, before the native "end" script
# end-migrate_update_taxes.py that would otherwise crash on the missing xml-id.
# Copy filestores
copy_filestore ou17 ou17 ou18 ou18 || exit 1

View File

@@ -0,0 +1,195 @@
# Copyright 0k / Élabore
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
#
# Custom OpenUpgrade migration script, injected through a dedicated
# --upgrade-path (see versions/18.0/upgrade.sh). Runs at stage "post" of
# l10n_fr_account/18.0.2.2, i.e. BEFORE the global "end" stage where the native
# l10n_fr_account/migrations/2.1/end-migrate_update_taxes.py runs.
#
# Problem
# -------
# end-migrate_update_taxes.py calls account.chart.template.try_loading('fr', ...).
# During _post_load_data(), Odoo 18 resolves the French template's default
# accounts via self.ref() WITHOUT raise_if_not_found=False, at three places:
# - property_account_income_categ_id (pcg_707_account) -> chart_template.py:711
# - property_account_expense_categ_id (pcg_607_account) -> chart_template.py:714
# - every _get_property_accounts()/additional_properties account -> :746
# If any expected account xml-id "account.<company>_<key>" is missing, it raises:
# ValueError: External ID not found in the system: account.1_pcg_607_account
# which aborts the whole registry load.
#
# This happens when a database customized its chart of accounts: the generic PCG
# account was deleted (e.g. 607000) or recreated without its xml-id (e.g. 707000).
#
# Fix (generic, not hardcoded to 607/707)
# ---------------------------------------
# For every company with a chart_template, we read the list of account xml-ids
# the template expects (dynamically, from the template ORM API), and for each one
# that is missing in the DB we recreate the "account.<company>_<key>" ir.model.data
# pointing to the best matching real account:
# 1. the account whose code matches exactly the template account code,
# 2. otherwise the company account whose code shares the longest common prefix
# with the template code (e.g. 607000 -> 607100),
# 3. otherwise nothing (logged as a warning; never blocks).
# No account is modified; only missing xml-ids are (re)created. Idempotent.
import logging
from openupgradelib import openupgrade
_logger = logging.getLogger(__name__)
def _collect_property_account_xmlids(template, code, accounts_csv):
"""Return the set of template account xml-ids referenced as property accounts.
Sources:
- template._get_property_accounts({}) (receivable, payable, expense, income, ...)
- the template_data dict (property_account_*_id keys)
- additional_properties declared on res.company template data
Only keep values that are real account xml-ids of this template, i.e. present
in accounts_csv (this filters out journals and dotted external ids).
"""
xmlids = set()
# 1. Base property accounts (model-level defaults)
try:
prop_fields = template._get_property_accounts({})
except Exception as exc: # noqa: BLE001
_logger.warning("Could not read _get_property_accounts: %s", exc)
prop_fields = {}
# 2. Full template data: template_data holds property_account_*_id values,
# res.company holds additional_properties (currency exchange, deferral...).
try:
tdata = template._get_chart_template_data(code)
except Exception as exc: # noqa: BLE001
_logger.warning("Could not read _get_chart_template_data(%s): %s", code, exc)
tdata = {}
candidate_values = []
template_data = tdata.get("template_data", {}) if tdata else {}
for key, value in template_data.items():
if key in prop_fields or key.startswith("property_"):
candidate_values.append(value)
# additional_properties live inside the res.company template values
res_company = tdata.get("res.company", {}) if tdata else {}
for _company_key, company_vals in res_company.items():
if not isinstance(company_vals, dict):
continue
add_props = company_vals.get("additional_properties")
if isinstance(add_props, dict):
candidate_values.extend(add_props.values())
# Keep only plain (non-dotted) xml-ids that are actual accounts of the template
for value in candidate_values:
if isinstance(value, str) and value and "." not in value and value in accounts_csv:
xmlids.add(value)
return xmlids
def _find_best_account(env, company, target_code):
"""Return the best account record for a template account code, or None.
1. exact code match for the company;
2. otherwise the company account whose code shares the longest common prefix
with target_code; ties are broken by smallest code (deterministic).
3. otherwise None.
The exact fallback account matters little functionally: it is only used as a
journal's default_account_id / an ir.default, which stays user-overridable.
The goal is to provide a valid account of the right family so that
try_loading() no longer crashes on a missing external id.
"""
if not target_code:
return None
Account = env["account.account"]
company_domain = [("company_ids", "in", company.id)]
exact = Account.search(company_domain + [("code", "=", target_code)], limit=1)
if exact:
return exact
# Longest common prefix fallback; ties broken by smallest code.
candidates = Account.search(company_domain)
best = None
best_key = None # (prefix_len, negated-code-for-min) -> maximize
for acc in candidates:
code = acc.code or ""
common = 0
for a, b in zip(code, target_code):
if a != b:
break
common += 1
if common == 0:
continue
key = (common, tuple(-ord(c) for c in code)) # longer prefix, then smallest code
if best_key is None or key > best_key:
best = acc
best_key = key
return best
@openupgrade.migrate()
def migrate(env, version):
template = env["account.chart.template"]
companies = env["res.company"].search([("chart_template", "!=", False)])
created = 0
for company in companies:
code = company.chart_template
tmpl = template.with_company(company)
try:
accounts_csv = tmpl._get_account_account(code)
except Exception as exc: # noqa: BLE001
_logger.warning(
"Company %s (%s): cannot read template accounts: %s",
company.id, code, exc,
)
continue
expected = _collect_property_account_xmlids(tmpl, code, accounts_csv)
for tmpl_xmlid in sorted(expected):
full = f"account.{company.id}_{tmpl_xmlid}"
if env.ref(full, raise_if_not_found=False):
continue
if company.parent_ids:
parent_full = (
f"account.{company.parent_ids[0].id}_{tmpl_xmlid}"
)
if env.ref(parent_full, raise_if_not_found=False):
continue
target_code = accounts_csv.get(tmpl_xmlid, {}).get("code")
account = _find_best_account(env, company, target_code)
if not account:
_logger.warning(
"Company %s: no account found for template xml-id %s "
"(code %s); skipping",
company.id, tmpl_xmlid, target_code,
)
continue
env["ir.model.data"].create({
"module": "account",
"name": f"{company.id}_{tmpl_xmlid}",
"model": "account.account",
"res_id": account.id,
"noupdate": True,
})
created += 1
_logger.info(
"Company %s: created xml-id %s -> account %s (%s) for "
"template code %s",
company.id, full, account.id, account.code, target_code,
)
_logger.info(
"[l10n_fr_account] property-account xml-id fix: %s xml-id(s) created",
created,
)