Skip to content

[20.0] 19→20 migration campaign (working branch) - #120

Open
dnplkndll wants to merge 97 commits into
20.0from
20.0-campaign
Open

dnplkndll wants to merge 97 commits into
20.0from
20.0-campaign

Conversation

@dnplkndll

@dnplkndll dnplkndll commented Sep 20, 2026 •

Copy link
Copy Markdown

Working branch for the fork-local 19→20 campaign: run -u all against the
585-module 19.0 seed, fix whatever stops it, repeat. Not for upstream. Stacks the
framework port, the analysis/apriori and the per-module scripts so the whole
thing can be run as one tree.

The seed migrates end to end: exit 0, Registry loaded in 918s, 563 modules
on 20.0, 0 migration-script errors, 2171 ir_access rows, no dangling
ir.model.access or ir.rule data rows.

Exit 0 was hiding six real defects, and there was a one-line detector for them

When migrated data does not satisfy a constraint the new version declares, Odoo
logs one INFO line and carries on. checklog-odoo only reacts to WARNING, so
nothing read them. The run that first exited 0 left twelve constraints
unapplied:

rows
res_partner_bank.account_number 118 / 118 NULL 20.0 renamed acc_number; every bank account read as having no number
resource_calendar_attendance duration 600 violating newly stored computed column, never backfilled, new CHECK wants > 0
hr_leave / hr_leave_allocation.work_entry_type_id 12 and 18 NULL hr.leave.type folded into hr.work.entry.type
pos_payment_method.type 8 / 8 NULL computed in 19.0, stored and required in 20.0
payment_method.provider_id 1 the last obsolete 19.0 method
6 others 0 resolved within the run; not defects

All six are fixed and the gate now passes on the full run: 118/118 bank
accounts have numbers, 1825/1825 attendances are in range, 8/8 POS methods are
typed, 12/18 leaves and allocations have a type, 48439/48439 payment methods have
a provider across all 117 companies.

The gate reads the schema, not the log

The log cannot be the verdict on its own, in both directions. The line is written
while the table is reshaped, before post- and end-migration run, so a script
that repairs the data cannot stop it being emitted. And a module that owns a
stricter rule replaces the one being attempted a moment later: account cannot
build its broad account_move_unique_name while LATAM vendor bills are present,
and then l10n_latam_invoice_document installs the narrower index that is the
real rule. Allowlisting on the log would have recorded that as a defect for ever
and blinded the check to a later regression — so one of the six items I set out
to fix turned out not to be a defect at all.

So the log says which constraints to look at and the schema says how each ended:
nulls are counted, and a named check constraint or unique index is looked up.
All three shapes Odoo reports are answerable, so the allowlist is empty. It
fails closed four ways, each verified: a log with no upgrade in it, an
unreachable database, a column that no longer exists, and an unrecognised
message all report rather than pass.

hr_holidays is the substantial one

hr.leave.type is gone; hr_holidays now _inherits hr.work.entry.type. Both
models existed in 19.0, so this is a merge, not a rename — it must not go in
apriori.renamed_models. 19.0 already recorded the pairing in
hr_leave_type.work_entry_type_id, honoured where unambiguous (46 of 76 link
uniquely, 12 share, 18 have none); two leave types cannot both keep one record,
so the oldest keeps the link and the rest get their own. count_as,
unit_of_measure and code have no counterpart and are mapped or, for code,
given a traceable placeholder that is counted in a message.

Not merge_models: nothing refers to hr.leave.type by name by then — Odoo has
already removed its ir_model row, and all nine dependent tables hold zero such
rows — and calling it fails rather than doing nothing.

Analysis coverage

317 of 362 analysed modules carry an upgrade_analysis_work.txt; the table
reads 129 Done / 477 Nothing to do / 0 Partial. 87 modules carry a migration
script and 16 carry a migration test.

Four gates over the migrated database, because exit 0 is not a verdict

Every defect found in this campaign exited 0 and looked green. Each gate reads
the analysis for where to look and the database for what happened, and each
was proved red on a real historical defect before being trusted:

gate what it catches
check_unapplied_constraints.py a constraint the upgrade logged and carried on without
check_selection_values.py a stored selection holding a key 20.0 no longer declares
check_rename_pairs.py a rename migrated as a drop plus a create — including, now, one whose type changes (user_id → user_ids)
check_defaulted_columns.py new: a NEW column the upgrade filled with ONE value across rows that differ

The last one exists because the rename gate needs a DEL/NEW pair, and four of
this campaign's five defects had no DEL sibling at all
. A column default is
evaluated once when the ORM creates the column, so one distinct value across a
populated table is the fingerprint of stamping rather than carrying. It is scoped
to dates and many2ones on purpose: 178 new columns came out uniform, 152 of them
non-falsy, and a gate needing 152 reviewed reasons is one whose allowlist becomes
boilerplate. On its first run it found a real defect nobody had looked at
(loyalty.history.points_changed_date, the FIFO expiry key).

test_gates.py covers the gates' own predicates over constructed facts — no
database, so CI runs it before the upgrade. It is the only exercise that logic
ever gets, and a gate whose predicate inverts reports the wrong verdict for every
migration proven afterwards rather than failing loudly.

Defects this branch fixes, and how each was proved

Each was shown to fail against the database migrated before the fix, which is
stronger than mutating the test:

  • pos_restaurant — six columns became one floor_plan_layout JSON; 27
    tables holding 25 distinct positions all came out NULL.
  • pos_event — default=lambda: str(uuid4()) is evaluated once, so 41 rows
    across two tables shared ONE uuid, which is the POS sync identity.
  • fleet — date → date_from carries 19.0's help text and a today
    default, so every service log moved to the day of the upgrade.
  • sale — expense_policy → reinvoice_policy; 197 templates collapsed to
    one value. Renamed in pre-migration because the new field is a stored compute.
  • resource — schedule_type → calendar_type, read off each version's own
    _is_flexible rather than the labels.
  • maintenance — completion lived on maintenance.stage.done, and the
    assignee moved many2one → many2many.
  • loyalty — every history line dated the moment the upgrade ran; restored
    from create_date, 7 distinct timestamps in place of 1.
  • account — 19.0's Reviewed flag; only the deviation is carried, because
    mapping the rest would assert a review of 3206 posted invoices.
  • stock_account — documented, NOT fixed: adjustment reads 0 for the whole
    valuation history and cannot be recovered, since 19.0 recorded only the delta.

~206 "empty is the right value" annotations were audited against the data,
and 23 corrected: the boilerplate claimed the default was falsy where it was
14, 'login', '9', 'full', 20.0 hours and True. There is no NULL on
those rows; the ORM wrote the default.

Depends on ledoent/openupgradelib#4, which this branch pins: three separate 20.0
breakages, the last of which stops merge_models running at all.

AI-assisted (Claude Code); every change reviewed, tested, and owned by the author.

@dnplkndll
dnplkndll changed the base branch from 19.0 to 20.0 September 21, 2026 00:25
@dnplkndll
dnplkndll force-pushed the 20.0-campaign branch 11 times, most recently from 3feb380 to 904b268 Compare September 21, 2026 02:51
Generated output of upgrade_analysis for 19.0 -> 20.0, covering 362 core
modules: the per-module upgrade_analysis.txt, the general log, and the
noupdate_changes.xml where data records moved.

Produced the way OCA's bot produces it -- a 19.0 instance and a 20.0 instance
with the same module set, upgrade.generate.record.wizard run on each to fill
upgrade_record, then upgrade.analysis comparing the two over odoorpc. Both
sides built without demo data: the analysis is a registry diff, and a full
reinstall with demo crashes on a purchase_order_stock_picking_rel foreign key.

