Compare commits

26 Commits

Author SHA1 Message Date
Stéphan Sainléger
e06d750b74 [PERF] finalize_db: purge `ir.ui.view` images in a single pass
The purge ran one correlated ``NOT EXISTS`` scan of ``ir_ui_view`` per
candidate attachment (``ILIKE '%web/image/<id>%'`` on ``arch_db::text``),
taking hours on large databases. It now scans ``ir_ui_view`` once with
``regexp_matches``, builds a deduplicated set of referenced ids, and
deletes orphans via hash lookups.

This also fixes substring false positives: ``/web/image/12`` was kept
because it matched ``/web/image/123``. Integer comparison (``ref_id =
a.id``) now requires an exact match.
2026-08-13 11:25:51 +02:00
Stéphan Sainléger
0c096bb5ff [FIX] pre_upgrade: guard `theme_ir_ui_view` cleanup with to_regclass
The table ``theme_ir_ui_view`` only exists when the ``theme`` (website)
module was installed in the source database. If it was never installed,
the cleanup block crashes with "relation does not exist". Add a
``to_regclass`` guard to skip silently instead.
2026-08-11 22:50:51 +02:00
Stéphan Sainléger
1d75a0f9db [CLN] finalize_db: purge `ir.ui.view images orphaned and unreferenced in any arch_db`
Delete image attachments linked to deleted ``ir.ui.view`` records only when
no other view's HTML references them via ``/web/image/<id>`` or
``/web/content/<id>``.  This avoids the aggressive approach of deleting all
``res_id = 0`` attachments, which can break website images that survived
their parent view's deletion but are still embedded in live pages.

The ``NOT EXISTS`` subquery scans all ``arch_db`` columns and is expensive
but runs once at migration time.
2026-08-11 09:52:51 +02:00
Stéphan Sainléger
134b935fa4 [ADD] finalize_db: run `_gc_file_store_unsafe` after attachment cleanup
Add ``lib/python/file_gc.py``, a minimal Odoo-shell script that calls
``ir.attachment._gc_file_store_unsafe()`` to remove filestore files
no longer referenced by any ``ir_attachment`` record.

Invoke it in ``finalize_db.sh`` after the SQL attachment purges so
that the deleted CSS/JS asset bundles, resized partner thumbnails,
and orphaned ``ir_attachment`` rows also have their backing files
cleaned from disk.
2026-08-10 17:07:08 +02:00
Stéphan Sainléger
b6fd05104a [CLN] finalize_db: purge resized partner images and orphaned `ir_attachment`
Delete the four thumbnail sizes (``image_128``, ``image_256``,
``image_512``, ``image_1024``) from ``ir_attachment`` for
``res.partner`` records.  Odoo 13+ regenerates all thumbnails
automatically from ``image_1920`` on the next partner write.

Also delete attachments whose ``res_model`` is ``NULL`` (orphaned
``factur-x.xml``, SCSS, ICS files) — these have no associated record
and consume ~48 MB of filestore space.
2026-08-10 17:06:34 +02:00
Stéphan Sainléger
8cb2fe5fd2 [CLN] finalize_db: purge `ir_logging`
Truncate the ``ir_logging`` table which accumulates Python logger
messages (63 MB of ``INFO``-level ``ir.actions.server`` traces) with
no business value after migration.
2026-08-10 17:06:14 +02:00
Stéphan Sainléger
7c680b17a4 [REF] pre_upgrade: rename `account_factoring to account_factoring_oca` for 18.0
``account_factoring`` (depends on ``account_payment_partner`` +
``account_payment_mode``) is replaced by ``account_factoring_oca``
(depends on ``account_payment_base_oca`` +
``account_payment_base_oca_sale``) in v18. Both modules share rigorously
identical stored fields on ``account_journal``, ``account_move`` and
``res_partner``, so a rename (not an uninstall) preserves the 4910
invoice factor statuses, 311 partner credit limits and journal factor
config; OpenUpgrade then treats ``account_factoring_oca`` as already
installed and adopts the existing columns via ORM ``_auto_init``.

Redirects dependencies pointing to the old name, drops the obsolete
``account_payment_partner``/``account_payment_mode`` dependencies and
inserts the new OCA equivalents. Must run AFTER the bank-payment
renaming block which creates ``account_payment_base_oca`` in the DB.
The orphaned ``base.module_account_factoring`` xmlid is renamed so
OpenUpgrade can rediscover the physical ``account_factoring`` addon dir
without conflicting on ``(base, module_account_factoring)``.
2026-08-07 10:36:12 +02:00
Stéphan Sainléger
803ae858ea [REM] pre_upgrade: delete obsolete `contract_payment_mode` before 18.0 load
``contract_payment_mode`` v18 still depends on ``account_payment_partner``
(removed by the bank-payment renaming) and uses the obsolete
``account.payment.mode`` model; no v18 replacement provides contract
integration, and only 2 contracts carried a ``payment_mode_id``.

Must run BEFORE the bank-payment renaming and BEFORE OpenUpgrade's
``button_upgrade()`` which would otherwise crash on the missing
dependency.

The module is DELETED (module row + ``ir_model_data`` + dependencies)
rather than marked 'to remove', because Odoo's "Transient module states
were reset" in ``load_modules()`` converts 'to remove' -> 'installed',
making ``button_upgrade()`` re-parse the missing
``account_payment_partner`` dependency and abort the registry load. The
orphaned ``base.module_contract_payment_mode`` xmlid is renamed so
``update_list()`` rediscovers the physical addon as a fresh uninstalled
module that ``button_upgrade()`` never touches.
2026-08-07 10:35:59 +02:00
Stéphan Sainléger
6890d19524 [FIX] pre_upgrade: drop orphan `theme.ir.ui.view` records before 17.0 load
The migrated 16.0 database still holds ``theme_ir_ui_view`` rows whose
``inherit_id`` points to ``ir.ui.view`` ids removed by the 17.0 asset
bundle rework (``web._assets_utils``, ``website.assets_editor``,
``website._assets_frontend_helpers`` replaced by the ``ir.asset`` model).

When ``website._theme_load`` runs during the registry load,
``ThemeView._convert_to_base_model`` accesses ``inherit.website_id`` on
the ghost record and raises ``MissingError``, aborting the whole
registry build.

Delete the ``ir_model_data`` + ``theme_ir_ui_view`` rows whose
``inherit_id`` targets a missing ``ir.ui.view``. Generic (covers any
theme), idempotent, scoped to ``theme_ir_ui_view`` only; no
``ir.ui.view`` copies exist for the affected templates.
2026-08-07 10:35:34 +02:00
Stéphan Sainléger
bc3907e1d8 [FIX] l10n_fr_account: recreate missing property-account xml-ids in 18.0
The core script ``l10n_fr_account/migrations/2.1/end-migrate_update_taxes.py``
calls ``account.chart.template.try_loading('fr', company)``. During
``_post_load_data``, Odoo 18 resolves the French template's default accounts via
``self.ref()`` WITHOUT ``raise_if_not_found=False`` (chart_template.py:711/714
and the ``_get_property_accounts`` loop at :746). If any expected
``account.<company>_<key>`` xml-id is missing, it raises
``ValueError: External ID not found`` and aborts the registry load.

This happens when a database customized its chart of accounts: the generic PCG
account was deleted (e.g. 607000) or recreated without its xml-id (e.g. 707000).

Add a generic ORM ``post-migration`` script that, for every company with a
``chart_template``, reads the expected account xml-ids from the template API
(``_get_property_accounts`` / ``_get_chart_template_data`` /
``_get_account_account``) and recreates any missing ``account.<company>_<key>``
``ir.model.data`` pointing to the best matching account: exact code, else the
company account sharing the longest common code prefix, else a non-blocking
warning. It runs at stage ``post`` (before the native ``end`` script) and only
creates missing xml-ids — no account is modified. Idempotent, generic across
all migrated FR databases.

Remove the previous hardcoded 607/707 SQL block from
``versions/18.0/pre_upgrade.sh`` now superseded by this script.
2026-07-22 16:14:36 +02:00
Stéphan Sainléger
b8e3168eaf [FIX] project: drop `project_list` act_window_view before 17.0 migration
``project_list`` (OCA) is not ported to 17.0 and is merged into core: in 17.0
the core ``project`` module natively adds the kanban/tree views to the
"Projects" actions (``act_window_id`` 149 & 760), and 17.0 introduces the
constraint ``act_window_view_unique_mode_per_action = unique(act_window_id,
view_mode)``.

When core reloads ``project_project_views.xml`` it INSERTs its own
``(act_window_id, view_mode='kanban')`` rows, colliding with the rows still
owned by ``project_list`` and aborting the registry load with a
``UniqueViolation`` on ``act_window_view_unique_mode_per_action``.

Delete ``project_list``'s ``ir.actions.act_window.view`` records (and their
``ir_model_data``) in ``versions/17.0/pre_upgrade.sh`` so core can recreate its
canonical rows. Verified against the 17.0 OpenUpgrade scripts: none reference
``project_list`` xml-ids nor remap these records, so this is safe. Scoped to
``ir.actions.act_window.view`` only, idempotent.
2026-07-15 23:15:06 +02:00
Stéphan Sainléger
a3bd1bbbe8 [REF] l10n_fr_account: host custom migration scripts in a dedicated `--upgrade-path`
Custom migration scripts for a core module used to require copying the
whole module into ``versions/18.0/addons/`` so its ``migrations/`` folder
would be picked up. Because the migration container mounts that copy with
priority (``--addons-path=mig,addons``) and ``get_module_path`` returns the
first match, the copy fully shadowed the core module — dragging along its
entire data/models just to carry one script.

Instead, inject the scripts through a dedicated OpenUpgrade ``upgrade_path``:

- New ``versions/18.0/scripts/l10n_fr_account/18.0.2.2/`` holding
  ``pre-migration-0k-fix-orphan-expressions.py`` (converted to the
  ``@openupgrade.migrate()`` / ``migrate(env, version)`` convention) and
  ``post-migration-0k-vat-box25.py`` + ``noupdate_changes.xml`` that reapply
  the box 25 VAT-report customization via ``openupgrade.load_data``.
- ``config/compose.yml``: mount it as ``/opt/odoo/auto/upgrade`` for ou18.
- ``versions/18.0/upgrade.sh``: pass
  ``--upgrade-path=/opt/odoo/auto/upgrade,/opt/odoo/auto/addons/openupgrade_scripts/scripts``.
  Odoo appends every path to ``odoo.upgrade.__path__`` and concatenates the
  per-version scripts, so our scripts run IN ADDITION to the native ones;
  the native path is listed explicitly because ``--upgrade-path`` overrides
  ``config['upgrade_path']`` which ``openupgrade_framework`` only sets when empty.

Files are named with a ``-0k-`` marker so they never collide with the native
``pre-migration.py`` / ``post-migration.py``. This removes the need for the
shadowing module copy entirely.

Also ignore ``__pycache__``/``*.pyc``.
2026-07-14 00:24:54 +02:00
Stéphan Sainléger
718b367e86 [ADD] various: deduplicate soon-to-be-unique fields before migration
Some target versions add a ``UNIQUE`` constraint on a field that allowed
duplicates in the source version (e.g. ``utm.source.name`` in 16.0). The
unique index creation then aborts the whole registry load with a
``psycopg2.errors.UniqueViolation``.

Declare such constraints per version in ``known_changes.yaml`` under the
``new_unique_constraints`` key (only ``model`` and ``fields`` needed).
During ``prepare_db.sh``, ``dedup_unique_constraints.py`` aggregates the
declarations of every traversed version and, on the source database,
renames duplicates by suffixing `` [<id>]`` while preserving foreign keys.

The actual PostgreSQL column type is used (not the ORM ``translate``
attribute) so both ``varchar`` and already-converted ``jsonb`` columns are
handled: ``jsonb`` duplicates are compared and renamed on every language
key (indexed ``en_US`` value). Renamed records are reported in a
timestamped JSON file and summarized at the end of ``migration.log``.
2026-07-13 15:23:26 +02:00
Stéphan Sainléger
781407fe4c [FIX] finalize_db: detach `ir_filters pointing to deleted ir_actions`
OpenUpgrade migrations can delete and recreate ``ir_actions`` records with
new ids, leaving ``ir_filters.action_id`` (custom user filters) pointing to
a now-missing action. Opening the custom filters list then fails with
"Record does not exist or has been deleted (ir.actions.actions(<id>,))"
when Odoo resolves the ``action_id`` many2one ``display_name``.

Detach such filters (set ``action_id = NULL``) as a final, version-agnostic
cleanup step: the filter stays usable, only the broken action link is
dropped. Placed in ``finalize_db.sh`` so the check runs whatever the target
version, with an integrated re-count that warns if orphans remain.
2026-07-07 22:24:17 +02:00
Stéphan Sainléger
9dd66f1b0a [FIX] finalize_db: only reset `to upgrade modules, not to install`
The pre-shell state reset in ``scripts/finalize_db.sh`` forced both
``to upgrade`` and ``to install`` modules to ``installed``. Forcing
``to install`` to ``installed`` makes Odoo skip their install scripts
entirely (tables, ``noupdate`` data, init hooks never run), leaving
ghost "installed but empty" modules that the final ``-u all`` cannot
recover.

Restrict the reset to ``to upgrade`` only, whose real update is honored
by the controlled ``-u all`` at the end of the script. Also drop the
dead ``NOT IN (... state = 'uninstalled')`` sub-query: ``name`` is
unique so a module never has two states, making the filter a no-op.

A commented-out ``SELECT`` is added to trace pending-upgrade modules if
the trailing ``-u all`` is ever removed.
2026-07-07 22:24:17 +02:00
Stéphan Sainléger
085abb02cb [FIX] remove useless and destructive website template cleaning 2026-07-07 22:24:17 +02:00
Stéphan Sainléger
1a8fb4f659 [IMP] add project_deadline in "merged in core" modules in 15.0 2026-07-07 22:24:17 +02:00
Stéphan Sainléger
9af147b113 [IMP] add migration log file
to store all the migration logs. File reset at each migration.
2026-07-07 22:24:17 +02:00
Stéphan Sainléger
e57241e789 [IMP] add --resume-from option
to resume the migration process from a step wehre the database and
filestore are correct.
2026-07-07 22:24:17 +02:00
Stéphan Sainléger
a2450faf93 [IMP] add hr_expense_report_merge_attachment in "merged in core" modules in 18.0 2026-07-07 22:24:17 +02:00
Stéphan Sainléger
3b81052ca6 [IMP] delete res.settings views in 18.0 pre-update to rebuild them latter 2026-07-07 22:24:17 +02:00
Stéphan Sainléger
231ec1e9b7 [IMP] manage the sequence issues in stock picking and pos orders in v18 post-upgrade 2026-07-07 22:24:17 +02:00
Stéphan Sainléger
d7d7904a36 [IMP] detect missing pos.order hashes and regenerate them 2026-07-07 22:24:17 +02:00
Stéphan Sainléger
1c88a2d6af [IMP] add Odoo addons known changes 2026-07-07 22:24:17 +02:00
Stéphan Sainléger
4594d5971c [NEW] add detection of obsolete, Odoo core integrated or renamed addons 2026-07-07 22:24:17 +02:00
Stéphan Sainléger
bf022605d4 [IMP] add migrations script to use bank-payment-alternative addons in 18.0 2026-07-07 22:21:42 +02:00
17 changed files with 1550 additions and 8 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
final_404_addons
migration.log
__pycache__/
*.pyc

View File

@@ -39,10 +39,11 @@ cd 0k-odoo-upgrade
├── lib/
│ ├── common.sh # Shared bash functions
│ └── python/ # Python utility scripts
│ ├── check_views.py # View analysis (pre-migration)
│ ├── validate_views.py # View validation (post-migration)
│ ├── fix_duplicated_views.py # Fix duplicated views
── cleanup_modules.py # Obsolete module cleanup
│ ├── check_views.py # View analysis (pre-migration)
│ ├── validate_views.py # View validation (post-migration)
│ ├── fix_duplicated_views.py # Fix duplicated views
── dedup_unique_constraints.py # Dedup soon-to-be-unique fields (pre-migration)
│ └── cleanup_modules.py # Obsolete module cleanup
├── scripts/
│ ├── prepare_db.sh # Database preparation before migration
@@ -84,6 +85,8 @@ The script performs a **step-by-step migration** between each major version. For
3. **Database Preparation** (`scripts/prepare_db.sh`)
- Neutralization: disable mail servers and cron jobs
- Detection of installed modules missing in the target version
- Deduplication of fields that become `UNIQUE` in a traversed version
(see [Unique Constraints](#unique-constraints-new_unique_constraints))
- View state verification
- User confirmation prompt
@@ -356,6 +359,48 @@ Version scripts have access to functions defined in `lib/common.sh`:
| `log_info`, `log_warn`, `log_error` | Logging functions |
| `log_step "title"` | Display a section header |
### Unique Constraints (`new_unique_constraints`)
Some Odoo versions add a `UNIQUE` constraint on a field that previously allowed
duplicates. A well-known case is `utm.source.name` in 16.0. When the migration
rebuilds the model, PostgreSQL tries to create the unique index and aborts the
whole registry load if the legacy data contains duplicates:
```
psycopg2.errors.UniqueViolation: could not create unique index "utm_source_unique_name"
DETAIL: Key (name)=(Webinaire gouvernance (copie)) is duplicated.
```
To prevent this, declare the newly-introduced unique constraints in the
`known_changes.yaml` of the version that introduces them, under the
`new_unique_constraints` key. Only `model` and `fields` are needed — the table,
the column type and the translatable (jsonb) flag are resolved automatically
from the ORM:
```yaml
new_unique_constraints:
- model: utm.source
fields: [name]
- model: utm.medium
fields: [name]
- model: utm.campaign
fields: [name]
```
During `prepare_db.sh`, the `dedup_unique_constraints.py` checker aggregates the
declarations of every traversed version and, on the **source** database:
- detects duplicate groups (for translatable/jsonb fields it compares the
indexed `en_US` value, not the raw jsonb);
- keeps the record with the smallest `id` untouched and renames each other
duplicate by appending ` [<id>]` to every language key (jsonb) or to the
plain value (varchar) — all foreign keys are preserved;
- writes a timestamped report to
`/tmp/dedup_unique_constraints_<source_db>_<timestamp>.json`.
Renamed records are summarized at the end of `migration.log` (see the final
`UPGRADE PROCESS ENDED WITH SUCCESS` section).
### Adding a New Version
To add support for a new version (e.g., 19.0):
@@ -368,6 +413,8 @@ cp versions/18.0/*.sh versions/19.0/
# - Change references from ou18 → ou19
# - Change the port from -p 8018:8069 → -p 8019:8069
# - Add SQL fixes specific to this migration
# - Declare any new UNIQUE constraints in versions/19.0/known_changes.yaml
# under `new_unique_constraints` (see "Unique Constraints" above)
```
## Troubleshooting

View File

@@ -82,6 +82,9 @@ ou18:
- "--log-level=debug"
- "--limit-time-cpu=1000000"
- "--limit-time-real=1000000"
volumes:
- /home/stephan/dev/Odoo/0k-odoo-upgrade/versions/18.0/addons/:/opt/odoo/auto/mig:rw
- /home/stephan/dev/Odoo/0k-odoo-upgrade/versions/18.0/scripts/:/opt/odoo/auto/upgrade:rw
options:
workers: 0

View File

@@ -100,6 +100,27 @@ exec_python_script_in_odoo_shell() {
run_compose --debug run "$service_name" shell -d "$db_name" --no-http --stop-after-init < "$python_script"
}
# Same as exec_python_script_in_odoo_shell but prepends a Python preamble read
# from stdin (heredoc). Useful to inject environment values into the Odoo shell
# when the compose wrapper does not reliably propagate host env vars into the
# container. The preamble runs before the script body in the same interpreter.
#
# Usage:
# exec_python_script_in_odoo_shell_with_preamble svc db /path/script.py <<'PY'
# import os; os.environ['FOO'] = 'bar'
# PY
exec_python_script_in_odoo_shell_with_preamble() {
local service_name="$1"
local db_name="$2"
local python_script="$3"
local preamble
preamble="$(cat)"
{ printf '%s\n' "$preamble"; cat "$python_script"; } \
| run_compose --debug run "$service_name" shell -d "$db_name" --no-http --stop-after-init
}
# Classifies missing modules into 4 categories based on the known_changes.yaml
# files from each traversed version (from ORIGIN_VERSION+1 to FINAL_VERSION).
# The following global arrays are populated:
@@ -175,8 +196,50 @@ classify_missing_addons() {
done
}
# Aggregates the `new_unique_constraints` sections from the known_changes.yaml
# files of every traversed version (from ORIGIN_VERSION+1 to FINAL_VERSION) into
# a single JSON array printed on stdout, e.g.:
# [{"model":"utm.source","fields":["name"]},{"model":"utm.medium","fields":["name"]}]
# Duplicate (model, fields) entries are collapsed. Prints "[]" when none.
#
# Prerequisites: ORIGIN_VERSION and FINAL_VERSION must be exported.
collect_new_unique_constraints() {
local versions_path="${PROJECT_ROOT}/versions"
local v
local -a json_parts=()
local -A seen=()
for v in $(seq $((ORIGIN_VERSION + 1)) "$FINAL_VERSION"); do
local yaml_file="${versions_path}/${v}.0/known_changes.yaml"
[[ -f "$yaml_file" ]] || continue
local count
count=$(yq '.new_unique_constraints | length' "$yaml_file" 2>/dev/null)
[[ "$count" =~ ^[0-9]+$ && "$count" -gt 0 ]] || continue
local i
for ((i = 0; i < count; i++)); do
local model fields entry
model=$(yq -o=json ".new_unique_constraints[$i].model" "$yaml_file" 2>/dev/null)
fields=$(yq -o=json -I=0 ".new_unique_constraints[$i].fields" "$yaml_file" 2>/dev/null)
[[ -n "$model" && "$model" != "null" ]] || continue
[[ -n "$fields" && "$fields" != "null" ]] || continue
entry="{\"model\":${model},\"fields\":${fields}}"
if [[ -z "${seen[$entry]:-}" ]]; then
seen["$entry"]=1
json_parts+=("$entry")
fi
done
done
local IFS=','
printf '[%s]' "${json_parts[*]}"
}
export PROJECT_ROOT DATASTORE_PATH FILESTORE_SUBPATH
export -f log_info log_warn log_error log_step confirm_or_exit
export -f check_required_commands
export -f query_postgres_container copy_database copy_filestore run_compose exec_python_script_in_odoo_shell
export -f exec_python_script_in_odoo_shell_with_preamble
export -f classify_missing_addons
export -f collect_new_unique_constraints

View File

@@ -0,0 +1,304 @@
#!/usr/bin/env python3
"""
Pre-Migration Unique Constraint Deduplication Script for Odoo
Some Odoo versions introduce new UNIQUE constraints on fields that previously
allowed duplicates (e.g. utm.source.name in 16.0). When the migration rebuilds
the model, the unique index creation raises
`psycopg2.errors.UniqueViolation: could not create unique index ...`
and aborts the whole registry load.
This script runs BEFORE the migration loop, on the SOURCE database, and renames
duplicate records so the target-version unique index can be built successfully.
For each declared constraint (model + fields), it:
- resolves the table, column type and translatable flag from the ORM;
- detects duplicate groups (for translatable/jsonb fields the comparison is
done on the value actually indexed by Odoo, i.e. the `en_US` key);
- keeps the record with the smallest id untouched and renames every other
duplicate by appending ` [<id>]` to ALL language keys (jsonb) or to the
plain value (varchar), preserving all foreign keys.
Constraints to check are passed as JSON via the DEDUP_UNIQUE_CONSTRAINTS
environment variable, e.g.:
[{"model": "utm.source", "fields": ["name"]}, ...]
A machine-readable report of every rename is written to
/tmp/dedup_unique_constraints_<db>_<timestamp>.json when DEDUP_REPORT is set.
Usage:
compose run <service> shell -d <db> --no-http --stop-after-init \
< dedup_unique_constraints.py
Exit codes:
0 - Completed (duplicates, if any, were deduplicated)
2 - Technical failure (see traceback)
"""
import os
import sys
import json
from datetime import datetime
from collections import defaultdict
class Colors:
RED = '\033[91m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
BLUE = '\033[94m'
BOLD = '\033[1m'
END = '\033[0m'
def _c(color, text):
return f"{color}{text}{Colors.END}"
def load_constraints():
"""Load the constraints catalog from the environment."""
raw = os.environ.get('DEDUP_UNIQUE_CONSTRAINTS', '').strip()
if not raw:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"DEDUP_UNIQUE_CONSTRAINTS is not valid JSON: {exc}")
if not isinstance(data, list):
raise ValueError("DEDUP_UNIQUE_CONSTRAINTS must be a JSON list")
return data
def resolve_constraint(env, spec):
"""Resolve a {model, fields} spec into concrete DB metadata.
Returns a dict with table, list of (field_name, column, is_jsonb) or
None if the model/fields are not present in this database (e.g. module
not installed in the source version).
"""
model_name = spec.get('model')
field_names = spec.get('fields') or []
if not model_name or not field_names:
return None
if model_name not in env:
return None
model = env[model_name]
table = model._table
# The physical table must exist in the source DB.
env.cr.execute("SELECT to_regclass(%s)", (table,))
if not env.cr.fetchone()[0]:
return None
columns = []
for fname in field_names:
field = model._fields.get(fname)
if field is None:
return None
column = field.name
# Column must physically exist (skip non-stored/computed fields).
# We rely on the ACTUAL PostgreSQL column type, not the ORM `translate`
# attribute: in the source version a translatable field may still be a
# plain `character varying` column (it only becomes `jsonb` during the
# migration to the version that introduces translation storage).
env.cr.execute(
"""
SELECT data_type FROM information_schema.columns
WHERE table_name = %s AND column_name = %s
""",
(table, column),
)
row = env.cr.fetchone()
if not row:
return None
data_type = row[0]
columns.append({
'field': fname,
'column': column,
'is_jsonb': data_type == 'jsonb',
})
return {'model': model_name, 'table': table, 'columns': columns}
def key_sql(column, is_jsonb):
"""SQL expression yielding the value Odoo compares for the unique index.
For jsonb (translated) columns Odoo indexes the value; the semantically
relevant duplicate is on the `en_US` key. For plain columns, the value.
"""
if is_jsonb:
return f"({column} ->> 'en_US')"
return column
def find_duplicate_groups(env, meta):
"""Return duplicate groups as list of dicts: {value, ids (sorted)}."""
table = meta['table']
key_exprs = [key_sql(c['column'], c['is_jsonb']) for c in meta['columns']]
key_concat = " || '\x1f' || ".join(f"COALESCE({e}, '')" for e in key_exprs)
query = f"""
SELECT {key_concat} AS dup_key, array_agg(id ORDER BY id) AS ids
FROM {table}
GROUP BY {key_concat}
HAVING COUNT(*) > 1
"""
env.cr.execute(query)
groups = []
for dup_key, ids in env.cr.fetchall():
groups.append({'value': dup_key, 'ids': list(ids)})
return groups
def rename_record(env, meta, record_id):
"""Suffix ` [<id>]` on every language key (jsonb) / plain value (varchar).
Returns a list of {field, old_value, new_value} describing the change.
"""
table = meta['table']
changes = []
suffix = f" [{record_id}]"
for col in meta['columns']:
column = col['column']
env.cr.execute(
f"SELECT {column} FROM {table} WHERE id = %s", (record_id,)
)
row = env.cr.fetchone()
if not row:
continue
old_value = row[0]
if col['is_jsonb']:
# jsonb: append suffix to each language value
if not isinstance(old_value, dict):
# psycopg may already decode jsonb to dict; if not, load it.
try:
old_value = json.loads(old_value) if old_value else {}
except (TypeError, ValueError):
old_value = {}
new_value = {
lang: (val or '') + suffix for lang, val in old_value.items()
}
env.cr.execute(
f"UPDATE {table} SET {column} = %s::jsonb WHERE id = %s",
(json.dumps(new_value), record_id),
)
else:
new_value = (old_value or '') + suffix
env.cr.execute(
f"UPDATE {table} SET {column} = %s WHERE id = %s",
(new_value, record_id),
)
changes.append({
'field': col['field'],
'old_value': old_value,
'new_value': new_value,
})
return changes
def main():
print("\n" + "=" * 80)
print(_c(Colors.BOLD, "PRE-MIGRATION - UNIQUE CONSTRAINT DEDUPLICATION"))
print("=" * 80 + "\n")
try:
specs = load_constraints()
except ValueError as exc:
print(_c(Colors.RED, f"[ERROR] {exc}"))
sys.exit(2)
if not specs:
print(_c(Colors.YELLOW, "No unique constraints declared. Nothing to do."))
return
renamed_records = [] # flat report entries
checked = 0
skipped = []
for spec in specs:
meta = resolve_constraint(env, spec)
label = f"{spec.get('model')}({', '.join(spec.get('fields', []))})"
if meta is None:
skipped.append(label)
print(_c(Colors.YELLOW, f"[SKIP] {label} - not present in this database"))
continue
checked += 1
groups = find_duplicate_groups(env, meta)
if not groups:
print(_c(Colors.GREEN, f"[OK] {label} - no duplicates"))
continue
total_dups = sum(len(g['ids']) - 1 for g in groups)
print(_c(Colors.BOLD,
f"[FIX] {label} - {len(groups)} duplicate group(s), "
f"{total_dups} record(s) to rename"))
for group in groups:
ids = group['ids']
keep_id, dup_ids = ids[0], ids[1:]
print(f" value={group['value']!r} keep id={keep_id} "
f"rename ids={dup_ids}")
for dup_id in dup_ids:
changes = rename_record(env, meta, dup_id)
for ch in changes:
renamed_records.append({
'model': meta['model'],
'table': meta['table'],
'id': dup_id,
'field': ch['field'],
'old_value': ch['old_value'],
'new_value': ch['new_value'],
})
if renamed_records:
env.cr.commit()
# ---- Summary ---------------------------------------------------------
print("\n" + "=" * 80)
print(_c(Colors.BOLD, "DEDUPLICATION SUMMARY"))
print("=" * 80)
print(f" Constraints checked ... {checked}")
print(f" Constraints skipped ... {len(skipped)}")
print(f" Records renamed ....... {len(renamed_records)}")
if renamed_records:
print("")
for r in renamed_records:
old_display = r['old_value']
new_display = r['new_value']
print(f" - {r['model']} #{r['id']} [{r['field']}]: "
f"{old_display!r} -> {new_display!r}")
print("=" * 80 + "\n")
# ---- JSON report -----------------------------------------------------
if os.environ.get('DEDUP_REPORT') and renamed_records:
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
db = env.cr.dbname
path = f"/tmp/dedup_unique_constraints_{db}_{ts}.json"
report = {
'type': 'dedup_unique_constraints',
'timestamp': datetime.now().isoformat(),
'database': db,
'constraints_checked': checked,
'constraints_skipped': skipped,
'renamed_count': len(renamed_records),
'renamed': renamed_records,
}
with open(path, 'w') as fh:
json.dump(report, fh, indent=2, default=str)
print(_c(Colors.BLUE, f"[REPORT] JSON written to {path}"))
try:
main()
except Exception as exc: # noqa: BLE001
print(_c(Colors.RED, f"[ERROR] Deduplication script failed: {exc}"))
import traceback
traceback.print_exc()
sys.exit(2)

1
lib/python/file_gc.py Normal file
View File

@@ -0,0 +1 @@
env['ir.attachment']._gc_file_store_unsafe()

View File

@@ -23,6 +23,66 @@ EOF
)
query_postgres_container "$CLEANUP_SQL" "$DB_NAME"
# ────────────────────────────────────────────────────────────
# Purge orphaned ``ir.ui.view`` images whose parent view was
# deleted AND whose ID is not referenced in any other view's
# HTML (``arch_db``). An image with ``res_id = 0`` can still
# be used by a live page via ``/web/image/<id>``, so we only
# remove it when no ``arch_db`` contains that URL.
# ────────────────────────────────────────────────────────────
echo "Purging ir.ui.view images orphaned of their parent view and unreferenced in any arch_db..."
PURGE_VIEW_IMAGES_SQL=$(cat <<'EOF'
DO $$
DECLARE purged int;
BEGIN
WITH refs AS (
SELECT DISTINCT (m[1])::bigint AS ref_id
FROM ir_ui_view v
CROSS JOIN LATERAL regexp_matches(
COALESCE(v.arch_db::text, ''), '/web/(?:image|content)/(\d+)', 'g') AS m
)
DELETE FROM ir_attachment a
WHERE a.res_model = 'ir.ui.view'
AND a.mimetype LIKE 'image/%'
AND a.store_fname IS NOT NULL
AND a.res_id NOT IN (SELECT id FROM ir_ui_view)
AND NOT EXISTS (SELECT 1 FROM refs r WHERE r.ref_id = a.id);
GET DIAGNOSTICS purged = ROW_COUNT;
RAISE NOTICE 'Purged % orphaned ir.ui.view image attachments', purged;
END $$;
EOF
)
query_postgres_container "$PURGE_VIEW_IMAGES_SQL" "$DB_NAME"
# ────────────────────────────────────────────────────────────
# Purge ir_logging (Python logger messages, no business value).
# ────────────────────────────────────────────────────────────
echo "Purging ir_logging..."
query_postgres_container "TRUNCATE ir_logging;" "$DB_NAME"
# ────────────────────────────────────────────────────────────
# Purge resized partner images (128, 256, 512, 1024) and
# attachments with no res_model (orphaned factur-x, CSS, ICS).
# Odoo 13+ regenerates thumbnails automatically from
# image_1920 on the next partner write, so only the full-size
# source needs to be kept.
# ────────────────────────────────────────────────────────────
echo "Purging resized partner images and orphaned attachments..."
PURGE_ATTACHMENTS_SQL=$(cat <<'EOF'
DELETE FROM ir_attachment
WHERE res_model = 'res.partner'
AND (name ILIKE '%image_128%'
OR name ILIKE '%image_256%'
OR name ILIKE '%image_512%'
OR name ILIKE '%image_1024%');
DELETE FROM ir_attachment
WHERE (res_model IS NULL OR res_model = '')
AND store_fname IS NOT NULL;
EOF
)
query_postgres_container "$PURGE_ATTACHMENTS_SQL" "$DB_NAME"
# ────────────────────────────────────────────────────────────
# Fix orphan ir_filters pointing to deleted ir_actions
#
@@ -93,7 +153,15 @@ SET state = 'installed'
WHERE state = 'to upgrade';
" "$DB_NAME" || true
# ────────────────────────────────────────────────────────────
# Clean orphaned filestore files left behind by the attachment
# deletions above. ``_gc_file_store_unsafe`` scans the filestore
# and removes files that no longer have a matching
# ``ir_attachment`` record.
# ────────────────────────────────────────────────────────────
echo "Cleaning orphaned filestore files..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec_python_script_in_odoo_shell "$ODOO_SERVICE" "$DB_NAME" "$SCRIPT_DIR/lib/python/file_gc.py"
PYTHON_SCRIPT="${SCRIPT_DIR}/lib/python/fix_duplicated_views.py"
echo "Remove duplicated views with script $PYTHON_SCRIPT ..."

View File

@@ -87,6 +87,30 @@ else
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
#############################################
## Deduplicate soon-to-be-unique fields ##
#############################################
# Some target versions add UNIQUE constraints on fields that allowed duplicates
# in the source version (e.g. utm.source.name in 16.0). Left untouched, the
# unique index creation aborts the migration with a UniqueViolation. We rename
# duplicates in the source DB now, before the migration loop.
log_step "UNIQUE CONSTRAINTS DEDUP"
UNIQUE_CONSTRAINTS_JSON=$(collect_new_unique_constraints)
if [[ "$UNIQUE_CONSTRAINTS_JSON" == "[]" || -z "$UNIQUE_CONSTRAINTS_JSON" ]]; then
log_info "No new unique constraints declared for the traversed versions — skipping."
else
log_info "Checking unique constraints: $UNIQUE_CONSTRAINTS_JSON"
DEDUP_SCRIPT="${SCRIPT_DIR}/lib/python/dedup_unique_constraints.py"
# Inject the catalog via a Python preamble because the compose wrapper does
# not reliably propagate host environment variables into the container.
exec_python_script_in_odoo_shell_with_preamble "$DB_NAME" "$DB_NAME" "$DEDUP_SCRIPT" <<PY
import os
os.environ['DEDUP_UNIQUE_CONSTRAINTS'] = ${UNIQUE_CONSTRAINTS_JSON@Q}
os.environ['DEDUP_REPORT'] = '1'
PY
fi
PYTHON_SCRIPT="${SCRIPT_DIR}/lib/python/check_views.py"
echo "Check views with script $PYTHON_SCRIPT ..."
exec_python_script_in_odoo_shell "$DB_NAME" "$DB_NAME" "$PYTHON_SCRIPT"

View File

@@ -232,4 +232,20 @@ log_step "POST-UPGRADE PROCESSES"
"${SCRIPT_DIR}/scripts/finalize_db.sh" "$FINALE_DB_NAME" "$FINALE_SERVICE_NAME"
log_step "UPGRADE PROCESS ENDED WITH SUCCESS"
# Report any records that were renamed to satisfy new unique constraints.
# The deduplication runs on the source DB (ou${ORIGIN_VERSION}) during
# prepare_db.sh and writes a timestamped JSON report to /tmp.
readarray -t dedup_reports < <(ls -1t /tmp/dedup_unique_constraints_"${COPY_DB_NAME}"_*.json 2>/dev/null || true)
if [[ ${#dedup_reports[@]} -gt 0 ]]; then
latest_report="${dedup_reports[0]}"
renamed_count=$(yq -p=json '.renamed_count' "$latest_report" 2>/dev/null || echo "?")
log_info "Unique-constraint deduplication: ${renamed_count} record(s) renamed in the source DB before migration."
log_info "Details: ${latest_report}"
if [[ "$renamed_count" =~ ^[0-9]+$ && "$renamed_count" -gt 0 ]]; then
yq -p=json -r '.renamed[] | " - \(.model) #\(.id) [\(.field)]: \(.old_value) -> \(.new_value)"' \
"$latest_report" 2>/dev/null || true
fi
fi
log_info "Full logs available at: ${LOG_FILE}"

View File

@@ -21,3 +21,12 @@ renamed:
new: server_action_mass_edit
- old: crm_project
new: crm_lead_to_task
# Unique constraints newly introduced in 16.0
new_unique_constraints:
- model: utm.source
fields: [name]
- model: utm.medium
fields: [name]
- model: utm.campaign
fields: [name]

View File

@@ -51,6 +51,166 @@ EOF
echo "SQL command = $PRE_MIGRATE_SQL_3"
query_postgres_container "$PRE_MIGRATE_SQL_3" ou17 || exit 1
# ────────────────────────────────────────────────────────────
# Remove project_list's ir.actions.act_window.view records
#
# `project_list` (OCA) is not ported to 17.0 and is classified as
# merged_in_core: in 17.0 the core `project` module natively adds the
# kanban/tree views to the "Projects" actions (act_window_id 149 & 760).
# 17.0 also introduces the unique constraint
# `act_window_view_unique_mode_per_action = unique(act_window_id, view_mode)`.
#
# When core reloads project_project_views.xml, it INSERTs its own
# (act_window_id, view_mode='kanban') rows, which collide with the rows still
# owned by project_list -> UniqueViolation, aborting the registry load:
#
# ERROR: duplicate key value violates unique constraint
# "act_window_view_unique_mode_per_action"
# DETAIL: Key (act_window_id, view_mode)=(760, kanban) already exists.
#
# We delete project_list's act_window_view rows (and their ir_model_data) so
# core can recreate its canonical rows. Verified against the 17.0 OpenUpgrade
# scripts: none reference project_list xmlids nor remap these records, so this
# is safe. Scoped to ir.actions.act_window.view only. Idempotent.
# ────────────────────────────────────────────────────────────
PRE_MIGRATE_SQL_4=$(cat <<'EOF'
DO $$
DECLARE
deleted_count INTEGER;
BEGIN
WITH targets AS (
SELECT d.id AS imd_id, d.res_id AS awv_id
FROM ir_model_data d
WHERE d.module = 'project_list'
AND d.model = 'ir.actions.act_window.view'
),
del_views AS (
DELETE FROM ir_act_window_view
WHERE id IN (SELECT awv_id FROM targets)
RETURNING id
),
del_imd AS (
DELETE FROM ir_model_data
WHERE id IN (SELECT imd_id FROM targets)
RETURNING id
)
SELECT count(*) INTO deleted_count FROM del_views;
RAISE NOTICE 'Removed % project_list act_window_view record(s) to avoid act_window_view_unique_mode_per_action collision', deleted_count;
END $$;
EOF
)
echo "SQL command = $PRE_MIGRATE_SQL_4"
query_postgres_container "$PRE_MIGRATE_SQL_4" ou17 || exit 1
# ────────────────────────────────────────────────────────────
# Remove orphan theme.ir.ui.view templates pointing to deleted ir.ui.view
#
# In Odoo 17, the asset bundle system was reworked: `web._assets_utils`,
# `website.assets_editor`, and `website._assets_frontend_helpers` were
# replaced by the new `ir.asset` model. The theme_common/theme_clean 17.0
# modules no longer define those templates, but the migrated database still
# holds the old `theme_ir_ui_view` records (kept by ir_model_data noupdate).
#
# Their `inherit_id` Reference field points to ir.ui.view ids that no longer
# exist. When website's `_theme_load(website)` runs (ir_module_module.py:95)
# during the registry load, `ThemeView._convert_to_base_model`
# (theme_models.py:85) accesses `inherit.website_id` on the ghost record and
# raises MissingError, aborting the whole registry:
#
# odoo.exceptions.MissingError: Record does not exist or has been deleted.
# (Record: ir.ui.view(174,), User: 1)
#
# This deletes the ir_model_data + theme_ir_ui_view rows whose inherit_id
# targets a missing ir.ui.view. Generic (covers any theme, idempotent).
# Scoped to theme_ir_ui_view only. Verified: no ir.ui.view copies exist for
# the affected templates, so no side effects.
# ────────────────────────────────────────────────────────────
PRE_MIGRATE_SQL_5=$(cat <<'EOF'
DO $$
DECLARE
deleted_imd INTEGER;
deleted_tpl INTEGER;
BEGIN
IF to_regclass('theme_ir_ui_view') IS NULL THEN
RAISE NOTICE 'Table theme_ir_ui_view does not exist, skipping stale view cleanup.';
RETURN;
END IF;
WITH targets AS (
SELECT id FROM theme_ir_ui_view
WHERE inherit_id LIKE 'ir.ui.view,%'
AND NOT EXISTS (
SELECT 1 FROM ir_ui_view v
WHERE v.id = split_part(theme_ir_ui_view.inherit_id, ',', 2)::int
)
),
del AS (
DELETE FROM ir_model_data
WHERE model = 'theme.ir.ui.view'
AND res_id IN (SELECT id FROM targets)
RETURNING id
)
SELECT count(*) INTO deleted_imd FROM del;
WITH del AS (
DELETE FROM theme_ir_ui_view
WHERE inherit_id LIKE 'ir.ui.view,%'
AND NOT EXISTS (
SELECT 1 FROM ir_ui_view v
WHERE v.id = split_part(theme_ir_ui_view.inherit_id, ',', 2)::int
)
RETURNING id
)
SELECT count(*) INTO deleted_tpl FROM del;
RAISE NOTICE 'Cleaned % stale theme_ir_ui_view record(s) and % ir_model_data entry(ies)', deleted_tpl, deleted_imd;
END $$;
EOF
)
echo "SQL command = $PRE_MIGRATE_SQL_5"
query_postgres_container "$PRE_MIGRATE_SQL_5" ou17 || exit 1
# ────────────────────────────────────────────────────────────
# Drop mail.tracking.value rows referencing event.registration.mobile
#
# In Odoo 17 the field event.registration.mobile is removed (OpenUpgrade:
# "event / event.registration / mobile (char) : DEL"). During _process_end,
# Odoo unlinks the now-orphan ir.model.fields row. mail's ir_model_fields
# unlink() override iterates the mail.tracking.value rows pointing to it and
# calls event.registration._mail_track_get_field_sequence('mobile'), which
# does self._fields['mobile'] -> KeyError: 'mobile', aborting the registry:
#
# KeyError: 'mobile' (mail/models/models.py, _mail_track_get_field_sequence)
#
# The OpenUpgrade event script deletes the field but leaves its tracking
# values, so any DB that tracked this field crashes. We delete those tracking
# values beforehand so the field unlinks cleanly.
# Scoped strictly to event.registration.mobile. Idempotent.
# NOTE: this runs before -u all, so the FK column is still named 'field'
# (mail renames it to 'field_id' during the 16->17 upgrade).
# ────────────────────────────────────────────────────────────
PRE_MIGRATE_SQL_6=$(cat <<'EOF'
DO $$
DECLARE
deleted_count INTEGER;
BEGIN
WITH del AS (
DELETE FROM mail_tracking_value
WHERE field IN (
SELECT id FROM ir_model_fields
WHERE model = 'event.registration' AND name = 'mobile'
)
RETURNING id
)
SELECT count(*) INTO deleted_count FROM del;
RAISE NOTICE 'Removed % mail_tracking_value row(s) for event.registration.mobile', deleted_count;
END $$;
EOF
)
echo "SQL command = $PRE_MIGRATE_SQL_6"
query_postgres_container "$PRE_MIGRATE_SQL_6" ou17 || exit 1
# Copy filestores
copy_filestore ou16 ou16 ou17 ou17 || exit 1

View File

@@ -6,6 +6,56 @@ echo "Prepare migration to 18.0..."
# Copy database
copy_database ou17 ou18 ou18 || exit 1
# ============================================================================
# REMOVE contract_payment_mode (depends on obsolete account_payment_partner)
#
# contract_payment_mode v18 still depends on account_payment_partner (deleted
# by the bank-payment renaming below) and uses the obsolete account.payment.mode
# model. No v18 replacement exists — account_payment_base_oca provides no
# contract integration. Only 2 contracts had payment_mode_id set, and the
# migration to the new payment.method.line model is not supported.
#
# MUST run BEFORE the bank-payment renaming and BEFORE OpenUpgrade's
# button_upgrade() which would otherwise crash on the missing dependency.
#
# We DELETE the module entirely (like the bank-payment merged_modules pattern)
# rather than marking it 'to remove', because Odoo's "Transient module states
# were reset" in load_modules() converts 'to remove' -> 'installed', causing
# button_upgrade() to re-parse the missing account_payment_partner dependency
# and abort the registry load. Deleting the row + ir_model_data + dependencies
# ensures OpenUpgrade's update_list() rediscovers the physical addon as a fresh
# 'uninstalled' module that button_upgrade() never touches.
# ============================================================================
contract_payment_mode_sql=$(cat <<'EOF'
DO $$
DECLARE
mod_id INTEGER;
BEGIN
SELECT id INTO mod_id FROM ir_module_module WHERE name = 'contract_payment_mode';
IF mod_id IS NOT NULL THEN
DELETE FROM ir_module_module_dependency WHERE module_id = mod_id;
DELETE FROM ir_model_data WHERE module = 'contract_payment_mode';
DELETE FROM ir_module_module WHERE id = mod_id;
-- Rename the module's own external ID (base.module_contract_payment_mode)
-- so update_list() can rediscover the physical contract_payment_mode addon
-- (which still exists in v18 addons path) without conflicting on
-- (base, module_contract_payment_mode). The orphaned xmlid pointed to
-- the deleted ir.module.module row and has no other references.
UPDATE ir_model_data
SET name = 'module_contract_payment_mode_removed'
WHERE module = 'base' AND name = 'module_contract_payment_mode';
RAISE NOTICE 'Removed contract_payment_mode module (depends on obsolete account_payment_partner)';
ELSE
RAISE NOTICE 'contract_payment_mode not found, skipping';
END IF;
END $$;
EOF
)
echo "Removing contract_payment_mode module..."
query_postgres_container "$contract_payment_mode_sql" ou18 || exit 1
# ============================================================================
# BANK-PAYMENT -> BANK-PAYMENT-ALTERNATIVE MODULE RENAMING
# Migration from OCA/bank-payment to OCA/bank-payment-alternative
@@ -96,6 +146,82 @@ else
echo "Module account_payment_mode not installed, skipping bank-payment migration."
fi
# ============================================================================
# ACCOUNT_FACTORING -> ACCOUNT_FACTORING_OCA MODULE RENAMING
#
# account_factoring (depends on account_payment_partner + account_payment_mode)
# is replaced by account_factoring_oca (depends on account_payment_base_oca
# + account_payment_base_oca_sale) in v18.
#
# Both modules have rigorously identical stored fields:
# - account_journal: is_factor, factor_fee, factor_holdback_percent,
# factor_partner_id, factor_fee_account_id, factor_holdback_account_id,
# factor_limit_holdback_account_id, factor_tax_id, factor_validation
# - account_move: factor_transfer_id, factor_payment_id,
# payment_state_with_factor
# - res_partner: factor_credit_limit
#
# Renaming (not uninstalling) preserves the 4910 invoice factor statuses,
# 311 partner credit limits, and journal factor config. OpenUpgrade will
# treat account_factoring_oca as already installed and adopt the existing
# columns via ORM _auto_init.
#
# MUST run AFTER the bank-payment renaming block above, which creates
# account_payment_base_oca in the DB.
# ============================================================================
ACCOUNT_FACTORING_RENAME_SQL=$(cat <<'EOF'
DO $$
DECLARE
factor_module_id INTEGER;
BEGIN
SELECT id INTO factor_module_id FROM ir_module_module WHERE name = 'account_factoring';
IF factor_module_id IS NOT NULL THEN
-- Rename the module itself
UPDATE ir_module_module SET name = 'account_factoring_oca' WHERE name = 'account_factoring';
UPDATE ir_model_data SET module = 'account_factoring_oca' WHERE module = 'account_factoring';
-- Rename the module's own external ID (base.module_account_factoring)
-- so OpenUpgrade can rediscover the physical account_factoring addon dir
-- (which still exists in v18 addons path) without conflicting on
-- (base, module_account_factoring) — that xmlid now points to the
-- renamed module under its new name.
UPDATE ir_model_data
SET name = 'module_account_factoring_oca'
WHERE module = 'base' AND name = 'module_account_factoring';
-- Redirect dependencies pointing TO the old name
UPDATE ir_module_module_dependency SET name = 'account_factoring_oca' WHERE name = 'account_factoring';
-- Replace obsolete dependencies (account_payment_partner + account_payment_mode
-- no longer exist in v18 addons) with the new OCA equivalents
DELETE FROM ir_module_module_dependency
WHERE module_id = factor_module_id
AND name IN ('account_payment_partner', 'account_payment_mode');
INSERT INTO ir_module_module_dependency (name, module_id)
SELECT 'account_payment_base_oca', factor_module_id
WHERE NOT EXISTS (
SELECT 1 FROM ir_module_module_dependency
WHERE module_id = factor_module_id AND name = 'account_payment_base_oca'
);
INSERT INTO ir_module_module_dependency (name, module_id)
SELECT 'account_payment_base_oca_sale', factor_module_id
WHERE NOT EXISTS (
SELECT 1 FROM ir_module_module_dependency
WHERE module_id = factor_module_id AND name = 'account_payment_base_oca_sale'
);
RAISE NOTICE 'Renamed account_factoring -> account_factoring_oca (data preserved)';
ELSE
RAISE NOTICE 'account_factoring not found, skipping rename';
END IF;
END $$;
EOF
)
echo "Executing account_factoring -> account_factoring_oca renaming..."
query_postgres_container "$ACCOUNT_FACTORING_RENAME_SQL" ou18 || exit 1
# ============================================================================
# FIX: Rename company-dependent columns before OpenUpgrade runs
# In Odoo 18, company-dependent fields are stored as JSONB columns.
@@ -121,9 +247,6 @@ EOF
echo "Fixing company-dependent columns for Odoo 18..."
query_postgres_container "$COMPANY_DEPENDENT_FIX_SQL" ou18 || exit 1
EOF
)
# Execute SQL pre-migration commands
PRE_MIGRATE_SQL=$(cat <<'EOF'
UPDATE account_analytic_plan SET default_applicability=NULL WHERE default_applicability='optional';
@@ -134,6 +257,13 @@ EOF
echo "SQL command = $PRE_MIGRATE_SQL"
query_postgres_container "$PRE_MIGRATE_SQL" ou18 || exit 1
# NOTE: Missing generic PCG account xml-ids expected by l10n_fr_account (e.g.
# account.<company>_pcg_607_account / _pcg_707_account) are recreated generically
# by the ORM post-migration script
# versions/18.0/scripts/l10n_fr_account/18.0.2.2/post-migration-0k-fix-property-account-xmlids.py
# which runs at stage "post" of l10n_fr_account, before the native "end" script
# end-migrate_update_taxes.py that would otherwise crash on the missing xml-id.
# Copy filestores
copy_filestore ou17 ou17 ou18 ou18 || exit 1

View File

@@ -0,0 +1,33 @@
<?xml version='1.0' encoding='utf-8'?>
<!--
0k / Élabore customization of the French VAT report (l10n_fr_account).
Adds an editable "adjustment" expression on box 25 (VAT credit) and
integrates it into the box 25 balance formula. Historically this lived in a
full copy of l10n_fr_account/data/tax_report_data.xml that shadowed the core
module; it is now applied the OpenUpgrade way, via load_data() from
post-migration.py, without duplicating the core module.
Loaded with mode="init"; records use the module's own xml ids
(l10n_fr_account.*). tax_report_25_formula already exists in core and is
updated here; tax_report_25_adjustment is new and gets created.
-->
<odoo>
<!-- Update the box 25 balance formula to include the adjustment -->
<record id="tax_report_25_formula" model="account.report.expression">
<field name="report_line_id" ref="l10n_fr_account.tax_report_25"/>
<field name="label">balance</field>
<field name="engine">aggregation</field>
<field name="formula">box_23.balance - box_16.balance + box_25.adjustment</field>
<field name="subformula">if_above(EUR(0))</field>
</record>
<!-- New editable external "adjustment" expression on box 25 -->
<record id="tax_report_25_adjustment" model="account.report.expression">
<field name="report_line_id" ref="l10n_fr_account.tax_report_25"/>
<field name="label">adjustment</field>
<field name="engine">external</field>
<field name="formula">sum</field>
<field name="subformula">editable;rounding=0</field>
</record>
</odoo>

View File

@@ -0,0 +1,195 @@
# Copyright 0k / Élabore
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
#
# Custom OpenUpgrade migration script, injected through a dedicated
# --upgrade-path (see versions/18.0/upgrade.sh). Runs at stage "post" of
# l10n_fr_account/18.0.2.2, i.e. BEFORE the global "end" stage where the native
# l10n_fr_account/migrations/2.1/end-migrate_update_taxes.py runs.
#
# Problem
# -------
# end-migrate_update_taxes.py calls account.chart.template.try_loading('fr', ...).
# During _post_load_data(), Odoo 18 resolves the French template's default
# accounts via self.ref() WITHOUT raise_if_not_found=False, at three places:
# - property_account_income_categ_id (pcg_707_account) -> chart_template.py:711
# - property_account_expense_categ_id (pcg_607_account) -> chart_template.py:714
# - every _get_property_accounts()/additional_properties account -> :746
# If any expected account xml-id "account.<company>_<key>" is missing, it raises:
# ValueError: External ID not found in the system: account.1_pcg_607_account
# which aborts the whole registry load.
#
# This happens when a database customized its chart of accounts: the generic PCG
# account was deleted (e.g. 607000) or recreated without its xml-id (e.g. 707000).
#
# Fix (generic, not hardcoded to 607/707)
# ---------------------------------------
# For every company with a chart_template, we read the list of account xml-ids
# the template expects (dynamically, from the template ORM API), and for each one
# that is missing in the DB we recreate the "account.<company>_<key>" ir.model.data
# pointing to the best matching real account:
# 1. the account whose code matches exactly the template account code,
# 2. otherwise the company account whose code shares the longest common prefix
# with the template code (e.g. 607000 -> 607100),
# 3. otherwise nothing (logged as a warning; never blocks).
# No account is modified; only missing xml-ids are (re)created. Idempotent.
import logging
from openupgradelib import openupgrade
_logger = logging.getLogger(__name__)
def _collect_property_account_xmlids(template, code, accounts_csv):
"""Return the set of template account xml-ids referenced as property accounts.
Sources:
- template._get_property_accounts({}) (receivable, payable, expense, income, ...)
- the template_data dict (property_account_*_id keys)
- additional_properties declared on res.company template data
Only keep values that are real account xml-ids of this template, i.e. present
in accounts_csv (this filters out journals and dotted external ids).
"""
xmlids = set()
# 1. Base property accounts (model-level defaults)
try:
prop_fields = template._get_property_accounts({})
except Exception as exc: # noqa: BLE001
_logger.warning("Could not read _get_property_accounts: %s", exc)
prop_fields = {}
# 2. Full template data: template_data holds property_account_*_id values,
# res.company holds additional_properties (currency exchange, deferral...).
try:
tdata = template._get_chart_template_data(code)
except Exception as exc: # noqa: BLE001
_logger.warning("Could not read _get_chart_template_data(%s): %s", code, exc)
tdata = {}
candidate_values = []
template_data = tdata.get("template_data", {}) if tdata else {}
for key, value in template_data.items():
if key in prop_fields or key.startswith("property_"):
candidate_values.append(value)
# additional_properties live inside the res.company template values
res_company = tdata.get("res.company", {}) if tdata else {}
for _company_key, company_vals in res_company.items():
if not isinstance(company_vals, dict):
continue
add_props = company_vals.get("additional_properties")
if isinstance(add_props, dict):
candidate_values.extend(add_props.values())
# Keep only plain (non-dotted) xml-ids that are actual accounts of the template
for value in candidate_values:
if isinstance(value, str) and value and "." not in value and value in accounts_csv:
xmlids.add(value)
return xmlids
def _find_best_account(env, company, target_code):
"""Return the best account record for a template account code, or None.
1. exact code match for the company;
2. otherwise the company account whose code shares the longest common prefix
with target_code; ties are broken by smallest code (deterministic).
3. otherwise None.
The exact fallback account matters little functionally: it is only used as a
journal's default_account_id / an ir.default, which stays user-overridable.
The goal is to provide a valid account of the right family so that
try_loading() no longer crashes on a missing external id.
"""
if not target_code:
return None
Account = env["account.account"]
company_domain = [("company_ids", "in", company.id)]
exact = Account.search(company_domain + [("code", "=", target_code)], limit=1)
if exact:
return exact
# Longest common prefix fallback; ties broken by smallest code.
candidates = Account.search(company_domain)
best = None
best_key = None # (prefix_len, negated-code-for-min) -> maximize
for acc in candidates:
code = acc.code or ""
common = 0
for a, b in zip(code, target_code):
if a != b:
break
common += 1
if common == 0:
continue
key = (common, tuple(-ord(c) for c in code)) # longer prefix, then smallest code
if best_key is None or key > best_key:
best = acc
best_key = key
return best
@openupgrade.migrate()
def migrate(env, version):
template = env["account.chart.template"]
companies = env["res.company"].search([("chart_template", "!=", False)])
created = 0
for company in companies:
code = company.chart_template
tmpl = template.with_company(company)
try:
accounts_csv = tmpl._get_account_account(code)
except Exception as exc: # noqa: BLE001
_logger.warning(
"Company %s (%s): cannot read template accounts: %s",
company.id, code, exc,
)
continue
expected = _collect_property_account_xmlids(tmpl, code, accounts_csv)
for tmpl_xmlid in sorted(expected):
full = f"account.{company.id}_{tmpl_xmlid}"
if env.ref(full, raise_if_not_found=False):
continue
if company.parent_ids:
parent_full = (
f"account.{company.parent_ids[0].id}_{tmpl_xmlid}"
)
if env.ref(parent_full, raise_if_not_found=False):
continue
target_code = accounts_csv.get(tmpl_xmlid, {}).get("code")
account = _find_best_account(env, company, target_code)
if not account:
_logger.warning(
"Company %s: no account found for template xml-id %s "
"(code %s); skipping",
company.id, tmpl_xmlid, target_code,
)
continue
env["ir.model.data"].create({
"module": "account",
"name": f"{company.id}_{tmpl_xmlid}",
"model": "account.account",
"res_id": account.id,
"noupdate": True,
})
created += 1
_logger.info(
"Company %s: created xml-id %s -> account %s (%s) for "
"template code %s",
company.id, full, account.id, account.code, target_code,
)
_logger.info(
"[l10n_fr_account] property-account xml-id fix: %s xml-id(s) created",
created,
)

View File

@@ -0,0 +1,20 @@
# Copyright 0k / Élabore
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
#
# Custom OpenUpgrade migration script, injected through a dedicated
# --upgrade-path (see versions/18.0/upgrade.sh). It runs IN ADDITION to the
# native openupgrade_scripts post-migration.py of l10n_fr_account/18.0.2.2.
#
# Loads the 0k customization of the French VAT report (box 25 "adjustment")
# the OpenUpgrade way, without shadowing the core l10n_fr_account module.
from openupgradelib import openupgrade
@openupgrade.migrate()
def migrate(env, version):
# mode="init" (re)creates records marked noupdate that the normal upgrade
# mechanism would skip; it updates existing ones (tax_report_25_formula)
# and creates new ones (tax_report_25_adjustment).
openupgrade.load_data(
env, "l10n_fr_account", "18.0.2.2/noupdate_changes.xml"
)

View File

@@ -0,0 +1,460 @@
# Copyright 0k / Élabore
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
#
# Custom OpenUpgrade migration script, injected through a dedicated
# --upgrade-path (see versions/18.0/upgrade.sh). It runs IN ADDITION to the
# native openupgrade_scripts pre-migration.py of l10n_fr_account/18.0.2.2:
# the framework appends every upgrade path, so this script does not override
# nor duplicate the native one.
from openupgradelib import openupgrade
@openupgrade.migrate()
def migrate(env, version):
"""Fix orphan account.report.expression records before XML data loading.
During migration from V17, some account.report.expression records lose
their ir_model_data entries. When l10n_fr_account loads tax_report_data.xml
in V18, it tries to INSERT these records again, violating the unique
constraint account_report_expression_line_label_uniq (report_line_id, label).
Fix 1: Re-insert missing ir_model_data entries so Odoo performs UPDATE
instead of INSERT when loading the XML data.
Fix 2: Delete orphan expressions that no longer exist in V18 XML
(box_X5, box_Y4, box_Y5, box_Z5 now use aggregation_formula field).
"""
cr = env.cr
# (expr_xmlid, line_xmlid, label)
# Generated from l10n_fr_account/data/tax_report_data.xml (18.0)
expressions = [
("tax_report_A1_tag", "tax_report_A1", "balance"),
("tax_report_A1_tag_rounded", "tax_report_A1", "balance_rounded"),
("tax_report_A1_balance_from_tags", "tax_report_A1", "balance_from_tags"),
("tax_report_A1_adjustment", "tax_report_A1", "adjustment"),
("tax_report_A2_tag", "tax_report_A2", "balance"),
("tax_report_A2_tag_rounded", "tax_report_A2", "balance_rounded"),
("tax_report_A2_balance_from_tags", "tax_report_A2", "balance_from_tags"),
("tax_report_A2_adjustment", "tax_report_A2", "adjustment"),
("tax_report_A3_tag", "tax_report_A3", "balance"),
("tax_report_A3_tag_rounded", "tax_report_A3", "balance_rounded"),
("tax_report_A3_balance_from_tags", "tax_report_A3", "balance_from_tags"),
("tax_report_A3_adjustment", "tax_report_A3", "adjustment"),
("tax_report_A4_tag", "tax_report_A4", "balance"),
("tax_report_A4_tag_rounded", "tax_report_A4", "balance_rounded"),
("tax_report_A4_balance_from_tags", "tax_report_A4", "balance_from_tags"),
("tax_report_A4_adjustment", "tax_report_A4", "adjustment"),
("tax_report_A5_tag", "tax_report_A5", "balance"),
("tax_report_A5_tag_rounded", "tax_report_A5", "balance_rounded"),
("tax_report_A5_balance_from_tags", "tax_report_A5", "balance_from_tags"),
("tax_report_A5_adjustment", "tax_report_A5", "adjustment"),
("tax_report_B1_tag", "tax_report_B1", "balance"),
("tax_report_B1_tag_rounded", "tax_report_B1", "balance_rounded"),
("tax_report_B1_balance_from_tags", "tax_report_B1", "balance_from_tags"),
("tax_report_B1_adjustment", "tax_report_B1", "adjustment"),
("tax_report_B2_tag", "tax_report_B2", "balance"),
("tax_report_B2_tag_rounded", "tax_report_B2", "balance_rounded"),
("tax_report_B2_balance_from_tags", "tax_report_B2", "balance_from_tags"),
("tax_report_B2_adjustment", "tax_report_B2", "adjustment"),
("tax_report_B3_tag", "tax_report_B3", "balance"),
("tax_report_B3_tag_rounded", "tax_report_B3", "balance_rounded"),
("tax_report_B3_balance_from_tags", "tax_report_B3", "balance_from_tags"),
("tax_report_B3_adjustment", "tax_report_B3", "adjustment"),
("tax_report_B4_tag", "tax_report_B4", "balance"),
("tax_report_B4_tag_rounded", "tax_report_B4", "balance_rounded"),
("tax_report_B4_balance_from_tags", "tax_report_B4", "balance_from_tags"),
("tax_report_B4_adjustment", "tax_report_B4", "adjustment"),
("tax_report_B5_tag", "tax_report_B5", "balance"),
("tax_report_B5_tag_rounded", "tax_report_B5", "balance_rounded"),
("tax_report_B5_balance_from_tags", "tax_report_B5", "balance_from_tags"),
("tax_report_B5_adjustment", "tax_report_B5", "adjustment"),
("tax_report_E1_tag", "tax_report_E1", "balance"),
("tax_report_E1_tag_rounded", "tax_report_E1", "balance_rounded"),
("tax_report_E1_balance_from_tags", "tax_report_E1", "balance_from_tags"),
("tax_report_E1_adjustment", "tax_report_E1", "adjustment"),
("tax_report_E2_tag", "tax_report_E2", "balance"),
("tax_report_E2_tag_rounded", "tax_report_E2", "balance_rounded"),
("tax_report_E2_balance_from_tags", "tax_report_E2", "balance_from_tags"),
("tax_report_E2_adjustment", "tax_report_E2", "adjustment"),
("tax_report_E3_tag", "tax_report_E3", "balance"),
("tax_report_E3_tag_rounded", "tax_report_E3", "balance_rounded"),
("tax_report_E3_balance_from_tags", "tax_report_E3", "balance_from_tags"),
("tax_report_E3_adjustment", "tax_report_E3", "adjustment"),
("tax_report_E4_tag", "tax_report_E4", "balance"),
("tax_report_E4_tag_rounded", "tax_report_E4", "balance_rounded"),
("tax_report_E4_balance_from_tags", "tax_report_E4", "balance_from_tags"),
("tax_report_E4_adjustment", "tax_report_E4", "adjustment"),
("tax_report_E5_tag", "tax_report_E5", "balance"),
("tax_report_E5_tag_rounded", "tax_report_E5", "balance_rounded"),
("tax_report_E5_balance_from_tags", "tax_report_E5", "balance_from_tags"),
("tax_report_E5_adjustment", "tax_report_E5", "adjustment"),
("tax_report_E6_tag", "tax_report_E6", "balance"),
("tax_report_E6_tag_rounded", "tax_report_E6", "balance_rounded"),
("tax_report_E6_balance_from_tags", "tax_report_E6", "balance_from_tags"),
("tax_report_E6_adjustment", "tax_report_E6", "adjustment"),
("tax_report_F1_tag", "tax_report_F1", "balance"),
("tax_report_F1_tag_rounded", "tax_report_F1", "balance_rounded"),
("tax_report_F1_balance_from_tags", "tax_report_F1", "balance_from_tags"),
("tax_report_F1_adjustment", "tax_report_F1", "adjustment"),
("tax_report_F2_tag", "tax_report_F2", "balance"),
("tax_report_F2_tag_rounded", "tax_report_F2", "balance_rounded"),
("tax_report_F2_balance_from_tags", "tax_report_F2", "balance_from_tags"),
("tax_report_F2_adjustment", "tax_report_F2", "adjustment"),
("tax_report_F3_tag", "tax_report_F3", "balance"),
("tax_report_F3_tag_rounded", "tax_report_F3", "balance_rounded"),
("tax_report_F3_balance_from_tags", "tax_report_F3", "balance_from_tags"),
("tax_report_F3_adjustment", "tax_report_F3", "adjustment"),
("tax_report_F4_tag", "tax_report_F4", "balance"),
("tax_report_F4_tag_rounded", "tax_report_F4", "balance_rounded"),
("tax_report_F4_balance_from_tags", "tax_report_F4", "balance_from_tags"),
("tax_report_F4_adjustment", "tax_report_F4", "adjustment"),
("tax_report_F5_tag", "tax_report_F5", "balance"),
("tax_report_F5_tag_rounded", "tax_report_F5", "balance_rounded"),
("tax_report_F5_balance_from_tags", "tax_report_F5", "balance_from_tags"),
("tax_report_F5_adjustment", "tax_report_F5", "adjustment"),
("tax_report_F6_tag", "tax_report_F6", "balance"),
("tax_report_F6_tag_rounded", "tax_report_F6", "balance_rounded"),
("tax_report_F6_balance_from_tags", "tax_report_F6", "balance_from_tags"),
("tax_report_F6_adjustment", "tax_report_F6", "adjustment"),
("tax_report_F7_tag", "tax_report_F7", "balance"),
("tax_report_F7_tag_rounded", "tax_report_F7", "balance_rounded"),
("tax_report_F7_balance_from_tags", "tax_report_F7", "balance_from_tags"),
("tax_report_F7_adjustment", "tax_report_F7", "adjustment"),
("tax_report_F8_tag", "tax_report_F8", "balance"),
("tax_report_F8_tag_rounded", "tax_report_F8", "balance_rounded"),
("tax_report_F8_balance_from_tags", "tax_report_F8", "balance_from_tags"),
("tax_report_F8_adjustment", "tax_report_F8", "adjustment"),
("tax_report_F9_tag", "tax_report_F9", "balance"),
("tax_report_F9_tag_rounded", "tax_report_F9", "balance_rounded"),
("tax_report_F9_balance_from_tags", "tax_report_F9", "balance_from_tags"),
("tax_report_F9_adjustment", "tax_report_F9", "adjustment"),
("tax_report_08_base_tag", "tax_report_08_base", "balance"),
("tax_report_08_base_tag_rounded", "tax_report_08_base", "balance_rounded"),
("tax_report_08_base_balance_from_tags", "tax_report_08_base", "balance_from_tags"),
("tax_report_08_base_adjustment", "tax_report_08_base", "adjustment"),
("tax_report_08_taxe_tag", "tax_report_08_taxe", "balance"),
("tax_report_08_taxe_tag_rounded", "tax_report_08_taxe", "balance_rounded"),
("tax_report_08_taxe_tag_no_rounding", "tax_report_08_taxe", "balance_from_tags"),
("tax_report_09_base_tag", "tax_report_09_base", "balance"),
("tax_report_09_base_tag_rounded", "tax_report_09_base", "balance_rounded"),
("tax_report_09_base_balance_from_tags", "tax_report_09_base", "balance_from_tags"),
("tax_report_09_base_adjustment", "tax_report_09_base", "adjustment"),
("tax_report_09_taxe_tag", "tax_report_09_taxe", "balance"),
("tax_report_09_taxe_tag_rounded", "tax_report_09_taxe", "balance_rounded"),
("tax_report_09_taxe_tag_no_rounding", "tax_report_09_taxe", "balance_from_tags"),
("tax_report_9B_base_tag", "tax_report_9B_base", "balance"),
("tax_report_9B_base_tag_rounded", "tax_report_9B_base", "balance_rounded"),
("tax_report_9B_base_balance_from_tags", "tax_report_9B_base", "balance_from_tags"),
("tax_report_9B_base_adjustment", "tax_report_9B_base", "adjustment"),
("tax_report_9B_taxe_tag", "tax_report_9B_taxe", "balance"),
("tax_report_9B_taxe_tag_rounded", "tax_report_9B_taxe", "balance_rounded"),
("tax_report_9B_taxe_tag_no_rounding", "tax_report_9B_taxe", "balance_from_tags"),
("tax_report_10_base_tag", "tax_report_10_base", "balance"),
("tax_report_10_base_tag_rounded", "tax_report_10_base", "balance_rounded"),
("tax_report_10_base_balance_from_tags", "tax_report_10_base", "balance_from_tags"),
("tax_report_10_base_adjustment", "tax_report_10_base", "adjustment"),
("tax_report_10_taxe_tag", "tax_report_10_taxe", "balance"),
("tax_report_10_taxe_tag_balance", "tax_report_10_taxe", "balance_rounded"),
("tax_report_10_taxe_tag_no_rounding", "tax_report_10_taxe", "balance_from_tags"),
("tax_report_11_base_tag", "tax_report_11_base", "balance"),
("tax_report_11_base_tag_rounded", "tax_report_11_base", "balance_rounded"),
("tax_report_11_base_balance_from_tags", "tax_report_11_base", "balance_from_tags"),
("tax_report_11_base_adjustment", "tax_report_11_base", "adjustment"),
("tax_report_11_taxe_tag", "tax_report_11_taxe", "balance"),
("tax_report_11_taxe_tag_rounded", "tax_report_11_taxe", "balance_rounded"),
("tax_report_11_taxe_tag_no_rounding", "tax_report_11_taxe", "balance_from_tags"),
("tax_report_T1_base_tag", "tax_report_T1_base", "balance"),
("tax_report_T1_base_tag_rounded", "tax_report_T1_base", "balance_rounded"),
("tax_report_T1_base_balance_from_tags", "tax_report_T1_base", "balance_from_tags"),
("tax_report_T1_base_adjustment", "tax_report_T1_base", "adjustment"),
("tax_report_T1_taxe_tag", "tax_report_T1_taxe", "balance"),
("tax_report_T1_taxe_tag_rounded", "tax_report_T1_taxe", "balance_rounded"),
("tax_report_T1_taxe_tag_no_rounding", "tax_report_T1_taxe", "balance_from_tags"),
("tax_report_T2_base_tag", "tax_report_T2_base", "balance"),
("tax_report_T2_base_tag_rounded", "tax_report_T2_base", "balance_rounded"),
("tax_report_T2_base_balance_from_tags", "tax_report_T2_base", "balance_from_tags"),
("tax_report_T2_base_adjustment", "tax_report_T2_base", "adjustment"),
("tax_report_T2_taxe_tag", "tax_report_T2_taxe", "balance"),
("tax_report_T2_taxe_tag_rounded", "tax_report_T2_taxe", "balance_rounded"),
("tax_report_T2_taxe_tag_no_rounding", "tax_report_T2_taxe", "balance_from_tags"),
("tax_report_T3_base_tag", "tax_report_T3_base", "balance"),
("tax_report_T3_base_tag_rounded", "tax_report_T3_base", "balance_rounded"),
("tax_report_T3_base_balance_from_tags", "tax_report_T3_base", "balance_from_tags"),
("tax_report_T3_base_adjustment", "tax_report_T3_base", "adjustment"),
("tax_report_T3_taxe_tag", "tax_report_T3_taxe", "balance"),
("tax_report_T3_taxe_tag_rounded", "tax_report_T3_taxe", "balance_rounded"),
("tax_report_T3_taxe_tag_no_rounding", "tax_report_T3_taxe", "balance_from_tags"),
("tax_report_T4_base_tag", "tax_report_T4_base", "balance"),
("tax_report_T4_base_tag_rounded", "tax_report_T4_base", "balance_rounded"),
("tax_report_T4_base_balance_from_tags", "tax_report_T4_base", "balance_from_tags"),
("tax_report_T4_base_adjustment", "tax_report_T4_base", "adjustment"),
("tax_report_T4_taxe_tag", "tax_report_T4_taxe", "balance"),
("tax_report_T4_taxe_tag_balance", "tax_report_T4_taxe", "balance_rounded"),
("tax_report_T4_taxe_tag_no_rounding", "tax_report_T4_taxe", "balance_from_tags"),
("tax_report_T5_base_tag", "tax_report_T5_base", "balance"),
("tax_report_T5_base_tag_rounded", "tax_report_T5_base", "balance_rounded"),
("tax_report_T5_base_balance_from_tags", "tax_report_T5_base", "balance_from_tags"),
("tax_report_T5_base_adjustment", "tax_report_T5_base", "adjustment"),
("tax_report_T5_taxe_tag", "tax_report_T5_taxe", "balance"),
("tax_report_T5_taxe_tag_rounded", "tax_report_T5_taxe", "balance_rounded"),
("tax_report_T5_taxe_tag_no_rounding", "tax_report_T5_taxe", "balance_from_tags"),
("tax_report_T6_base_tag", "tax_report_T6_base", "balance"),
("tax_report_T6_base_tag_balance", "tax_report_T6_base", "balance_rounded"),
("tax_report_T6_base_balance_from_tags", "tax_report_T6_base", "balance_from_tags"),
("tax_report_T6_base_adjustment", "tax_report_T6_base", "adjustment"),
("tax_report_T6_taxe_tag", "tax_report_T6_taxe", "balance"),
("tax_report_T6_taxe_tag_balance", "tax_report_T6_taxe", "balance_rounded"),
("tax_report_T6_taxe_tag_no_rounding", "tax_report_T6_taxe", "balance_from_tags"),
("tax_report_T7_base_tag", "tax_report_T7_base", "balance"),
("tax_report_T7_base_tag_rounded", "tax_report_T7_base", "balance_rounded"),
("tax_report_T7_base_balance_from_tags", "tax_report_T7_base", "balance_from_tags"),
("tax_report_T7_base_adjustment", "tax_report_T7_base", "adjustment"),
("tax_report_T7_taxe_tag", "tax_report_T7_taxe", "balance"),
("tax_report_T7_taxe_balance_rounded", "tax_report_T7_taxe", "balance_rounded"),
("tax_report_T7_taxe_balance_from_tags", "tax_report_T7_taxe", "balance_from_tags"),
("tax_report_13_base_tag", "tax_report_13_base", "balance"),
("tax_report_13_base_tag_rounded", "tax_report_13_base", "balance_rounded"),
("tax_report_13_base_balance_from_tags", "tax_report_13_base", "balance_from_tags"),
("tax_report_13_base_adjustment", "tax_report_13_base", "adjustment"),
("tax_report_13_taxe_tag", "tax_report_13_taxe", "balance"),
("tax_report_13_taxe_balance_rounded", "tax_report_13_taxe", "balance_rounded"),
("tax_report_13_taxe_balance_from_tags", "tax_report_13_taxe", "balance_from_tags"),
("tax_report_14_base_tag", "tax_report_14_base", "balance"),
("tax_report_14_base_tag_rounded", "tax_report_14_base", "balance_rounded"),
("tax_report_14_base_balance_from_tags", "tax_report_14_base", "balance_from_tags"),
("tax_report_14_base_adjustment", "tax_report_14_base", "adjustment"),
("tax_report_14_taxe_tag", "tax_report_14_taxe", "balance"),
("tax_report_14_taxe_balance_rounded", "tax_report_14_taxe", "balance_rounded"),
("tax_report_14_taxe_balance_from_tags", "tax_report_14_taxe", "balance_from_tags"),
("tax_report_P1_base_tag", "tax_report_P1_base", "balance"),
("tax_report_P1_base_tag_rounded", "tax_report_P1_base", "balance_rounded"),
("tax_report_P1_base_balance_from_tags", "tax_report_P1_base", "balance_from_tags"),
("tax_report_P1_base_adjustment", "tax_report_P1_base", "adjustment"),
("tax_report_P1_taxe_tag", "tax_report_P1_taxe", "balance"),
("tax_report_P1_taxe_tag_rounded", "tax_report_P1_taxe", "balance_rounded"),
("tax_report_P1_taxe_tag_no_rounding", "tax_report_P1_taxe", "balance_from_tags"),
("tax_report_P2_base_tag", "tax_report_P2_base", "balance"),
("tax_report_P2_base_tag_rounded", "tax_report_P2_base", "balance_rounded"),
("tax_report_P2_base_balance_from_tags", "tax_report_P2_base", "balance_from_tags"),
("tax_report_P2_base_adjustment", "tax_report_P2_base", "adjustment"),
("tax_report_P2_taxe_tag", "tax_report_P2_taxe", "balance"),
("tax_report_P2_taxe_tag_rounded", "tax_report_P2_taxe", "balance_rounded"),
("tax_report_P2_taxe_tag_no_rounding", "tax_report_P2_taxe", "balance_from_tags"),
("tax_report_I1_base_tag", "tax_report_I1_base", "balance"),
("tax_report_I1_base_tag_rounded", "tax_report_I1_base", "balance_rounded"),
("tax_report_I1_base_balance_from_tags", "tax_report_I1_base", "balance_from_tags"),
("tax_report_I1_base_adjustment", "tax_report_I1_base", "adjustment"),
("tax_report_I1_taxe_tag", "tax_report_I1_taxe", "balance"),
("tax_report_I1_taxe_tag_rounded", "tax_report_I1_taxe", "balance_rounded"),
("tax_report_I1_taxe_tag_no_rounding", "tax_report_I1_taxe", "balance_from_tags"),
("tax_report_I2_base_tag", "tax_report_I2_base", "balance"),
("tax_report_I2_base_tag_rounded", "tax_report_I2_base", "balance_rounded"),
("tax_report_I2_base_balance_from_tags", "tax_report_I2_base", "balance_from_tags"),
("tax_report_I2_base_adjustment", "tax_report_I2_base", "adjustment"),
("tax_report_I2_taxe_tag", "tax_report_I2_taxe", "balance"),
("tax_report_I2_taxe_tag_rounded", "tax_report_I2_taxe", "balance_rounded"),
("tax_report_I2_taxe_tag_no_rounding", "tax_report_I2_taxe", "balance_from_tags"),
("tax_report_I3_base_tag", "tax_report_I3_base", "balance"),
("tax_report_I3_base_tag_rounded", "tax_report_I3_base", "balance_rounded"),
("tax_report_I3_base_balance_from_tags", "tax_report_I3_base", "balance_from_tags"),
("tax_report_I3_base_adjustment", "tax_report_I3_base", "adjustment"),
("tax_report_I3_taxe_tag", "tax_report_I3_taxe", "balance"),
("tax_report_I3_taxe_tag_rounded", "tax_report_I3_taxe", "balance_rounded"),
("tax_report_I3_taxe_tag_no_rounding", "tax_report_I3_taxe", "balance_from_tags"),
("tax_report_I4_base_tag", "tax_report_I4_base", "balance"),
("tax_report_I4_base_tag_rounded", "tax_report_I4_base", "balance_rounded"),
("tax_report_I4_base_balance_from_tags", "tax_report_I4_base", "balance_from_tags"),
("tax_report_I4_base_adjustment", "tax_report_I4_base", "adjustment"),
("tax_report_I4_taxe_tag", "tax_report_I4_taxe", "balance"),
("tax_report_I4_taxe_tag_rounded", "tax_report_I4_taxe", "balance_rounded"),
("tax_report_I4_taxe_tag_no_rounding", "tax_report_I4_taxe", "balance_from_tags"),
("tax_report_I5_base_tag", "tax_report_I5_base", "balance"),
("tax_report_I5_base_tag_rounded", "tax_report_I5_base", "balance_rounded"),
("tax_report_I5_base_balance_from_tags", "tax_report_I5_base", "balance_from_tags"),
("tax_report_I5_base_adjustment", "tax_report_I5_base", "adjustment"),
("tax_report_I5_taxe_tag", "tax_report_I5_taxe", "balance"),
("tax_report_I5_taxe_tag_rounded", "tax_report_I5_taxe", "balance_rounded"),
("tax_report_I5_taxe_tag_no_rounding", "tax_report_I5_taxe", "balance_from_tags"),
("tax_report_I6_base_tag", "tax_report_I6_base", "balance"),
("tax_report_I6_base_tag_rounded", "tax_report_I6_base", "balance_rounded"),
("tax_report_I6_base_balance_from_tags", "tax_report_I6_base", "balance_from_tags"),
("tax_report_I6_base_adjustment", "tax_report_I6_base", "adjustment"),
("tax_report_I6_taxe_tag", "tax_report_I6_taxe", "balance"),
("tax_report_I6_taxe_tag_rounded", "tax_report_I6_taxe", "balance_rounded"),
("tax_report_I6_taxe_tag_no_rounding", "tax_report_I6_taxe", "balance_from_tags"),
("tax_report_15_tag", "tax_report_15", "balance"),
("tax_report_15_balance_rounded", "tax_report_15", "balance_rounded"),
("tax_report_15_balance_from_tags", "tax_report_15", "balance_from_tags"),
("tax_report_15_1_tag", "tax_report_15_1", "balance"),
("tax_report_15_1_balance_rounded", "tax_report_15_1", "balance_rounded"),
("tax_report_15_1_balance_from_tags", "tax_report_15_1", "balance_from_tags"),
("tax_report_15_2_tag", "tax_report_15_2", "balance"),
("tax_report_15_2_balance_rounded", "tax_report_15_2", "balance_rounded"),
("tax_report_15_2_balance_from_tags", "tax_report_15_2", "balance_from_tags"),
("tax_report_5B_tag", "tax_report_5B", "balance"),
("tax_report_5B_balance_rounded", "tax_report_5B", "balance_rounded"),
("tax_report_5B_balance_from_tags", "tax_report_5B", "balance_from_tags"),
("tax_report_16_formula", "tax_report_16", "balance"),
("tax_report_17_tag", "tax_report_17", "balance"),
("tax_report_17_balance_rounded", "tax_report_17", "balance_rounded"),
("tax_report_17_balance_from_tags", "tax_report_17", "balance_from_tags"),
("tax_report_18_tag", "tax_report_18", "balance"),
("tax_report_18_balance_rounded", "tax_report_18", "balance_rounded"),
("tax_report_18_balance_from_tags", "tax_report_18", "balance_from_tags"),
("tax_report_19_tag", "tax_report_19", "balance"),
("tax_report_19_balance_rounded", "tax_report_19", "balance_rounded"),
("tax_report_19_balance_from_tags", "tax_report_19", "balance_from_tags"),
("tax_report_20_tag", "tax_report_20", "balance"),
("tax_report_20_balance_rounded", "tax_report_20", "balance_rounded"),
("tax_report_20_balance_from_tags", "tax_report_20", "balance_from_tags"),
("tax_report_21_tag", "tax_report_21", "balance"),
("tax_report_21_balance_from_tags", "tax_report_21", "balance_from_tags"),
("tax_report_22_applied_carryover", "tax_report_22", "_applied_carryover_balance"),
("tax_report_22_tag", "tax_report_22", "tag"),
("tax_report_22_balance_rounded", "tax_report_22", "balance_rounded"),
("tax_report_22_balance", "tax_report_22", "balance"),
("tax_report_2C_tag", "tax_report_2C", "balance"),
("tax_report_2C_balance_rounded", "tax_report_2C", "balance_rounded"),
("tax_report_2C_balance_from_tags", "tax_report_2C", "balance_from_tags"),
("tax_report_22A_tag", "tax_report_22A", "balance"),
("tax_report_22A_balance_rounded", "tax_report_22A", "balance_rounded"),
("tax_report_22A_balance_from_tags", "tax_report_22A", "balance_from_tags"),
("tax_report_23_formula", "tax_report_23", "balance"),
("tax_report_24_tag", "tax_report_24", "balance"),
("tax_report_24_balance_rounded", "tax_report_24", "balance_rounded"),
("tax_report_24_balance_from_tags", "tax_report_24", "balance_from_tags"),
("tax_report_2E_tag", "tax_report_2E", "balance"),
("tax_report_2E_balance_rounded", "tax_report_2E", "balance_rounded"),
("tax_report_2E_balance_from_tags", "tax_report_2E", "balance_from_tags"),
("tax_report_25_formula", "tax_report_25", "balance"),
("tax_report_25_adjustment", "tax_report_25", "adjustment"),
("tax_report_td_formula", "tax_report_TD", "balance"),
("tax_report_TICFE_tag", "tax_report_TICFE", "balance"),
("tax_report_TICGN_tag", "tax_report_TICGN", "balance"),
("tax_report_TICC_tag", "tax_report_TICC", "balance"),
("tax_report_TIC_total_formula", "tax_report_TIC_total", "balance"),
("tax_report_X1_tag", "tax_report_X1", "balance"),
("tax_report_X1_balance_rounded", "tax_report_X1", "balance_rounded"),
("tax_report_X1_balance_from_tags", "tax_report_X1", "balance_from_tags"),
("tax_report_X2_tag", "tax_report_X2", "balance"),
("tax_report_X2_balance_rounded", "tax_report_X2", "balance_rounded"),
("tax_report_X2_balance_from_tags", "tax_report_X2", "balance_from_tags"),
("tax_report_X3_tag", "tax_report_X3", "balance"),
("tax_report_X3_balance_rounded", "tax_report_X3", "balance_rounded"),
("tax_report_X3_balance_from_tags", "tax_report_X3", "balance_from_tags"),
("tax_report_X4_formula", "tax_report_X4", "balance"),
("tax_report_Y1_formula", "tax_report_Y1", "balance"),
("tax_report_Y2_formula", "tax_report_Y2", "balance"),
("tax_report_Y3_formula", "tax_report_Y3", "balance"),
("tax_report_Z1_tag", "tax_report_Z1", "balance"),
("tax_report_Z1_balance_rounded", "tax_report_Z1", "balance_rounded"),
("tax_report_Z1_balance_from_tags", "tax_report_Z1", "balance_from_tags"),
("tax_report_Z2_tag", "tax_report_Z2", "balance"),
("tax_report_Z2_balance_rounded", "tax_report_Z2", "balance_rounded"),
("tax_report_Z2_balance_from_tags", "tax_report_Z2", "balance_from_tags"),
("tax_report_Z3_tag", "tax_report_Z3", "balance"),
("tax_report_Z3_balance_rounded", "tax_report_Z3", "balance_rounded"),
("tax_report_Z3_balance_from_tags", "tax_report_Z3", "balance_from_tags"),
("tax_report_Z4_formula", "tax_report_Z4", "balance"),
("tax_report_26_external_tag", "tax_report_26_external", "balance"),
("tax_report_AA_tag", "tax_report_AA", "balance"),
("tax_report_AA_balance_rounded", "tax_report_AA", "balance_rounded"),
("tax_report_AA_balance_from_tags", "tax_report_AA", "balance_from_tags"),
("tax_report_27_formula", "tax_report_27", "balance"),
("tax_report_27_formula_temp", "tax_report_27", "_balance_temp"),
("tax_report_27_carryover", "tax_report_27", "_carryover_balance"),
("tax_report_28_formula", "tax_report_28", "balance"),
("tax_report_29_tag", "tax_report_29", "balance"),
("tax_report_29_balance_rounded", "tax_report_29", "balance_rounded"),
("tax_report_29_balance_from_tags", "tax_report_29", "balance_from_tags"),
("tax_report_32_formula", "tax_report_32", "balance"),
]
# Fix 1: insert missing ir_model_data entries for orphan expressions
cr.execute(
"""
SELECT imd.name, imd.res_id
FROM ir_model_data imd
WHERE imd.module = 'l10n_fr_account'
AND imd.model = 'account.report.line'
"""
)
line_xmlid_to_id = {row[0]: row[1] for row in cr.fetchall()}
cr.execute(
"""
SELECT res_id FROM ir_model_data
WHERE model = 'account.report.expression'
"""
)
known_expr_ids = {row[0] for row in cr.fetchall()}
inserted = 0
for expr_xmlid, line_xmlid, label in expressions:
line_id = line_xmlid_to_id.get(line_xmlid)
if not line_id:
continue
cr.execute(
"""
SELECT id FROM account_report_expression
WHERE report_line_id = %s AND label = %s
""",
(line_id, label),
)
row = cr.fetchone()
if not row:
continue
expr_id = row[0]
if expr_id in known_expr_ids:
continue
cr.execute(
"""
INSERT INTO ir_model_data (name, module, model, res_id, noupdate, write_date, create_date)
VALUES (%s, 'l10n_fr_account', 'account.report.expression', %s, false, NOW(), NOW())
ON CONFLICT DO NOTHING
""",
(expr_xmlid, expr_id),
)
known_expr_ids.add(expr_id)
inserted += 1
if inserted:
print(
f" [l10n_fr_account] Inserted {inserted} missing ir_model_data entries"
" for orphan expressions"
)
# Fix 2: delete orphan expressions obsolete in V18
# box_X5, box_Y4, box_Y5, box_Z5 now use aggregation_formula field
# instead of expression_ids, so their V17 expressions are obsolete.
obsolete_line_xmlids = [
"tax_report_X5",
"tax_report_Y4",
"tax_report_Y5",
"tax_report_Z5",
]
cr.execute(
"""
DELETE FROM account_report_expression
WHERE report_line_id IN (
SELECT res_id FROM ir_model_data
WHERE module = 'l10n_fr_account'
AND model = 'account.report.line'
AND name = ANY(%s)
)
AND id NOT IN (
SELECT res_id FROM ir_model_data
WHERE model = 'account.report.expression'
)
""",
(obsolete_line_xmlids,),
)
deleted = cr.rowcount
if deleted:
print(
f" [l10n_fr_account] Deleted {deleted} obsolete orphan expressions"
" (X5/Y4/Y5/Z5)"
)

View File

@@ -1,4 +1,11 @@
#!/bin/bash
set -euo pipefail
run_compose run -p 8018:8069 ou18 --config=/opt/odoo/auto/odoo.conf --stop-after-init -u all --workers 0 --log-level=debug --max-cron-threads=0 --limit-time-real=10000 --database=ou18 --load=base,web,openupgrade_framework
# --upgrade-path lists BOTH our custom migration scripts and OpenUpgrade's
# native ones. Passing --upgrade-path overrides config['upgrade_path'], and
# openupgrade_framework only sets that default when it is empty; so we must
# include the native openupgrade_scripts/scripts path explicitly, otherwise the
# native migration scripts would no longer run. Odoo appends every listed path
# to odoo.upgrade.__path__ and concatenates (does not override) the scripts
# found per module/version, so our script runs IN ADDITION to the native ones.
run_compose run -p 8018:8069 ou18 --config=/opt/odoo/auto/odoo.conf --stop-after-init -u all --workers 0 --log-level=debug --max-cron-threads=0 --limit-time-real=10000 --database=ou18 --load=base,web,openupgrade_framework --addons-path=/opt/odoo/auto/mig,/opt/odoo/auto/addons --upgrade-path=/opt/odoo/auto/upgrade,/opt/odoo/auto/addons/openupgrade_scripts/scripts