[ADD] migration quality: tell Odoo's own redesigns from real losses

« 81 tables lost rows » buried the questions worth asking. The largest of
them — ir_translation, 32 984 rows — is a redesign Odoo made in 16, when
translations moved into jsonb columns. Putting redesigns and real losses
on the same footing is the surest way not to see the second kind.

A map now names what Odoo moves or retires, and the report splits the
list in two: unexplained first, then what Odoo did on purpose. On a real
12 → 18 the split is 57 and 14, and the partition is exact — an explained
loss is annotated, never removed, because removing it was the one thing
that could hide a real one.

Every entry was VERIFIED on that migration by counting both sides, not
written from memory: crm_lead_tag 6 → crm_tag +6, mail_channel 26 →
discuss_channel +26, account_invoice 651 → account_move +441. A map
written from memory would explain losses that are not losses.

And a merge whose target gained nothing says so: the explanation does not
hold, and that is exactly what the map was meant to surface.

--- FR ---

[ADD] qualité de migration : distinguer les refontes d'Odoo des vraies pertes

« 81 tables ont perdu des lignes » enterrait les questions qui valaient la
peine. La plus grosse — ir_translation, 32 984 lignes — est une refonte
d'Odoo 16, quand les traductions sont passées en colonnes jsonb.

Une carte nomme désormais ce qu'Odoo déplace ou retire, et le rapport
sépare la liste en deux : l'inexpliqué d'abord, puis ce qu'Odoo a fait
exprès. Sur une vraie 12 → 18 : 57 et 14, et la partition est exacte —
une perte expliquée est annotée, jamais retirée.

Chaque entrée a été VÉRIFIÉE sur cette migration en comptant les deux
côtés : crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel
+26, account_invoice 651 → account_move +441.

Et une fusion dont la table d'accueil n'a rien reçu le dit : l'explication
ne tient pas, et c'est précisément ce que la carte devait révéler.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-20 02:42:18 -04:00
parent 951b88abab
commit e75bc585ba
4 changed files with 384 additions and 23 deletions

View file