Fork-local. Not for upstream.

Assisted-by: Claude Opus 5
docsource/modules190-200.rst: the coverage table for 611 modules, 45 of them
marked |del| as gone in 20.0. Generated by generate_module_coverage_file, which
only runs when module_coverage_file_folder is set.

apriori.py rewritten for this cycle. Every merged_modules entry is backed by the
analysis reporting stored fields as "previously in module <old>" under exactly
one new owner, not by inspection of names:

  account_add_gln -> account, account_peppol_response -> account_peppol,
  delivery_stock_picking_batch -> stock_delivery, hr_homeworking/hr_hourly_cost/
  hr_org_chart -> hr, l10n_dk_nemhandel(+_response) -> l10n_dk,
  l10n_pl_bank_verification -> l10n_pl, l10n_ro_cpv_code -> l10n_ro_edi,
  l10n_ro_edi_stock_batch -> l10n_ro_edi_stock,
  l10n_tr_nilvera_einvoice_extended -> l10n_tr, pos_restaurant_adyen ->
  pos_adyen, stock_picking_batch -> stock, website_sale_comparison and
  website_sale_wishlist -> website_sale.

base_vat is mapped to base rather than to account: the analysis only shows
res.company.vat_check_vies moving to account, while every check_vat_* method
now lives in base/models/res_partner.py.

website_sale_autocomplete -> website_address_autocomplete is a rename, not a
merge, so it sits in renamed_modules.

Attributions that came only through res.partner.invoice_edi_format are left
out. That selection is contributed by every l10n EDI module, so it named
l10n_dk as the new owner of l10n_tr_nilvera's fields, which is an artefact.

renamed_models is still empty: 75 models go obsolete and 105 appear, and
pairing them is a judgement call that drives real behaviour, so the candidates
are parked in docs/19-to-20-model-delta.md until each one is settled.

