diff --git a/script/odoo/migration/restore_config_defaults.py b/script/odoo/migration/restore_config_defaults.py new file mode 100755 index 0000000..e40cf92 --- /dev/null +++ b/script/odoo/migration/restore_config_defaults.py @@ -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()) diff --git a/test/test_restore_config_defaults.py b/test/test_restore_config_defaults.py new file mode 100644 index 0000000..3f2e8de --- /dev/null +++ b/test/test_restore_config_defaults.py @@ -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()