[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:
parent
93d26f50cb
commit
721e59dba9
2 changed files with 459 additions and 0 deletions
262
script/odoo/migration/restore_config_defaults.py
Executable file
262
script/odoo/migration/restore_config_defaults.py
Executable 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())
|
||||
197
test/test_restore_config_defaults.py
Normal file
197
test/test_restore_config_defaults.py
Normal 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()
|
||||
Loading…
Reference in a new issue