[ADD] migration: recréer les réglages qu'aucun événement ne remet

Deux enregistrements ont cessé d'être LIVRÉS en données pour devenir le
produit d'un geste : créer une société, cocher une case, charger un plan
comptable. Une migration n'en fait aucun. Le nettoyage des orphelins les
emporte et rien ne les remet.

`product.list0` est déclaré jusqu'en 16 : 1 liste en 16, 0 en 17, alors
que le groupe compte six membres — tout devis s'ouvre alors sans liste
de prix. Le modèle de rapprochement est déclaré en 12 seulement : 0 dès
la 13, pour trois journaux de trésorerie.

On n'invente rien : `_activate_or_create_pricelists` et `_load_data` du
plan comptable, les méthodes qu'Odoo aurait appelées. Éprouvé sur copie :
0 → 1 liste, 0 → 4 modèles, un devis reprend « Par défaut (CAD) », un
second passage ne double rien.

--- EN ---

Two records stopped being SHIPPED as data and became the product of a
gesture: creating a company, ticking a box, loading a chart of accounts.
A migration does none of them. The orphan cleanup takes them away and
nothing brings them back.

`product.list0` is declared up to 16: 1 pricelist in 16, 0 in 17, while
the group has six members — every quotation then opens without a
pricelist. The reconciliation model is declared in 12 only: 0 from 13 on,
for three cash journals.

Nothing is invented: `_activate_or_create_pricelists` and the chart
template's `_load_data`, the methods Odoo itself would have called.
Proven on a copy: 0 → 1 pricelist, 0 → 4 models, a quotation picks up
« Par défaut (CAD) », a second pass duplicates nothing.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-24 04:43:38 -04:00
parent 93d26f50cb
commit 721e59dba9
2 changed files with 459 additions and 0 deletions

View file

@ -0,0 +1,262 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les réglages qu'Odoo ne recrée qu'à des moments qu'une migration évite.
Certains enregistrements de configuration ont cessé d'être LIVRÉS en
données et sont devenus le produit d'un geste : créer une société,
changer de devise, cocher une case, charger un plan comptable. Une
migration ne fait aucun de ces gestes. L'enregistrement part avec le
nettoyage des orphelins et rien ne le remet.
Deux cas mesurés sur une chaîne 12 → 18 réelle :
liste de prix `product.list0` est déclaré jusqu'en 16 et plus
après. Odoo 18 crée les listes par défaut dans
`_activate_or_create_pricelists()`, appelée à la
création d'une société, au changement de devise
et au basculement du réglage. Résultat : 1 liste
en 16, 0 en 17 et en 18, alors que le groupe
`product.group_product_pricelist` compte six
membres. Toute commande s'ouvre alors sans liste
de prix — elle ne casse rien, elle facture faux.
rapprochement bancaire `account.reconciliation_model_default_rule` est
déclaré en 12 et plus dès la 13 : la 18 le crée
depuis le PLAN COMPTABLE
(`_get_account_reconcile_model`), qu'une montée
de version ne recharge jamais. Résultat : 1 en
12, 0 partout ensuite, et plus aucun lettrage
automatique sur les journaux de trésorerie.
On n'invente rien : on appelle les méthodes d'Odoo, celles-là mêmes que
le geste manquant aurait appelées. `_load_data` est rejoué avec
`ignore_duplicates`, donc l'outil se relance sans risque.
Lecture seule par défaut. `--apply` écrit, puis RELIT pour vérifier.
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
import database_cleanup # noqa: E402
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
# Les sentinelles de `run_shell`, pas les nôtres : il les impose, et en
# poser d'autres ferait lire un rapport vide.
DEBUT = database_cleanup.START
FIN = database_cleanup.END
SCRIPT = """
import json
DRY = {dry}
rapport = {{"dry_run": DRY}}
try:
Societe = env["res.company"].sudo()
societes = Societe.search([])
rapport["companies"] = len(societes)
# ── Listes de prix ──────────────────────────────────────────────
if "product.pricelist" in env:
Liste = env["product.pricelist"].sudo().with_context(active_test=False)
rapport["pricelist_before"] = Liste.search_count([])
# Le groupe décide : sans lui la fonctionnalité est masquée et
# l'absence de liste est normale, pas une perte.
rapport["pricelist_group"] = env.user.has_group(
"product.group_product_pricelist"
)
if not DRY and rapport["pricelist_group"]:
Societe._activate_or_create_pricelists()
env.cr.commit()
rapport["pricelist_after"] = Liste.search_count([])
else:
rapport["pricelist_absent"] = True
# ── Modèles de rapprochement bancaire ───────────────────────────
if "account.reconcile.model" in env:
Modele = env["account.reconcile.model"].sudo()
rapport["reconcile_before"] = Modele.search_count([])
# Sans journal de trésorerie il n'y a rien à rapprocher : créer
# des modèles là serait du bruit dans un menu qu'on n'ouvre pas.
rapport["cash_journals"] = env["account.journal"].sudo().search_count(
[("type", "in", ("bank", "cash"))]
)
rapport["charts"] = sorted(
{{s.chart_template for s in societes if s.chart_template}}
)
if not DRY and rapport["cash_journals"] and rapport["charts"]:
Gabarit = env["account.chart.template"].sudo()
for societe in societes:
if not societe.chart_template:
continue
donnees = Gabarit._get_account_reconcile_model(
societe.chart_template
)
if not donnees:
continue
# `_load_data` est le chemin d'Odoo : il pose les xmlid
# préfixés par la société, donc un rechargement futur du
# plan comptable ne fera pas de doublon.
Gabarit.with_company(societe)._load_data(
{{"account.reconcile.model": donnees}},
ignore_duplicates=True,
)
env.cr.commit()
rapport["reconcile_after"] = Modele.search_count([])
else:
rapport["reconcile_absent"] = True
except Exception as exc:
rapport["error"] = "%s: %s" % (type(exc).__name__, exc)
print({debut!r})
print(json.dumps(rapport))
print({fin!r})
"""
def build_script(dry_run):
return SCRIPT.format(
dry="True" if dry_run else "False", debut=DEBUT, fin=FIN
)
def pricelist_missing(rapport, apres=False):
"""Une liste manque-t-elle vraiment ?
Pas de liste ET le groupe actif : la fonctionnalité est offerte aux
utilisateurs et ne répond rien. Groupe éteint, l'absence est normale.
`apres` regarde le compte D'APRÈS la réparation. Sans ce choix, un
`--apply` réussi se conclurait toujours par « il en manque encore » :
le compte d'avant, lui, reste à zéro pour l'éternité.
"""
if rapport.get("pricelist_absent"):
return False
if not rapport.get("pricelist_group"):
return False
cle = "pricelist_after" if apres else "pricelist_before"
return not rapport.get(cle)
def reconcile_missing(rapport, apres=False):
"""Aucun modèle alors qu'il y a de quoi rapprocher."""
if rapport.get("reconcile_absent"):
return False
if not rapport.get("cash_journals"):
return False
cle = "reconcile_after" if apres else "reconcile_before"
return not rapport.get(cle)
def findings(rapport, apres=False):
lst = []
if pricelist_missing(rapport, apres):
lst.append("pricelist")
if reconcile_missing(rapport, apres):
lst.append("reconcile")
return lst
def render(rapport, dry_run):
lignes = []
if not findings(rapport):
return [f"✅ {t('Every default configuration record is there.')}"]
lignes.append(
f"🧾 {t('Configuration records a migration never recreates')}"
)
lignes.append("")
if pricelist_missing(rapport):
lignes.append(
f" 💲 {t('No pricelist, and the pricelist feature is on.')}"
)
lignes.append(
f" {t('Every quotation opens without one: raw price, no rule.')}"
)
if reconcile_missing(rapport):
lignes.append(
f" 🏦 {t('No bank reconciliation model, for')}"
f" {rapport.get('cash_journals', 0)} {t('cash/bank journal(s).')}"
)
lignes.append(f" {t('Every statement line is matched by hand.')}")
lignes.append("")
if dry_run:
lignes.append(f" {t('Use --apply to let Odoo recreate them.')}")
else:
lignes.append(
f" {t('pricelists:')} {rapport.get('pricelist_before', 0)}"
f" → {rapport.get('pricelist_after', 0)}"
f" · {t('reconcile models:')}"
f" {rapport.get('reconcile_before', 0)}"
f" → {rapport.get('reconcile_after', 0)}"
)
return lignes
def main(argv=None):
parser = argparse.ArgumentParser(
description=(
"Report the default configuration records a version bump drops"
" and no event recreates, and optionally let Odoo recreate them."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument("-c", "--config", default="config.conf")
parser.add_argument(
"--apply",
action="store_true",
help="actually recreate them (default: report only)",
)
config = parser.parse_args(argv)
# Le même garde-fou que partout : un Odoo d'une autre version ÉCRIT
# dans la base avant d'échouer.
souci = database_cleanup.require_matching_version(config.database)
if souci:
print(f"❌ {souci}")
return 2
try:
rapport = database_cleanup.run_shell(
config.database,
config.config,
build_script(not config.apply),
echo=lambda texte: print(f"⧖ {texte}", flush=True),
)
except RuntimeError as exc:
print(f"❌ {exc}")
return 2
if rapport.get("error"):
print(f"❌ {rapport['error']}")
return 2
print("\n".join(render(rapport, not config.apply)))
if not findings(rapport):
return 0
if not config.apply:
return 1
# Juger sur le compte D'APRÈS : le script a recompté dans la même
# session. Annoncer « recréé » sans regarder ferait découvrir
# l'absence au premier devis.
reste = findings(rapport, apres=True)
if reste:
print(f"⚠️ {t('Still missing after the repair.')} {', '.join(reste)}")
return 1
print(f"✅ {t('Odoo recreated them.')}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,197 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les réglages qu'Odoo ne recrée qu'à des moments qu'une migration évite.
Deux enregistrements ont cessé d'être LIVRÉS en données pour devenir le
produit d'un geste — créer une société, cocher une case, charger un plan
comptable. Une migration n'en fait aucun : le nettoyage des orphelins les
emporte et rien ne les remet.
Mesuré sur une chaîne 12 → 18 réelle : 1 liste de prix en 16, 0 en 17,
alors que le groupe compte six membres ; 1 modèle de rapprochement en 12,
0 dès la 13, pour trois journaux de trésorerie.
Ce que l'outil doit surtout savoir faire, c'est SE TAIRE : sans le groupe
des listes de prix, sans journal de trésorerie, ou sur une base qui n'a
ni vente ni comptabilité, l'absence est normale et le dire serait du
bruit qu'on apprendrait à ignorer.
"""
import os
import sys
import unittest
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.odoo.migration import restore_config_defaults as conf # noqa: E402
def rapport(**extra):
base = {
"dry_run": True,
"companies": 1,
"pricelist_before": 0,
"pricelist_after": 0,
"pricelist_group": True,
"reconcile_before": 0,
"reconcile_after": 0,
"cash_journals": 3,
"charts": ["ca_2023"],
}
base.update(extra)
return base
class TestWhenAPricelistIsReallyMissing(unittest.TestCase):
def test_none_and_the_feature_is_on(self):
self.assertTrue(conf.pricelist_missing(rapport()))
def test_the_feature_is_off_so_the_absence_is_normal(self):
# Sans le groupe, le menu n'existe pas : signaler l'absence
# enverrait réparer ce que personne n'utilise.
self.assertFalse(
conf.pricelist_missing(rapport(pricelist_group=False))
)
def test_there_is_already_one(self):
self.assertFalse(conf.pricelist_missing(rapport(pricelist_before=1)))
def test_the_module_is_not_installed_at_all(self):
self.assertFalse(
conf.pricelist_missing(rapport(pricelist_absent=True))
)
class TestWhenAReconcileModelIsReallyMissing(unittest.TestCase):
def test_none_and_there_are_journals_to_match(self):
self.assertTrue(conf.reconcile_missing(rapport()))
def test_no_cash_journal_so_nothing_to_match(self):
# Créer des modèles là serait du bruit dans un menu qu'on
# n'ouvre pas.
self.assertFalse(conf.reconcile_missing(rapport(cash_journals=0)))
def test_there_are_already_some(self):
self.assertFalse(conf.reconcile_missing(rapport(reconcile_before=4)))
def test_accounting_is_not_installed(self):
self.assertFalse(
conf.reconcile_missing(rapport(reconcile_absent=True))
)
class TestJudgingBeforeAndAfter(unittest.TestCase):
"""Le compte d'AVANT reste à zéro pour l'éternité.
Juger dessus après une réparation réussie conclurait toujours « il en
manque encore ». C'est le compte d'APRÈS qui dit si le geste a pris.
"""
def test_before_the_repair_both_are_missing(self):
self.assertEqual(["pricelist", "reconcile"], conf.findings(rapport()))
def test_after_a_successful_repair_nothing_is_missing(self):
plein = rapport(pricelist_after=1, reconcile_after=4)
self.assertEqual([], conf.findings(plein, apres=True))
# …et le compte d'avant, lui, dit toujours qu'il manquait.
self.assertEqual(2, len(conf.findings(plein)))
def test_a_half_repair_is_reported(self):
moitie = rapport(pricelist_after=1, reconcile_after=0)
self.assertEqual(["reconcile"], conf.findings(moitie, apres=True))
class TestTheReport(unittest.TestCase):
def test_a_healthy_database_says_so(self):
sain = rapport(pricelist_before=1, reconcile_before=4)
texte = "\n".join(conf.render(sain, True))
self.assertIn("✅", texte)
def test_it_says_what_breaks_for_a_user_not_what_is_missing(self):
# « 0 liste de prix » ne dit rien à personne ; « tout devis
# s'ouvre sans liste » se comprend sans connaître le modèle.
#
# On demande la MÊME traduction que le code : comparer à un mot
# anglais en dur fait tomber le test dès qu'on traduit, ce qui
# m'est arrivé sur celui-ci.
texte = "\n".join(conf.render(rapport(), True))
self.assertIn(
conf.t("Every quotation opens without one: raw price, no rule."),
texte,
)
self.assertIn(
conf.t("Every statement line is matched by hand."), texte
)
def test_it_counts_the_journals_it_found(self):
texte = "\n".join(conf.render(rapport(cash_journals=3), True))
self.assertIn("3", texte)
def test_the_dry_run_offers_the_flag(self):
self.assertIn("--apply", "\n".join(conf.render(rapport(), True)))
def test_after_applying_it_shows_the_movement(self):
# Un rapport qui répète « il en manquait » sans dire ce qui a été
# fait laisse rouvrir l'outil pour savoir.
fait = rapport(pricelist_after=1, reconcile_after=4)
texte = "\n".join(conf.render(fait, False))
self.assertIn("0 → 1", texte)
self.assertIn("0 → 4", texte)
self.assertNotIn("--apply", texte)
class TestTheOrmScript(unittest.TestCase):
"""Le script poussé dans le shell doit être du Python valide."""
def test_both_shapes_compile(self):
for sec in (True, False):
with self.subTest(dry=sec):
compile(conf.build_script(sec), "script", "exec")
def test_the_dry_run_never_writes(self):
sec = conf.build_script(True)
self.assertIn("DRY = True", sec)
def test_applying_commits_each_repair(self):
# Le shell d'Odoo annule en sortant : sans commit, la réparation
# s'évapore et l'outil annonce une réussite qui n'a rien laissé.
#
# UN par réparation, et l'on compte : chercher « il y a un
# commit » laissait retirer celui du rapprochement sans que rien
# ne le dise — l'autre le masquait. Mesuré.
vif = conf.build_script(False)
self.assertIn("DRY = False", vif)
self.assertEqual(2, vif.count("env.cr.commit()"), vif)
def test_each_branch_has_its_own_commit(self):
vif = conf.build_script(False)
prix = vif[vif.index("_activate_or_create_pricelists") :]
prix = prix[: prix.index("account.reconcile.model")]
self.assertIn("env.cr.commit()", prix)
reco = vif[vif.index("_get_account_reconcile_model") :]
self.assertIn("env.cr.commit()", reco)
def test_it_calls_odoo_rather_than_inventing_records(self):
vif = conf.build_script(False)
self.assertIn("_activate_or_create_pricelists", vif)
self.assertIn("_get_account_reconcile_model", vif)
self.assertIn("_load_data", vif)
def test_it_lets_odoo_skip_what_already_exists(self):
# `ignore_duplicates` est ce qui rend l'outil rejouable ; sans
# lui, une seconde passe doublerait les modèles.
self.assertIn("ignore_duplicates=True", conf.build_script(False))
def test_it_uses_the_sentinels_run_shell_imposes(self):
# En poser d'autres ferait lire un rapport vide.
vif = conf.build_script(False)
self.assertIn(conf.DEBUT, vif)
self.assertIn(conf.FIN, vif)
if __name__ == "__main__":
unittest.main()