@ -333,6 +333,158 @@ def render_missing(etat, colour=False, limit=60):
return "\n".join(lignes)
# Ce qu'Odoo déplace ou retire de lui-même, d'une version à l'autre.
#
# Sans cette carte, l'outil annonçait « 81 tables ont perdu des lignes »
# et la plus grosse d'entre elles — ir_translation, 32 984 lignes — était
# une refonte voulue par l'éditeur. Les vraies questions se noyaient dans
# les fausses, ce qui est la façon la plus sûre de ne pas les voir.
#
# Chaque entrée a été VÉRIFIÉE sur une migration réelle 12 → 18, en
# comptant les deux côtés. Une carte écrite de mémoire vaudrait moins que
# pas de carte : elle expliquerait des pertes qui n'en sont pas.
SEMANTIC_MAP = (
# Fusions : les enregistrements continuent, ailleurs et autrement.
{
"since": 13,
"table": "account_invoice",
"into": "account_move",
"kind": "merged",
"why": "invoices became journal entries",
},
{
"since": 13,
"table": "account_invoice_line",
"into": "account_move_line",
"kind": "merged",
"why": "invoices became journal entries",
},
{
"since": 13,
"table": "account_invoice_tax",
"into": "account_move_line",
"kind": "merged",
"why": "invoices became journal entries",
},
# Renommages : les mêmes enregistrements, sous un autre nom.
{
"since": 17,
"table": "mail_channel",
"into": "discuss_channel",
"kind": "renamed",
"why": "Discuss was renamed",
},
{
"since": 17,
"table": "mail_channel_partner",
"into": "discuss_channel_member",
"kind": "renamed",
"why": "Discuss was renamed",
},
{
"since": 13,
"table": "website_redirect",
"into": "website_rewrite",
"kind": "renamed",
"why": "redirections were reworked",
},
{
"since": 13,
"table": "crm_lead_tag",
"into": "crm_tag",
"kind": "renamed",
"why": "tags became shared",
},
# Retraits : la fonction a quitté la base pour du code.
{
"since": 16,
"table": "ir_translation",
"into": None,
"kind": "retired",
"why": "translations moved into jsonb columns",
},
{
"since": 17,
"table": "account_tax_template",
"into": None,
"kind": "retired",
"why": "chart templates left the database",
},
{
"since": 17,
"table": "account_account_template",
"into": None,
"kind": "retired",
"why": "chart templates left the database",
},
{
"since": 17,
"table": "account_chart_template",
"into": None,
"kind": "retired",
"why": "chart templates left the database",
},
{
"since": 17,
"table": "account_fiscal_position_template",
"into": None,
"kind": "retired",
"why": "chart templates left the database",
},
{
"since": 17,
"table": "account_fiscal_position_tax_template",
"into": None,
"kind": "retired",
"why": "chart templates left the database",
},
{
"since": 17,
"table": "account_fiscal_position_account_template",
"into": None,
"kind": "retired",
"why": "chart templates left the database",
},
{
"since": 15,
"table": "stock_inventory",
"into": "stock_quant",
"kind": "merged",
"why": "inventory adjustments became quants",
},
{
"since": 15,
"table": "stock_inventory_line",
"into": "stock_quant",
"kind": "merged",
"why": "inventory adjustments became quants",
},
)
def as_version(etat):
"""« 18.0 » -> 18. None si l'on ne sait pas."""
try:
return int(float((etat or {}).get("odoo") or 0)) or None
except (TypeError, ValueError):
return None
def explain_loss(table, version):
"""Ce qu'Odoo a fait de cette table à cette version, ou None.
`version` est celle d'ARRIVÉE : une refonte de la 17 n'explique rien
d'un palier 13 → 14, et l'accepter ferait taire une vraie perte sous
prétexte qu'elle porte le nom d'une table refondue plus tard.
"""
if not version:
return None
for entree in SEMANTIC_MAP:
if entree["table"] == table and entree["since"] <= version:
return entree
return None
def compare(avant, apres):
"""Ce qui a été gagné et ce qui a été perdu entre deux paliers."""
if not avant.get("exists") or not apres.get("exists"):
@ -341,18 +493,34 @@ def compare(avant, apres):
mod_avant, mod_apres = set(avant["model"]), set(apres["model"])
tbl_avant, tbl_apres = avant["table"], apres["table"]
# TOUJOURS quatre éléments, le dernier étant l'explication ou None.
# Un tuple de taille variable obligerait chaque lecteur à s'en méfier.
lignes_perdues = []
for table, nombre in sorted(tbl_avant.items()):
reste = tbl_apres.get(table)
if reste is None and nombre:
lignes_perdues.append((table, nombre, 0))
lignes_perdues.append((table, nombre, 0, None))
elif reste is not None and reste < nombre:
lignes_perdues.append((table, nombre, reste))
lignes_perdues.append((table, nombre, reste, None))
lignes_gagnees = [
(table, tbl_avant.get(table, 0), nombre)
for table, nombre in sorted(tbl_apres.items())
if nombre > tbl_avant.get(table, 0)
]
version = as_version(apres)
for index, (table, debut, fin, _rien) in enumerate(lignes_perdues):
connu = explain_loss(table, version)
if not connu:
continue
# Une fusion qui n'a PAS grossi la table d'accueil n'explique
# rien : on garde l'explication et on dit qu'elle ne tient pas.
arrivee = connu["into"]
recue = (
apres["table"].get(arrivee, 0) - avant["table"].get(arrivee, 0)
if arrivee
else None
)
lignes_perdues[index] = (table, debut, fin, {**connu, "gained": recue})
return {
"modules_lost": sorted(inst_avant - inst_apres),
"modules_gained": sorted(inst_apres - inst_avant),
@ -384,7 +552,9 @@ def probable_renames(perdues, gagnees):
cherche un dégât là où il n'y en a pas.
"""
disparues = {
table: avant for table, avant, apres in perdues if apres == 0 and avant
table: avant
for table, avant, apres, _connu in perdues
if apres == 0 and avant
}
apparues = {
table: apres for table, avant, apres in gagnees if avant == 0 and apres
@ -528,21 +698,51 @@ def render_compare(diff, colour, limit=8):
# LE signal qui compte : un module en moins se voit, une table qui
# passe de quatre mille lignes à zéro ne se voit nulle part.
#
# Une table probablement renommée reste dans la LISTE, annotée. L'en
# retirer était le vrai danger : un rapprochement faux — et il y en
# a eu — aurait fait disparaître une perte réelle du rapport.
# Les pertes EXPLIQUÉES sont séparées des autres, jamais retirées.
# Sans cette séparation on lisait « 81 tables ont perdu des lignes »
# dont la plus grosse — ir_translation, 32 984 lignes — était une
# refonte voulue par Odoo : les vraies questions se noyaient dans
# les fausses, ce qui est la façon la plus sûre de ne pas les voir.
vers = {a: b for a, b, _n in diff["renamed"]}
lignes.append(
f" {paint('▼', 'fail', colour)} {len(perdues)}"
f" {t('table(s) lost rows')} :"
)
for table, avant, apres in perdues[:limit]:
note = (
f" ↻ {t('probably renamed to')} {vers[table]}"
if table in vers
else ""
ouvertes = [item for item in perdues if not item[3]]
connues = [item for item in perdues if item[3]]
if ouvertes:
lignes.append(
f" {paint('▼', 'fail', colour)} {len(ouvertes)}"
f" {t('table(s) lost rows, unexplained')} :"
)
lignes.append(f" {table:<40} {avant:>8} → {apres}{note}")
for table, avant, apres, _rien in ouvertes[:limit]:
note = (
f" ↻ {t('probably renamed to')} {vers[table]}"
if table in vers
else ""
)
lignes.append(f" {table:<40} {avant:>8} → {apres}{note}")
if len(ouvertes) > limit:
lignes.append(f" … {len(ouvertes) - limit} {t('more')}")
if connues:
lignes.append(
f" {paint('▽', 'dim', colour)} {len(connues)}"
f" {t('table(s) Odoo moved or retired')} :"
)
for table, avant, apres, connu in connues[:limit]:
if connu["into"]:
recue = connu.get("gained")
# Une fusion dont la table d'accueil n'a PAS grossi
# n'explique rien : le dire, plutôt que de classer la
# perte comme attendue et passer à autre chose.
accueil = (
f"+{recue} {t('there')}"
if recue and recue > 0
else paint(t("but it gained nothing"), "fail", colour)
)
ou = f"→ {connu['into']} ({accueil})"
else:
ou = t("retired from the database")
lignes.append(f" {table:<40} {avant:>8} → {apres} {ou}")
lignes.append(f" {connu['why']}")
if len(connues) > limit:
lignes.append(f" … {len(connues) - limit} {t('more')}")
delta = diff["delta"]
lignes.append(
" "

View file

@ -67,10 +67,13 @@ def rows(lst_snapshot):
# Le précédent voyage avec la ligne : le panneau en a besoin pour
# écrire « 171 (−43) », et le recalculer là-bas ferait deux
# sources pour la même comparaison.
# La colonne compte les pertes INEXPLIQUÉES : afficher 81 quand
# 79 sont des refontes voulues par Odoo ferait fuir le lecteur du
# seul chiffre qui demande une réponse.
perdu = (
0
if diff is None or diff.get("unavailable")
else len(diff["rows_lost"])
else len([item for item in diff["rows_lost"] if not item[3]])
)
lst.append(
{

View file

@ -5783,6 +5783,26 @@ TRANSLATIONS = {
"fr": "Choisissez un palier pour voir ses fichiers absents.",
"en": "Pick a step to see its missing files.",
},
"table(s) lost rows, unexplained": {
"fr": "table(s) ont perdu des lignes, sans explication",
"en": "table(s) lost rows, unexplained",
},
"table(s) Odoo moved or retired": {
"fr": "table(s) qu'Odoo a déplacées ou retirées",
"en": "table(s) Odoo moved or retired",
},
"retired from the database": {
"fr": "retirée de la base",
"en": "retired from the database",
},
"but it gained nothing": {
"fr": "mais elle n'a rien reçu",
"en": "but it gained nothing",
},
"there": {
"fr": "là-bas",
"en": "there",
},
"Clean the database before testing the pages?": {
"fr": "Nettoyer la base avant de tester les pages ?",
"en": "Clean the database before testing the pages?",

View file

@ -122,17 +122,19 @@ class TestWhatIsGainedAndLost(Base):
def test_a_table_that_empties_is_reported(self):
# LE signal : un module en moins se voit, une table qui passe de
# cent lignes à zéro ne se voit nulle part.
# Une table NEUTRE : le mécanisme se teste sans la carte
# sémantique, qui a ses propres tests.
diff = quality.compare(
snapshot(table={"account_invoice": 651}),
snapshot(table={"account_invoice": 0}),
snapshot(table={"ma_table": 651}),
snapshot(table={"ma_table": 0}),
)
self.assertEqual(diff["rows_lost"], [("account_invoice", 651, 0)])
self.assertEqual(diff["rows_lost"], [("ma_table", 651, 0, None)])
def test_a_table_that_disappears_counts_as_emptied(self):
diff = quality.compare(
snapshot(table={"account_invoice": 651}), snapshot(table={})
snapshot(table={"ma_table": 651}), snapshot(table={})
)
self.assertEqual(diff["rows_lost"], [("account_invoice", 651, 0)])
self.assertEqual(diff["rows_lost"], [("ma_table", 651, 0, None)])
def test_an_empty_table_that_disappears_is_not_a_loss(self):
# Rien à perdre : le signaler noierait les vraies pertes.
@ -207,7 +209,7 @@ class TestNotCryingWolfOnRenames(Base):
self.assertEqual(
diff["renamed"], [("muk_dms_directory", "dms_directory", 7)]
)
self.assertIn(("muk_dms_directory", 7, 0), diff["rows_lost"])
self.assertIn(("muk_dms_directory", 7, 0, None), diff["rows_lost"])
def test_the_report_says_it_is_only_probable(self):
diff = quality.compare(
@ -219,6 +221,142 @@ class TestNotCryingWolfOnRenames(Base):
self.assertIn("probably renamed", texte)
class TestTheSemanticMap(Base):
"""« 81 tables ont perdu des lignes » noyait les vraies questions.
La plus grosse d'entre elles — `ir_translation`, 32 984 lignes — est
une refonte voulue par Odoo en 16. Mettre les refontes et les pertes
réelles sur le même plan est la façon la plus sûre de ne pas voir les
secondes.
"""
def perte(
self,
table,
avant_n,
apres_n,
version="18.0",
cible=None,
cible_avant=0,
cible_apres=0,
):
tbl_avant = {table: avant_n}
tbl_apres = {} if apres_n == 0 else {table: apres_n}
if cible:
tbl_avant[cible] = cible_avant
tbl_apres[cible] = cible_apres
return quality.compare(
snapshot(odoo="12.0", table=tbl_avant),
snapshot(odoo=version, table=tbl_apres),
)
def test_a_known_merge_is_explained(self):
diff = self.perte(
"account_invoice",
651,
0,
cible="account_move",
cible_avant=1371,
cible_apres=1812,
)
connu = [x for x in diff["rows_lost"] if x[0] == "account_invoice"][0]
self.assertIsNotNone(connu[3])
self.assertEqual(connu[3]["into"], "account_move")
self.assertEqual(connu[3]["gained"], 441)
def test_a_retired_table_is_explained_without_a_target(self):
diff = self.perte("ir_translation", 32984, 0)
connu = [x for x in diff["rows_lost"] if x[0] == "ir_translation"][0]
self.assertIsNotNone(connu[3])
self.assertIsNone(connu[3]["into"])
def test_the_map_does_not_apply_BEFORE_its_version(self):
"""Une refonte de la 16 n'explique rien d'un palier 12 → 13.
L'accepter ferait taire une vraie perte sous prétexte que la table
porte le nom d'une autre, refondue trois versions plus tard.
"""
diff = self.perte("ir_translation", 32984, 0, version="13.0")
connu = [x for x in diff["rows_lost"] if x[0] == "ir_translation"][0]
self.assertIsNone(connu[3])
def test_it_applies_AT_its_version(self):
diff = self.perte("ir_translation", 32984, 0, version="16.0")
connu = [x for x in diff["rows_lost"] if x[0] == "ir_translation"][0]
self.assertIsNotNone(connu[3])
def test_an_explained_loss_is_STILL_in_the_list(self):
# La règle qui vaut plus que tout : expliquer n'est pas cacher.
diff = self.perte("ir_translation", 32984, 0)
self.assertIn("ir_translation", [x[0] for x in diff["rows_lost"]])
def test_the_partition_loses_nothing(self):
diff = quality.compare(
snapshot(
odoo="12.0", table={"ir_translation": 100, "ma_table": 50}
),
snapshot(odoo="18.0", table={}),
)
perdues = diff["rows_lost"]
ouvertes = [x for x in perdues if not x[3]]
connues = [x for x in perdues if x[3]]
self.assertEqual(len(perdues), len(ouvertes) + len(connues))
self.assertEqual(len(perdues), 2)
def test_a_merge_whose_target_gained_NOTHING_is_flagged(self):
"""Le cas qui compte : la carte dit où les données sont allées.
Si elles n'y sont pas, l'explication ne tient pas — et la classer
« attendue » puis passer à autre chose serait exactement l'erreur
que la carte devait empêcher.
"""
diff = self.perte(
"account_invoice",
651,
0,
cible="account_move",
cible_avant=1371,
cible_apres=1371,
)
connu = [x for x in diff["rows_lost"] if x[0] == "account_invoice"][0]
self.assertEqual(connu[3]["gained"], 0)
texte = "\n".join(quality.render_compare(diff, colour=False))
self.assertIn("gained nothing", texte)
def test_the_report_puts_the_unexplained_FIRST(self):
diff = quality.compare(
snapshot(
odoo="12.0", table={"ir_translation": 100, "ma_table": 50}
),
snapshot(odoo="18.0", table={}),
)
texte = "\n".join(quality.render_compare(diff, colour=False))
self.assertLess(texte.index("unexplained"), texte.index("moved or"))
def test_an_unknown_table_stays_unexplained(self):
diff = self.perte("ma_table_a_moi", 50, 0)
self.assertIsNone(diff["rows_lost"][0][3])
def test_every_entry_of_the_map_is_complete(self):
# Une entrée sans « why » expliquerait sans dire pourquoi.
for entree in quality.SEMANTIC_MAP:
for cle in ("since", "table", "into", "kind", "why"):
self.assertIn(cle, entree, entree)
self.assertTrue(entree["why"], entree)
self.assertIn(entree["kind"], ("merged", "renamed", "retired"))
def test_the_column_counts_only_what_needs_an_answer(self):
# Afficher 81 quand 14 sont des refontes voulues ferait fuir le
# lecteur du seul chiffre qui demande une réponse.
lst = [
snapshot(
odoo="12.0", table={"ir_translation": 100, "ma_table": 50}
),
snapshot(odoo="18.0", table={}),
]
self.assertEqual(qtui.rows(lst)[1]["detail"], "1")
class TestTheOverallReport(Base):
def test_it_compares_the_ENDS_not_the_sum_of_steps(self):
"""Un module retiré en 15 puis remis en 17 n'a rien perdu.