Assisted-by: Claude Opus 5
Every patch in odoo_patch wraps its target via _original_method and forwards
*args/**kwargs, so the 20.0 core rewrites of unlink() and load_module_graph()
need no adaptation -- load_module_graph dropping its models_to_check parameter
is absorbed by the forwarding. All patch targets still resolve on 20.0:
BaseModel.unlink, ModuleGraph._update_from_database, MigrationManager._get_files,
IrModel._drop_table, IrModelFields._drop_column, IrModelRelation.
_module_data_uninstall, the four IrUiView methods, IrModule.update_list and
check_external_dependencies, plus MODULE_UNINSTALL_FLAG, models.MAGIC_COLUMNS
and registry._force_upgrade_scripts.

Dropped the 18->19 ir_model_fields.translate normalisation from the
ModuleGraph patch. It rewrote translate='false' to NULL while the column was
being converted from boolean to varchar. On a 19.0 source the column is already
varchar holding NULL/standard/html_translate/xml_translate, and the selection is
byte-identical in 20.0, so the statement matches nothing and no longer describes
a real migration step.

Assisted-by: Claude Opus 5
…gistry loads

20.0 refuses _button_immediate_function on an unloaded registry and points at
button_install instead. Core still reaches for it from code that runs during an
install: hr's res_config_settings.set_values installs hr_attendance, and every
installed module's set_values chains into hr's, so any post_init_hook executing
a res.config.settings brings the operation down once hr is present. Seen from
l10n_mx._enable_group_uom_post_init and stock_fleet._enable_dispatch_management
-- excluding one module only moves the crash to the next.

Doing what the error message asks keeps the module operation, which is all the
caller wanted.

Assisted-by: Claude Opus 5
…dels

mail.activity._get_phone_numbers_by_activity resolves self.env[res_model] for
every distinct model among the activities it is given. That holds at runtime,
where every installed module is in the registry, but not during a major
upgrade: activities belonging to a module that has not been loaded yet are
already in the table, so the lookup raises KeyError and stops the upgrade. A
single mail.activity on event.event ends a 19->20 run while mail is being
updated and event has not been reached.

Filters those activities out before delegating. mail is not a dependency of
this module, so the patch is skipped when mail is absent.

Assisted-by: Claude Opus 5
Working state of the fork-local campaign: run the upgrade against the
585-module 19.0 seed, fix what stops it, repeat. Eight runs so far, each
carrying the load several hundred lines further.

base: clean_transient_models, the ir.model.fields.index boolean-to-selection
conversion, and update_module_names over apriori.

mail: claim the administrator's membership of the admin channel, which 20.0
now ships an xml_id for and 19.0 created through autosubscribe, so the data
load updates the row instead of tripping
discuss_channel_member_partner_unique.

account: re-point stock_account.action_report_stock_valuation at account. The
client action keeps its unique path, so account's copy has to land on the
existing row.

hr: rename hr.contract.type to hr.employee.type, model and table. The existing
xml_ids still name the old model, so hr's data raises KeyError on a model the
registry no longer has.

apriori gains the two model renames behind those last two scripts. Both are
evidence-backed: each appears in the analysis as an obsolete model paired with
a new one, and the collision that motivated the script confirms the pairing.

Assisted-by: Claude Opus 5
mass_mailing: release mailing_list_merge_action. 19.0 shipped it as an
ir.actions.act_window, 20.0 ships the same external id as an
ir.actions.server, and the loader will not bind an id to a record of another
model. Only the ir_model_data row goes; the orphaned window action is left for
database_cleanup.

hr_work_entry: rename the nine country-neutral work entry types to their
generic_ names. Without it the data load inserts a second country-less record
per code and _check_code_unicity rejects it.

l10n_tr: rename l10n_tr_nilvera_einvoice_extended.tax.office to
l10n_tr.tax.office, model and table. res.partner.l10n_tr_tax_office_id comes
along and its foreign key is rebuilt against l10n_tr_tax_office, which fails on
existing partner rows unless the table moves too.

apriori gains that third model rename.

Log length per run: 2305, 4671, 5092, 5608.

Assisted-by: Claude Opus 5
…pgrade

account is inconsistent about which mapping it uses. _load_translations picks
the companies to work on with _get_chart_template_mapping(get_all=True), while
_parse_csv resolves those same codes against the default visible-only mapping
and subscripts the result. A company on a template 20.0 stopped offering passes
the filter and then fails the lookup.

19.0's plain "fr" is one of them: 20.0 keeps it as the parent of the visible
French variants, so an existing FR company ends the load with "'NoneType'
object is not subscriptable" after every module has already migrated -- the
last thing standing between a 585-module database and a finished upgrade.

A database being upgraded has whatever templates it has, and they all need to
resolve. account is not a dependency here, so the patch is skipped when absent.

Assisted-by: Claude Opus 5
Runs 12-21. The upgrade now finishes: registry loads in 562s, every installed
module reports a 20.0 version, and the resulting database serves.

uom field rename, 12 fields across mrp, product, purchase,
purchase_requisition, repair and stock. 20.0 drops the product_ prefix. Derived
from the analysis by pairing DEL and NEW relations to uom.uom per model rather
than by reading names.

crm_iap_mine: the industry list is replaced wholesale, 23 entries becoming 83
with renumbered ids. Matching both csv files by name, Real Estate is the only
one carried over and the only one that collides.

l10n_th: claim the four withholding tax tags. 19.0 created them implicitly from
the tax template csv, which names tags without giving them ids, so the rows
exist with no ir_model_data entry and 20.0's explicit records collide.

sale: product.template.sale_delay becomes company_dependent, which in 20.0
means jsonb on the table rather than an ir.property row. Postgres will not cast
integer to jsonb, so the value is parked under a legacy column in pre-migration
and written back as one entry per company in post-migration. Also releases
action_accrued_revenue_entry_sale_order_line, which changed from act_window to
server action -- a sweep of every ir.actions.server id in the 20.0 source found
it to be the last one in that state.

apriori grows to 45 entries. The 23 added here had no field of their own left
to trace, so each is matched to the module carrying its feature in 20.0 and
confirmed to exist there. That takes modules stuck in "to upgrade" from 27 to
4: delivery_mondialrelay, website_sale_mondialrelay, transifex and
purchase_requisition_sale have no successor at all.

purchase_requisition_sale -> purchase_alternative_sale is deliberately absent.
It reads like a rename and is not one: applying it leaves the upgrade building
a foreign key on purchase_alternative_warning_id, a column nothing creates.

Assisted-by: Claude Opus 5
ruff-format on the framework patches and the generated purchase_requisition
script, plus trailing whitespace in the analysis output and the coverage table.

Assisted-by: Claude Opus 5
Status column filled from what is actually in the tree, not by hand:

  Done (57)           the 16 modules with a hand-written script, the 41 |del|
                      rows that are apriori merge sources (base's pre-migration
                      folds their data into the surviving module, so they have
                      to stay registered), and website_address_autocomplete,
                      which needs -u after base renames its ir_module_module row
  Partial (345)       analysed by upgrade_analysis, no reviewed Python yet
  Nothing to do (203) no 20.0 script directory at all -- no DB layout delta
  blank (4)           delivery_mondialrelay, website_sale_mondialrelay,
                      transifex and purchase_requisition_sale have no successor

Partial rather than Done for the 345 is deliberate. CI's grep does not
distinguish the three words, so the choice is purely what we claim, and calling
machine-analysed work reviewed would be false.

This takes the job from one module to 580 old / 564 new.

Assisted-by: Claude Opus 5
--test-tags openupgrade has matched nothing on this branch since the 19.0
series content was dropped, and Odoo exits 0 when a tag matches no tests --
load_modules guards on "if suite.countTestCases()". Zero tests run and all
tests passing are indistinguishable from outside, so the migration job proved
only that the upgrade does not raise.

Nine assertions over base's largest script. The snippet plants records in 19.0
through the old shell; the tests read them back after the upgrade:

- the four perm_ booleans concatenate in c,r,u,d order, with perm_write
  becoming "u" -- "ru" and "cd" are asserted because non-adjacent letters and
  the write/update spelling are what a reader would get wrong
- external ids survive, which is the whole reason the script renames the table
  instead of copying rows
- a three-group rule fans out to exactly three rows, one per group, each
  carrying the domain byte for byte, and exactly one of them keeps the
  external id -- asserted by counting, not by picking a row, because the
  script maps min(id) and which row that is follows join order
- a rule with no groups becomes one row with an empty group_id
- an unnamed rule is labelled with its model
- an access line granting nothing is dropped along with its external id
- an archived line survives archived, searched with active_test=False so the
  assertion cannot pass by finding nothing
- nothing is left pointing at ir.model.access or ir.rule

Every method opens by asserting its records exist. The snippet runs in a
separate process and would exit 0 having written nothing if its commit were
lost, so an assertion whose empty case is a pass would report success for a
migration that never happened.

Verified against the seed: 9 tests, 0 failed. Then verified they can fail --
flipping the expected operation from "ru" to "ur" gives exactly one failure,
the mutated test, with the other eight still passing.

There is deliberately no rule granting no permissions: 19.0 carries a CHECK
constraint, ir_rule_no_access_rights, forbidding one. ir_model_access has no
equivalent, which is why the access line case is real and needs handling.

Assisted-by: Claude Opus 5
release_xmlid moves into openupgrade_scripts/helpers.py, importable from any
script the way apriori already is. mass_mailing and sale carried the same
eight-line DELETE for the same reason: 19.0 shipped an action as an
ir.actions.act_window and 20.0 ships the id as an ir.actions.server, so the id
has to be freed before the loader will bind it. openupgradelib has nothing for
this -- delete_records_safely_by_xml_id removes the record itself and needs the
model in the registry, which is wrong for a pre step.

base no longer re-points ir_model_data.model by hand. rename_models already
does it: it walks the many2one references, and on a 19.0 schema
get_many2one_references falls back to a static list whose first entry is
("ir.model.data", "res_id", "model", ""). The hand-written update logged
"0 rows affected" every run, and its comment -- that rename_models leaves the
column alone -- was simply wrong. It is also why hr and l10n_tr rename their
models without such an update and come out clean.

The test fixtures file their external ids under a module that does not exist.
_process_end deletes data rows belonging to modules it has just loaded, so ids
claiming to be base's were removed at the end of every run; the tests still
passed, being at_install, but nothing survived to inspect afterwards. They now
survive, and the fan-out is visible in the finished database.

Verified: full migration, 9 tests, 0 failed, registry loaded in 678s, and
__ou19__.ou19_access_ru and __ou19__.ou19_rule_multigroup both resolve to
ir.access records afterwards.

Assisted-by: Claude Opus 5
The 362 analysis files were generated against an empty apriori. It now carries
40 merged modules and one rename, and upgrade_analysis reads both to attribute
records that changed hands. Re-running moves 22 files.

Two kinds of change. Records that used to be reported as anomalies are gone,
because apriori explains them -- "previously in module account_add_gln",
"model account.peppol.response (moved from account_peppol_response)" and so on
for every absorbed module. And the surviving module now owns what it absorbed,
which is why l10n_ro_edi, l10n_cn and l10n_tr grow: l10n_ro_cpv_code,
l10n_cn_city and the l10n_tr_nilvera family fold into them, so their records
belong to the receiving module's analysis, which is where a migration script
for them would have to live.

The one renamed_models entry earns its place too:

  -new model account.stock.valuation.report [abstract]
  +new model account.stock.valuation.report (renamed from
   stock_account.stock.valuation.report in module stock_account) [abstract]

docsource/modules190-200.rst is deliberately not part of this.
generate_module_coverage_file rewrites the table from scratch with a blank
Status column, so running the analysis after marking it silently undoes the
marking. The row sets are identical -- 610 either way -- so the marked copy is
kept. Worth knowing: with the guard added earlier this now fails the migration
job loudly instead of quietly migrating nothing.

Assisted-by: Claude Opus 5
The analysis generated 73 noupdate_changes.xml files and nothing loaded any of
them, so the upgrade ran green while silently delivering none of the 781 record
updates they hold. noupdate records are skipped by a module update by
definition, so these only reach an existing database through an explicit
openupgrade.load_data call.

payment and payment_demo need a transformation file. payment_method_ids was a
many2many in 19.0 and is a one2many in 20.0, so the analyser's eval="None" no
longer clears a relation table but unlinks the provider's payment methods, which
core refuses outright.

Assisted-by: Claude Opus 5
Every line in these is either the ir.model.access/ir.rule fold into ir.access,
which base does globally, or a record whose noupdate flag switched without its
definition changing. The three that also carry a definition change are marked
against the load_data added for them.

Assisted-by: Claude Opus 5
…od-copy hits

The four 20.0 gates against the sanitised prod copy erp_mig20test reported five
between renames-20 and defaults-20. All five were triaged against the data and
none is data loss.

check_rename_pairs: the acknowledgement key was the full label, which embeds the
module attribution and the type token. The same field pair arrives under
different ones depending on which module's analysis file it was read from --
res.partner.peppol_eas -> routing_scheme comes through as
`account_edi_ubl_cii:... (selection)` from one and `account_peppol:... (False)`
from another. The maintainer had already adjudicated that pair; the key simply
stopped matching, and the gate failed the run on a settled question. Match on
the field pair, keep module and type in the label because they read well.

Plus one new entry: resource.calendar.tz -> calendar_type are different fields
(tz holds timezones, calendar_type holds 'fixed' -- 20.0's new fixed/flexible
mode, correct for every calendar predating the concept).

check_defaulted_columns: three acknowledgements where one value really is right.
res.partner.bank.country_id and clearing_label_id are uniform because all 19
accounts are US, not because they were stamped. hr.employee.first_contract_date
equals each employee's create_date and the migration ran months later, so it is
carried, not stamped -- the uniform-date heuristic misreads a 2-row table whose
employees were created the same day.

All four gates now pass against erp_mig20test.
…stamped

Suggested by the odoo-19-to-20-migration session after the prod-copy triage, and
it generalises the acknowledgement it replaces.

RULE A flags a date/datetime holding one value across rows that differ, on the
reasoning that a column default is evaluated once when the column is created. The
corollary was never encoded: that stamp necessarily lands on the day the upgrade
ran. A uniform value months earlier is therefore evidence AGAINST stamping -- it
came from somewhere -- and any small table whose rows genuinely share a date reads
as a defect without it.

The migration's date is the newest ir_module_module.write_date, since every module
is written during -u all.

Proved on erp_mig20test by removing the hr.employee.first_contract_date
acknowledgement: defaults-20 still passes, the rule clearing it on its own
(2026-03-05 held vs a 2026-09-28 migration). The acknowledgement is kept because
it records the reasoning about hr_contract being absent, which the rule does not.
The two defects only our own data could show -- res.device.log losing the
user agent 20.0 requires, and the account/base pair -- lived on 20.0-prodcopy
alone. Campaign is the tree the lab bind-mounts, so it has to carry them.

# Conflicts:
#	.github/scripts/check_defaulted_columns.py
19.0's crm_menu_root carried action="crm.action_your_pipeline", an
ir.actions.server. 20.0 redeclares the same menuitem with no action and drops
action_your_pipeline entirely -- My Pipeline moved down to
menu_crm_opportunities on crm.crm_lead_action_pipeline. A menuitem redeclared
without an action does not clear one, so the upgrade deletes the action and
leaves ir_ui_menu.action naming it.

Not cosmetic, and not confined to CRM. Resolving the reference raises "Record
does not exist or has been deleted", and website.layout does exactly that, so
every frontend page answers 500 for a logged-in session -- /web/login included.
Nobody can sign in. An anonymous GET of the same URL returns 200, which is why
a health check would have called the database fine.

Measured on a 19.0 production copy: exactly one stale row, and the whole site
down. Read through SQL rather than menu.action, because the ORM resolves the
reference into a recordset and there is nothing left to inspect -- and
resolving a dangling one is the raise this prevents.

crm was marked "Nothing to do"; it isn't.
20.0 moved _get_visitor_from_request from website.visitor to ir.http. The
module update rewrites the views it owns, so website.contactus comes through
correct -- but it cannot rewrite a COW copy. When a page is edited Odoo
duplicates the view with website_id set and no ir_model_data row, and a record
no xmlid points at is invisible to every module update thereafter.

So the copy keeps the 19.0 call, and it is the copy that gets served: /contactus
answers 500 with "'website.visitor' object has no attribute
'_get_visitor_from_request'". The public Contact Us page is down and nothing in
the log says why until you request it.

Measured on a 19.0 production copy: one COW view of eleven carried the call.
The OCA seed has no COW views at all, which is why only real data could show it.
… existence

The guard added in aca417b asked "does the action this menu names still
exist?" and returned early when it did. That condition is never true at the
moment it is tested and never false afterwards, so the fix was inert and the
defect it exists to prevent came straight back on the next prod-copy run.

Odoo deletes obsolete ir_model_data records in one sweep AFTER every migration
stage — post-migration and end-migration alike — so the target is always still
present while any script can see it, and always gone by the time anything
renders a menu. Measured on the 2026-10-01 prod-copy run:

    15:18:13  crm post-migration runs, sees action 501, returns
    15:18:58  every end-migration script runs (still too early)
    15:19:41  Deleting 501@ir.actions.server (crm.action_your_pipeline)
    15:20:03  Modules loaded

Result: ir_ui_menu 355 (crm.crm_menu_root) left naming ir.actions.server,501
after the upgrade. website.layout resolves menu actions for a logged-in
session, so every frontend page 500s and nobody can sign in, while an
anonymous GET still answers 200.

So the test is not "is it dangling yet" but "is it the record 20.0 removes".
This resolves crm.action_your_pipeline by xmlid — still resolvable here, for
the same reason the row is still present — and clears the menu only when that
is what it names, leaving an installation that points its CRM root menu
somewhere else alone. The old existence check is kept as a second case so a
re-run, or a database that lost the action earlier, is still repaired.

Verified by re-running the full prod-copy migration: the script now fires
("1 rows affected ... UPDATE ir_ui_menu SET action = NULL") and the database
ends with 427 menus whose actions all resolve.

Assisted-by: Claude Opus 5
19.0 shipped account.journal_group_comp_rule, a global rule on
account.journal.group filtering by company_id. 20.0 removes company_id from
that model outright -- it has no company field at all now -- and deletes the
rule with it, keeping only three plain group rows in ir.access.csv.

The rule survives the upgrade, and then every non-superuser search on
account.journal.group dies in domain optimisation with KeyError: 'company_id'.
Core searches that model while building an ordinary view:
account.move.line._get_view does `env['account.journal.group'].search([])` to
add the Ledger filters to the SEARCH view. So loading any account.move.line
search view raises for a normal user. On a production copy this presented as
mis_builder's report preview 500ing, purely because mis_builder was what
opened the view first -- the fault is nowhere near mis_builder.

The part worth remembering: NOTHING cleans this up on its own. The
ir.model.access + ir.rule -> ir.access conversion writes its ir_model_data rows
with noupdate=true, and _process_end's obsolete-record sweep selects
`COALESCE(noupdate, false) != true` (odoo/addons/base/models/ir_model.py:2732).
It skips noupdate records by design. So an obsolete SECURITY record can never be
removed by the normal mechanism and has to be deleted by a script.

Keyed on the field, not the id: a database that somehow still has company_id on
that model has a meaningful rule, and is left alone.

A sweep of every ir.access domain in the migrated production copy, parsing each
domain structurally so values are never mistaken for field names, found this as
the only stale field reference.

Assisted-by: Claude Opus 5
19.0 declared most ir.rule records inside <data noupdate="1">; 20.0 ships the
same xml_ids as rows of security/ir.access.csv, which loads updatable.
_convert_rules re-points the existing ir_model_data row instead of letting the
CSV create one, so the noupdate flag is inherited and the loader then declines
to write 20.0's definition over it.

Measured on a production copy: 271 ir.access rows flagged noupdate that the
installed 20.0 source ships updatable, 22 of them still carrying a 19.0 domain.
Most are silent -- hr.hr_employee_comp_rule, calendar_event_rule_private and
sale.sale_order_line_rule_portal decide who may read what, and they were
deciding it by 19.0's rules. One is not silent at all:
project.project_task_rule_portal filtered on project.collaborator.limited_access,
which 20.0 replaced with access_mode, so reading project.task as a portal user
raised "Invalid field project.collaborator.limited_access in condition" and
project sharing was simply broken. Nothing in the upgrade reported any of it.

The flag is cleared only where the owning module ships the xml_id in an
ir.access.csv, i.e. where 20.0 has itself declared the record updatable; rules
20.0 still declares noupdate, and rules a user created, are left alone.

Assisted-by: Claude Opus 5
20.0 stops shipping payment_custom's own redirect template and points the two
providers it declares at payment.generic_redirect_form; noupdate_changes.xml
carries that over, but only for records that have an xml_id. payment.provider
rows are routinely duplicated by hand, and a production copy here had a third
`custom` provider with no xml_id still referencing the old view.

redirect_form_view_id has a database foreign key, so the obsolete-record sweep
cannot drop the view while that row points at it. The upgrade does not fail:
openupgrade_framework patches the unlink to log "Could not delete obsolete
record ... of model ir.ui.view" and carry on, so the run ends green with a view
20.0 no longer maintains still rendering the provider's payment form. The next
ordinary -u payment_custom retries the same delete without that patch loaded
and stops dead on ForeignKeyViolation.

Assisted-by: Claude Opus 5
…he data exists

Burns the 20.0 verdict debt from 202 to 158, and records what was FOUND rather
than that somebody looked. Nine of the 44 are SCRIPT NEEDED -- real carries the
upgrade currently drops.

The ones measured against erp_mig20test (the sanitised prod copy):

* hr.version.tz is the sharpest. 19.0 had it related='employee_id.tz', so the
  value lived in resource_resource.tz; 20.0 inverts the relation, stores it
  required with a 'UTC' default, and makes hr.employee.tz related to the
  version. The upgrade took the default: both versions hold 'UTC' while their
  resources hold 'America/New_York', and _get_tz() reads self.tz first, so the
  wrong zone reaches every working-hours and attendance calculation.

* point_of_sale carries three in one config row. 20.0 cuts POS invoices from
  journal_id, which still holds the 19.0 closing journal (POSS, 0 moves) while
  every one of the 88 POS invoices sits in invoice_journal_id's journal (INV).
  closing_journal_id points at a journal the migration itself created on
  2026-10-01. And session_closing_mode defaulted to 'daily', which arms a 20.0
  cron to post session accounting at 04:00 on sessions nobody closed.

website_sale is NOT re-measured and says so: the module is uninstalled in the
copy and the seed the bootstrap measured is gone, so those verdicts rest on
19.0/20.0 source plus the bootstrap's recorded counts.

Also records which of the verdict-debt modules the copy can even speak for: 18
of them are uninstalled there, so a verdict for those cannot cite rows.

Assisted-by: Claude Opus 5
Debt 158 -> 120. Six more SCRIPT NEEDED, two of them measured on real rows
rather than argued from source.

* base:res.partner.bank.acc_holder_name is the worst of the set, because it
  OVERWRITES rather than drops. 20.0's _compute_account_holder_name filled
  holder_name with partner_id.name for every row during the upgrade, so a
  holder deliberately different from the partner is replaced: on this copy 4 of
  the 13 accounts that held a legacy value now read differently -- "Ledo
  Enterprises LLC" became "Ledo Enterprises", and the joint holder "Don Kendall
  and Aimee Kendall" became "Kendall Family Budget" on two accounts.

* account:account.move.line.deductible_percentage is a scale change nothing
  converts. 19.0's deductible_amount was 0-100 (its consumer divides by 100);
  20.0's deductible_percentage is a 0-1 fraction with a `> 1` constraint. The
  DEL/NEW pair means the new column took its default. Left alone a half-private
  expense claims full VAT deductibility. This copy cannot show it -- both
  columns are uniformly at their defaults, 100 and 1.0, which agree by
  coincidence and are exactly why it would pass unnoticed.

* base also loses the whole bank record: 20.0 deletes res.bank and inlines
  name/bic/address onto res.partner.bank, and nothing carried them -- every
  such column is empty on all 19 accounts while the orphaned res_bank table
  still holds values 17 accounts could use. bank_id survives, which is the only
  reason it is recoverable.

Plus account.report.active (archived reports silently un-archive, because 20.0
reads active_fallback and the upgrade defaulted it True) and the GLN carry into
additional_identifiers.

Records where the copy cannot speak: the chart-of-accounts and report side is
far smaller than the counts this file was bootstrapped against (356 accounts, 4
reports, 0 report lines vs 50192 / 187 / 6225), and several base counts differ
too, so some mechanical markers above are stale.

Assisted-by: Claude Opus 5
Two of the four defects the verdict pass measured on the sanitised prod copy.
Both show the same shape: 20.0 turns a derived value into a stored one, the
upgrade fills the new column with a default, and the result is plausible enough
that nothing looks wrong.

base: acc_holder_name -> holder_name joins the two renames already in
_renamed_fields on this model. 20.0's _compute_account_holder_name is `if not
account.holder_name: account.holder_name = account.partner_id.name`, so it
never overwrites -- it fills an EMPTY column, which is what an unrenamed
holder_name is. 13 of the copy's 19 accounts carried a holder and 4 disagreed
with what the compute wrote: "Ledo Enterprises LLC" showed as "Ledo
Enterprises", and the joint holder "Don Kendall and Aimee Kendall" showed as
"Kendall Family Budget" on two accounts. The rename is the whole fix -- the
value arrives and the compute's own guard then skips those rows.

hr: the tz relation inverted. 19.0 had hr.version.tz related to
employee_id.tz, which is resource.mixin's related to resource_id.tz, so the
zone lived in resource_resource. 20.0 stores it on the version, required,
defaulting to UTC, and makes the employee read FROM the version. The upgrade
took the default: both versions came out UTC while their resources held
America/New_York. _get_tz() reads self.tz first, so the wrong zone reaches
every working-hours and attendance calculation. post-migration now carries it
from the resource, touches only rows still on the bare UTC default, and
reports versions whose resource names no zone.

Both fixtures plant a value that differs from what the default would produce --
a holder unlike its partner, a zone that is not UTC -- so neither assertion can
pass on the computed value.

Assisted-by: Claude Opus 5
Completes Tier 1 -- the four defects measured on the sanitised prod copy.

account: deductible_amount was a 0-100 percentage whose consumer divided by
100 (19.0 account_move.py:1731); 20.0's deductible_percentage is a 0-1
fraction constrained `< 0 or > 1`. The analysis reports a DEL/NEW pair, not a
rename, so the new column took its default and the 19.0 figure stayed behind.
A line marked 50% deductible reads as fully deductible and 20.0 claims the
whole VAT on a mixed-use expense. Only purchase documents are rescaled, since
20.0 forbids deductibility elsewhere, and a stray value on a sales document is
reported rather than written into a move that could then not be saved.

point_of_sale: the two journals swapped jobs and the precompute invented one.
19.0's journal_id was the session CLOSING journal and invoice_journal_id was
where POS invoices were cut; 20.0 reverses both. Nothing moved the values, so
after the upgrade invoices come from the old closing journal -- POSS, 0 moves,
while all 88 POS invoices sit in INV -- and closing_journal_id points at a POSC
journal _ensure_company_closing_journal created during the migration run
itself. Written as one statement because each half reads what the other
overwrites, and guarded: 20.0 narrowed the closing journal to type 'sale'
where 19.0 also allowed 'general', so a config with no legal home is reported
instead.

Three more from the same module: session_closing_mode back to 'closing' (the
'daily' default arms a cron to post session accounting unattended at 04:00),
pos.payment.currency_rate carried from the order (was an unstored related, now
a stored column nothing fills), and pos.session.move_id re-pointed into
account_move.pos_session_sales_id, which drives _compute_always_tax_exigible.

Every fixture plants a value the default would not produce -- 50 against a
default of 100/1.0, two journals deliberately different from each other -- so
no assertion can pass on the default.

Assisted-by: Claude Opus 5
…ptom

Both were found by running the migration test suite against the 585-module
19.0 seed rather than reasoning about it, and both had passed review.

hr: the timezone carry was guarded on `v.tz = 'UTC'`, which is only the value
the sanitised prod copy happened to produce -- 20.0's default is
`context tz or user tz or 'UTC'`, so the column is filled with the timezone of
whoever ran the upgrade. On the seed that is Europe/Brussels on all 31
versions, the guard matched nothing, and the carry silently did nothing on any
database whose admin has a timezone set. There is no 19.0 value to protect
either: hr.version.tz was a non-stored related field with no column at all, so
every timezone now in that table was invented by the default seconds earlier.
The write is therefore unconditional wherever the resource names a zone.

Its companion test was equally vacuous -- it searched for versions left on
'UTC' -- so it went green while nothing was carried. Both tests now assert a
disagreement with the resource, which is the actual defect.

crm: the script claimed a dangling menu action makes "EVERY frontend page 500,
/web/login included". Probing it (planting a nonexistent action id and calling
each candidate) gives a different and narrower answer: reading menu.action
through the ORM raises nothing, because Reference browses the id without
checking it. load_web_menus is what raises MissingError, and its two callers
are the backend home controller -- so /odoo is dead for every internal user --
and website.layout's frontend-to-backend app switcher, which sits inside
`groups="base.group_user"`, so anonymous visitors are unaffected and
/web/login is not website.layout at all.

The symptom test rendered website.layout with an empty values dict, which can
never pass: the template's first statement is `lang.replace('_', '-')` and it
also wants main_object, so it failed for a reason unrelated to the migration.
It now calls load_web_menus, the verified raise.

Assisted-by: Claude Opus 5
… the calendar timezone

Tier 2 -- values the 19->20 upgrade drops in silence. All three verified
against the 585-module seed, each with a fixture that plants a value no
default can produce.

base: 20.0 deletes res.bank and inlines bank_name, bank_bic and the address
onto res.partner.bank. Nothing carried them -- every column is empty on all 19
accounts while the orphaned res_bank table still holds the values and the
bank_id column still joins to them, so this is recoverable only until someone
runs database_cleanup. country_id is the exception that makes it more than a
copy: it is a stored precompute whose body reads `partner_id.country_id or
company_id.country_id or env.company.country_id`, yet it sits in the inlined
bank-address block, so a bank abroad acquires the account holder's country.
The bank's own country wins where it named one. email, phone and active have
no destination at all in 20.0 and are reported rather than forced somewhere.

base: company_registry becomes the additional_identifiers Json. Filing
everything under the generic 'OTHER' key would be lossless and invisible --
_compute_available_additional_identifiers_metadata pops that key for any
country offering another EN-category identifier, and 38 of the 39 are
country-scoped, so the value would appear nowhere on the form. The country's
own key is therefore preferred, but only when the value fits it:
@api.constrains('additional_identifiers') revalidates every key with
validation='error', so free text filed under FR_SIREN would leave the partner
permanently unsavable. Each candidate is probed with the non-raising
_validate_identifier first, and a value fitting nothing falls back to 'OTHER'
and is reported. Hidden beats unsavable, and both beat dropped.

resource: resource.calendar.tz is dropped and the timezone becomes
res.company.tz, one per company, defaulting from the company's country -- so a
database whose calendars disagreed silently changes what a working day means,
with no error, because the column is populated either way. The default
calendar's zone wins; the tally was ordered by count first, which would let
three calendars in one zone outvote the calendar the company's own records are
actually computed against. The test plants exactly that arrangement, so it
fails if the ordering is ever restored.

Assisted-by: Claude Opus 5
…timezone

The rule landed as "the default calendar's zone wins", on the reasoning that it
is the calendar the company's own records are computed against. Walking
resource_booking on a migrated production copy showed the other half of it.

That company's default calendar said UTC and the two calendars actually driving
bookings said US/Eastern, so the default won and every booking type's hours were
read five hours out. UTC there is not a decision: resource.calendar.tz was
required with default `self.env.user.tz or 'UTC'`, so a UTC default calendar is
one created by a user who had no timezone set. Preferring it propagates the
unset value over calendars that do name a zone -- and those two were
resource-less, which is the case where the company zone is the only thing that
says what the hours mean. The default calendar's own resources are unaffected
either way, since they carry resource.resource.tz and core prefers it whenever a
resource is in play.

So the default still wins, unless it is UTC and another zone is named. The
existing test keeps the first half honest (a majority in one zone must not
outvote a deliberate default); a second fixture plants the measured arrangement
and keeps this half honest. Either rule alone passes one of them and fails the
other.

Assisted-by: Claude Opus 5
Two more carries, verified against the 585-module seed.

hr.job.user_id becomes recruiter_id: the same field retyped, res.users ->
hr.employee, with both versions labelling it "Recruiter" and carrying the same
help text word for word. The analysis reports a DEL and a NEW, so nothing was
carried. The new column is not empty either -- recruiter_id defaults to
`self.env.user.employee_id`, so adding it stamped every job with the employee
of whoever ran the upgrade, which is the same trap the hr.version.tz guard fell
into. The mapping is therefore written wherever it resolves, matched inside the
job's own company to satisfy check_company, and a recruiter with no employee
record there is reported because 20.0 cannot express it at all.

hr.version's departure_reason_id, departure_description and departure_date move
onto the new hr.employee.departure, with hr.version reading all three through
departure_id. The columns survive but go invisible to the ORM, so a departure
recorded in 19.0 reads as blank.

The rows are inserted directly rather than through create(), which does two
things a migration must not. It rewrites the employee's contract --
`contract_date_end = departure.departure_date` on the matching version
(hr_employee_departure.py:124-131) -- overwriting what 19.0 recorded. And it
leaves apply_date NULL, which arms the daily cron: _cron_apply_departure selects
`apply_date = False` with a past departure_date and calls action_register(),
archiving the employee AND their res.users (:134-142). Every historical
departure carried would archive a user account within a day of the upgrade. The
rows are written as already applied, which is true -- 19.0's own wizard applied
them years ago -- and a test asserts no past departure is left queued.

Only versions carrying a departure_date are carried: 20.0 requires both
dismissal_date and departure_reason_id, and a date cannot be invented for a
record whose purpose is to say when somebody left. A version with a reason or a
note but no date is reported instead.

Assisted-by: Claude Opus 5
account_add_gln is absorbed into account, which redeclares
global_location_number as a NON-STORED compute returning
_get_additional_identifier('EAN_GLN') (partner.py:677-679). The 19.0 column
survives, so nothing fails -- the value simply stops being read. The Json key is
written directly rather than through _set_additional_identifier, which validates
with ean.validate and raises on a bad check digit; one legacy typo would abort
the whole upgrade. Each value is probed with the non-raising
_validate_identifier first, and a failure is reported and left in the legacy
column, because @api.constrains('additional_identifiers') revalidates every key
on write -- a bad GLN filed there would block every later save of the partner
rather than merely being unmigrated.

account.report.active stops being stored: 20.0 computes it from active_fallback
and active_selection, and names the carrier in its own loader error ("'active'
field of account.report shouldn't be directly written to in data files. Use
active_fallback."). active_fallback defaults to True, so a report an
administrator archived comes back into every report menu. Only the False side is
carried; True is what the default produces anyway.

Both fixtures identify their records by external id rather than by name. Every
account.report in the seed is core module data, so account's data load rewrites
`name` during the upgrade and a fixture rename does not survive -- which is how
the first version of the test reported "the fixture's report is gone" against a
migration that had in fact worked. `active` is not rewritten, precisely because
20.0's loader refuses to write it from a data file.

Assisted-by: Claude Opus 5
19.0 baked one table class into each report layout template; 20.0 lifts it out
into res.company.report_tables_id, whose 'light' default silently restyles the
documents of every company that printed with anything else.

The mapping is read off 19.0's own classes (web/views/report_templates.xml) and
inverted through 20.0's only consumer of the field, external_layout_body
(:447-453): striped/boxed/bold/standard keep their own class, folder and wave
both used o_table_striped, and bubble used o_table_boxed-rounded. Every 19.0
layout therefore maps to a 20.0 key.

It has to be resolved in PRE-migration, and not because the new column is
missing. 20.0 ships neither external_layout_striped, external_layout_boxed nor
external_layout_bold, so web's data load deletes those view records and every
company that printed with one is left with external_report_layout_id NULL -- by
post-migration there is nothing to map. The resolved key is kept in a legacy
column rather than the view id, which would no longer resolve to an xmlid
either. The fixture plants 'bold' for exactly that reason.

Post-migration's guard is a comparison, not a NULL check: report_tables_id
declares a default, so adding the column stamped every row -- the same shape as
hr.version.tz and hr.job.recruiter_id, where guarding on emptiness means doing
nothing at all.

Assisted-by: Claude Opus 5
… reference price

Three settings 20.0 re-defaults, none of which fails anything.

contact_us_button_url becomes contact_us_link_url. Both versions declare
default="/contactus", so the new column is created already holding it and a site
pointing at /help or an external form reads "/contactus" while its own value
sits in a column 20.0 never looks at. A pre-migration rename carries the value
and the jsonb translations both versions store.

The three category display flags move from product.public.category to website.
The ORM wrote show_category_description's default True and left the other two
NULL, a falsy default not being written at all, so a shop that unticked "Show
Description" gets descriptions back and one that ticked "Show Title" loses
titles. A flag is carried onto a website only where every category that website
shows agrees on it -- its own plus the global ones, which is 19.0's own
_get_available_category_domain -- and the disagreements are REPORTED rather than
resolved, because one website-wide flag cannot answer for categories that
differ and claiming a value nobody chose is worse than saying so. A NULL legacy
column is read as that field's 19.0 default, which is what the field returned
for a row nobody ever wrote.

show_product_reference_price: 19.0 showed the $/kg price whenever the feature
group was enabled, with no per-website dimension at all; 20.0 keeps the group
AND requires a per-website Boolean that has no default, so the price disappears
from every shop that was showing it. The group is read through core's own
_is_feature_enabled rather than by inspecting implied_ids, and every website
gets the flag -- that is the per-website spelling of "on everywhere".

Verified against the 585-module seed: three websites carried, one disagreement
reported, three switched back to showing the reference price.

Assisted-by: Claude Opus 5
The UTC-default case created a res.company, and Odoo copies every per-company
record when a company is created -- including the payment.provider rows the
payment fixture had just renamed. Fixtures are planted in module order, so
payment runs first and resource's new company duplicated its two providers,
failing payment's own test with "2 != 1: ou19-provider-live" in a module this
branch had not touched.

A second EXISTING company with a default working schedule exercises the same
case: its default calendar is set to UTC and two calendars naming
America/New_York are added beside it. Nothing about the rule under test needed a
company that did not already exist.

Assisted-by: Claude Opus 5
Every remaining "# SCRIPT NEEDED" marker across the 20.0 analysis work files now
has a script behind it, so each is rewritten as "# DONE" naming the function and
the measured outcome on the 585-module seed. 23 markers over account, base, hr,
point_of_sale and website_sale; none is left.

Three website_sale annotations were stale rather than missing. website's
align_category_content and show_category_title both read "NOTHING TO DO: empty
on all 3 rows", which was true of the analysis and is no longer true of the
database -- the per-website aggregation writes all three flags. The category-side
align_category_content had no annotation at all, its alphabetical neighbour
having absorbed one.

Assisted-by: Claude Opus 5
… not reproduce

A 19->20 run over the sanitised prod copy confirms the substance and refutes the
number. POSS does hold 0 moves, and INV -- the journal invoice_journal_id named
-- holds 508, so an unfixed upgrade really does cut POS invoices into a journal
with no history. But the copy carries no pos.order rows at all, so "all 88 POS
invoices to date sit in INV" is not a figure this data can produce, and the
argument is restated to rest on which journal is which.

The invented closing journal is confirmed; only its id was run-specific, so the
docstring no longer names one.

Assisted-by: Claude Opus 5
…e date

Clears the verdict debt for both modules: 19 undecided lines, 3 scripts, 16
NOTHING TO DO each backed by a count rather than an argument. Verified on the
585-module seed and measured against a real 19.0 production copy and its
migrated result.

Two settings became company fields and nothing carried them. 19.0 kept the
Shipping Policy and the Invoicing Policy as ir.default rows -- for
sale.order.picking_policy and product.template.invoice_policy -- and 20.0 adds
res.company.picking_policy (required, default 'direct') and
res.company.sale_invoice_policy (required, default "order"). So a company that
chose "When all products are ready" starts shipping partial orders, and one
invoicing on delivered quantities starts defaulting new products to ordered
ones. Both carries read the company-specific ir.default before the global one,
which is the order 19.0 resolved them in.

Neither is visible on the production copy: its ir.defaults are "direct" and
"order", the same values 20.0 defaults to. The two agreeing by coincidence is
exactly what would let this ship unnoticed, so the fixture plants the opposite
pair and the tests assert for them.

stock.move.line.date becomes `related="move_id.date", store=True`. The column
already exists, so the upgrade recomputes nothing and 65 of the seed's 122 lines
keep a date their move does not -- until the next write to the move overwrites
them silently. They are aligned here instead, with the count reported, because
that date was real information in 19.0 and the report is the only record left of
it.

The 16 others are closed on measurement: stock.move.reference and
sale.order.document_tax_mode fill themselves (the latter from
company.account_price_include, with both values present on the seed and 0 of 58
orders disagreeing with their own company); product.template.tracking was
already handled by _clear_tracking_none; location_final_id and
utm.campaign.company_id have no destination in 20.0 and stay in their orphaned
columns; customer_lead's float-to-integer narrowing would round, but no row on
either database holds a fractional value.

Assisted-by: Claude Opus 5
Clears the verdict debt for website, product and mail: 18 undecided lines, one
script, 17 closed on measurement.

website.menu.url stops being stored and becomes a compute:

    menu.url = (menu.page_id.url if menu.page_id else menu.manual_url) or "#"

A page-backed menu is fine -- the compute reads the page. A menu whose address
was typed in has nothing to read, because 20.0 keeps that in the new manual_url
and nothing fills it, so the expression falls through to "#". The link stays in
the navigation bar and goes nowhere. On the 585-module seed that is 48 of 53
menus with no page, manual_url empty on all 53, and the orphaned column still
holding /blog, /event and the rest: every website's navigation turned into dead
links. post-migration carries the value across, 39 menus on the seed.

'#' is deliberately not carried -- the compute returns it anyway for a mega menu
or one with children, so writing it would add rows that change nothing a visitor
sees, and the test asserts that too.

One menu stays out of reach and it is core's doing, not a gap here.
website.menu_home is the template menu, no website_id and no page, and 20.0's
data load puts it through _inverse_url, which writes manual_url = '' when the
url it is handed is falsy; its write_date is the upgrade run itself. The live
per-website Home menus are page-backed and resolve through website.page. The
global assertion therefore scopes itself to menus that are not module data --
46 of the seed's 55 -- which is a limit worth naming rather than hiding.

The 17 others close on counts, not arguments. ir.ui.view.visibility was NULL on
all 3552 views of the prod copy and 20.0's required 'public' default fills only
NULLs, so nothing is widened. website.page.name and .url became translatable and
the ORM widened the columns with the values intact. product.pricelist.item
.applied_on is recomputed from references that survive -- verified against the
matched pair, 1_product on template 31 before and after, rather than argued.
product.product.qty_available is still store=False and never had a column.
website_sale's base_unit_id comodel rename is undeclared in apriori and would
need rename_models on a database that used base units; both tables hold 0 rows
here, which is stated rather than glossed. mail's four new selections fill
themselves; its two dropped res.users.settings fields are interface state with
nowhere to go in 20.0.

Assisted-by: Claude Opus 5
…t prod modules

Takes every module installed in production to zero verdict debt: 22 lines across
ten modules, one script, 21 closed on counts.

project.collaborator.limited_access becomes access_mode -- view / edit /
advanced_edit -- required and defaulting to 'view', so the upgrade demotes every
collaborator to a reader. That is granted rights lost rather than cosmetics: a
collaborator row existed only because a project was shared for EDITING, and a
read-only share created none at all. After the upgrade those portal users can
open the project and change nothing.

The mapping is taken from the same method in both versions rather than from the
labels, which is what makes it defensible:

    19.0  def _add_collaborators(self, partners, limited_access=False)
    20.0  def _add_collaborators(self, partners, access_mode='advanced_edit')

The default argument of one is the default argument of the other, so False is
advanced_edit; the remaining True is 'edit', which is what 20.0 tests for
editing rights (project_task.py:2281) while reserving advanced_edit for the
capability it gates at :1130. Nothing maps to 'view'.

The test asserts row by row against each collaborator's own legacy boolean
rather than against a count. The seed carries a collaborator besides the
fixture's two -- counting them would have tested the seed, and the first version
of this test did exactly that and failed on the third row.

The 21 others close on measurement. resource.calendar.attendance.day_period was
already handled by _reclassify_break_attendances and .name has no destination at
all (12 distinct labels across the copy's 60 rows, kept in the orphaned column);
resource.resource.calendar_id's new NOT NULL applies over data that already
satisfies it. calendar.calendar and calendar.user are new 20.0 models whose rows
20.0 writes itself. purchase.order.document_tax_mode fills from
company.account_price_include exactly as sale.order's does, with 0 of 18 orders
disagreeing with their own company, and non_deductible_tax is a stored compute
the upgrade evaluated. account.move.checked is the column
_carry_a_deliberate_not_reviewed_into_the_review_queue reads, so the retained
value is the point rather than a loss. crm_iap_mine's reveal_ids held an IAP
vendor's own industry codes that only that service can interpret, 3 of 83 rows,
and 20.0 offers nowhere to put them.

Assisted-by: Claude Opus 5
…column

20.0 renamed the link an invoice uses to find its timesheets:

  19.0  account.move.timesheet_ids = One2many(..., "timesheet_invoice_id")
  20.0  account.move.timesheet_ids = One2many(..., "reinvoice_move_id")

The analysis cannot see that as a rename, because the halves belong to
different modules: the DEL is reported under sale_timesheet, the NEW
reinvoice_move_id under sale. Nothing correlates them, so the old column was
preserved untouched and the new one created empty.

Both sides had been annotated NOTHING TO DO, and both annotations said the same
thing -- the column is empty across all 531 rows. That was true of the SEED,
where no timesheet had ever been invoiced. It is not a property of the field.
The first real database carried 33, and every one of them came out of the
migration with its invoice link gone.

The consequence is worse than a blank column. _is_line_reinvoicable consults
this link to decide a timesheet has already been invoiced
(sale_timesheet/models/sale_order_line.py:168), so a line that lost it becomes
billable a second time -- on a company that bills from timesheets.

Verified end to end on a sanitised copy of production: 33 rows carrying the old
link before, 33 carrying the new one after, 0 left behind and 0 pointing at a
different move.

The lesson is recorded in both verdict files rather than just fixed: a verdict
of "the column held nothing" is only ever as good as the data it was measured
on.
… upgrade

20.0 renames product.template.expense_policy to reinvoice_policy -- the upgrade
log reads "table product_template, column expense_policy: renaming to
reinvoice_policy" -- so every row keeps the value it had. What changes is the
MEANING.

On 19.0 the field only governed re-invoicing costs. On 20.0 it additionally
gates whether an invoice links its analytic lines at all: _is_line_reinvoicable
requires it to be something other than 'no' (sale/models/sale_order_line.py).

A delivered-timesheet product sensibly carried 'no' on 19.0 -- it re-invoices
no expenses -- and that same 'no' now stops it linking timesheets. The invoice
posts claiming none, anything reading account.move.timesheet_ids sees an empty
set, and the hours keep counting as still-to-invoice because
_timesheet_compute_delivered_quantity_domain treats a NULL reinvoice_move_id as
not yet billed. Nothing appears in the log.

So products that were ALREADY billing from timesheets get 'sales_price' --
invoice_policy 'delivery' with service_type 'timesheet', both stored on both
versions -- and only where the column still holds 'no', so a deliberate choice
is never overwritten. That is the value core's own sale_timesheet tests set for
exactly this case.

It is a real change to a field that also feeds sale_expense, sale_stock and
sale_purchase re-invoicing, which is why the predicate is this narrow: on the
production copy it touches exactly two templates, Consulting Hours and Service
on Timesheets, and leaves the other four service products alone.

Verified end to end on a migrated copy of production: Consulting Hours comes out
'sales_price', a new sale order with a logged timesheet produces a draft invoice
carrying timesheet_ids=1, and posting it generates the timesheet backup PDF
(122 KB) showing the hours, the activity and the time range. Without the carry
that invoice links nothing and no document is produced.

Both verdicts in sale/20.0.1.2 are corrected rather than just the behaviour:
the DEL was explained as "nothing was left behind" when the data had in fact
been renamed across, and the NEW as "20.0 filled it itself ... a compute did the
work" when it is full because the rename carried it.
20.0 dropped `url` from core's `website.menu_home` record, because the field is
computed from `page_id` now. The generator emitted it **empty**, and loading an
empty url runs `_inverse_url`, whose first branch is
`if not menu.url: menu.page_id = None`. Home then computed to "#": a navigation
entry pointing nowhere, on a record no 19.0 database has any problem with.
Measured on a production copy -- `page_id` 1 on 19.0, NULL after.

Core is not the cause and cannot be: its own `menu_home` sits inside a
`<data noupdate="1">` block and is never rewritten on upgrade. The previous
docstring here blamed core's data load; it was this script.

`test_no_menu_was_left_pointing_nowhere` excluded module-data menus, and that
exclusion was covering exactly this row -- with the field restored, both it and
the new `test_the_home_menu_keeps_its_page` fail. An exclusion that happens to
hide the one broken record is indistinguishable from no assertion, so it is
gone.

Assisted-by: Claude Opus 5
19.0's template fetcher reads Domain('favorite', '=', True); 20.0's
action_fetch_templates reads Domain('is_template', '=', True). Same method,
same job, renamed field -- but the analysis reports the halves as unrelated
DEL and NEW lines, and this was annotated NOTHING TO DO on a two-row sample
where favorite happened to be false. A production copy with 35 mailings, one
favourited, came out with favorite still true and is_template false.

Signed-off-by: Don Kendall <dkendall@ledoweb.com>
Assisted-by: Claude Code
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant