Compare commits

...

17 Commits

Author SHA1 Message Date
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
23 changed files with 2414 additions and 54 deletions

3
.gitignore vendored
View File

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

129
README.md
View File

@@ -9,6 +9,7 @@ A tool for migrating Odoo databases between major versions, using [OpenUpgrade](
- [Project Structure](#project-structure)
- [How It Works](#how-it-works)
- [Usage](#usage)
- [Resuming a Failed Migration](#resuming-a-failed-migration)
- [Customization](#customization)
- [Troubleshooting](#troubleshooting)
@@ -38,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
@@ -83,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
@@ -158,7 +162,7 @@ The script performs a **step-by-step migration** between each major version. For
### Running the Migration
```bash
./upgrade.sh <source_version> <target_version> <database_name> <source_service>
./upgrade.sh <source_version> <target_version> <database_name> <source_service> [--resume-from|-r <version>]
```
**Parameters:**
@@ -169,6 +173,11 @@ The script performs a **step-by-step migration** between each major version. For
| `database_name` | Database name | `my_prod_db` |
| `source_service` | Source Docker Compose service | `odoo14` |
**Options:**
| Option | Description |
|--------|-------------|
| `--resume-from <version>`, `-r <version>` | Resume from an intermediate checkpoint (see [Resuming a Failed Migration](#resuming-a-failed-migration)) |
**Example:**
```bash
./upgrade.sh 14 17 elabore_20241208 odoo14
@@ -239,6 +248,48 @@ compose run odoo17 shell -d ou17 --no-http --stop-after-init < lib/python/valida
- **JSON report** written to `/tmp/validation_views_<db>_<timestamp>.json`
- **Exit code**: `0` = success, `1` = errors found
## Resuming a Failed Migration
Each version hop copies the database before modifying it (`ou14` → `ou15` → `ou16` → …). If a migration crashes mid-way, the intermediate databases from completed hops are still intact and can be used as a restart point.
### How It Works
Use `--resume-from <version>` (or `-r <version>`) to restart from an intermediate checkpoint:
```bash
./upgrade.sh <source_version> <target_version> <database_name> <source_service> --resume-from <checkpoint_version>
```
When a checkpoint is specified, the script:
- **Skips** the initial database and filestore copy
- **Skips** `prepare_db.sh` (already done before the crash)
- **Starts the migration loop** from `checkpoint_version + 1`
### Example
A migration from 14 to 18 crashes during the 16→17 hop. The database `ou15` was successfully created. Resume from there:
```bash
./upgrade.sh 14 18 my_database odoo14 --resume-from 15
```
This runs: `16.0 → 17.0 → 18.0`, starting from `ou15`.
### Constraints
- The checkpoint version must be strictly between the source and target versions.
- The intermediate database (`ou<version>`) and its filestore must exist before resuming.
### Restarting From Scratch
To restart a full migration from the beginning (ignoring all intermediate databases):
```bash
./upgrade.sh 14 18 my_database odoo14
```
The script automatically drops and recreates the final target database (`ou18`) if it already exists.
## Customization
### Version Scripts
@@ -308,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):
@@ -320,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
@@ -365,10 +460,30 @@ The script works on a **copy** of the original database. You can restart as many
### Viewing Detailed Logs
Odoo/OpenUpgrade logs are displayed in real-time. For a problematic migration:
All output (scripts, Docker containers, OpenUpgrade logs) is automatically saved to `migration.log` at the project root. The file is overwritten at the start of each run (full migration or resume).
```bash
# Follow live output while also saving to file (already done automatically)
tail -f migration.log
# Search for errors after the fact
grep -i error migration.log
# View with ANSI colors preserved (less handles CRLF line endings natively)
less -R migration.log
# Plain text output without CRLF artifacts
cat migration.log | col -b
```
> **Note:** `migration.log` is recorded via a pseudo-TTY (`script`), which preserves
> color output in the terminal. As a side effect, the file uses `\r\n` line endings.
> Use `less -R` or `col -b` for clean viewing.
For a problematic migration:
1. Note the version where the error occurs
2. Check the logs to identify the problematic module/table
2. Check `migration.log` to identify the problematic module/table
3. Add a fix in the `pre_upgrade.sh` for that version
4. Restart the migration

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

@@ -14,7 +14,7 @@ readonly FILESTORE_SUBPATH="var/lib/odoo/filestore"
check_required_commands() {
local missing=()
for cmd in docker compose sudo rsync; do
for cmd in docker compose sudo rsync yq; do
if ! command -v "$cmd" &>/dev/null; then
missing+=("$cmd")
fi
@@ -100,7 +100,146 @@ 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:
# addons_obsolete : modules that became obsolete
# addons_core : modules merged into Odoo Core
# addons_renamed : renamed modules (format "old_name -> new_name")
# addons_truly_missing : modules that are genuinely missing
#
# Prerequisites: ORIGIN_VERSION and FINAL_VERSION must be exported.
classify_missing_addons() {
local missing_addons_raw="$1"
addons_obsolete=()
addons_core=()
addons_renamed=()
addons_truly_missing=()
# Convert the string into an array (one entry per line)
local -a missing=()
while IFS= read -r line; do
[[ -n "$line" ]] && missing+=("$line")
done <<< "$missing_addons_raw"
if [[ ${#missing[@]} -eq 0 ]]; then
return 0
fi
# Build lookup tables from all known_changes.yaml files in traversed versions
local -A known_obsolete=()
local -A known_core=()
local -A known_renamed=()
local versions_path="${PROJECT_ROOT}/versions"
local v
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 mod
while IFS= read -r mod; do
[[ -n "$mod" && "$mod" != "null" ]] && known_obsolete["$mod"]=1
done < <(yq '.obsolete[]?' "$yaml_file" 2>/dev/null)
while IFS= read -r mod; do
[[ -n "$mod" && "$mod" != "null" ]] && known_core["$mod"]=1
done < <(yq '.merged_in_core[]?' "$yaml_file" 2>/dev/null)
local count
count=$(yq '.renamed | length' "$yaml_file" 2>/dev/null)
if [[ "$count" =~ ^[0-9]+$ && "$count" -gt 0 ]]; then
local i
for ((i = 0; i < count; i++)); do
local old new
old=$(yq ".renamed[$i].old" "$yaml_file" 2>/dev/null)
new=$(yq ".renamed[$i].new" "$yaml_file" 2>/dev/null)
[[ -n "$old" && "$old" != "null" ]] && known_renamed["$old"]="$new"
done
fi
done
# Classify each missing module
local mod
for mod in "${missing[@]}"; do
if [[ -n "${known_obsolete[$mod]:-}" ]]; then
addons_obsolete+=("$mod")
elif [[ -n "${known_core[$mod]:-}" ]]; then
addons_core+=("$mod")
elif [[ -n "${known_renamed[$mod]:-}" ]]; then
addons_renamed+=("${mod} -> ${known_renamed[$mod]}")
else
addons_truly_missing+=("$mod")
fi
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)

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Regenerate all POS order inalterability hashes and sequence numbers.
This script should only be called when the SQL pre-check in finalize_db.sh
has confirmed that some pos_orders are missing l10n_fr_hash or
l10n_fr_secure_sequence_number.
It resets ALL sequence numbers and hashes from scratch, in chronological order
(date_order ASC, id ASC), and re-establishes the full hash chain.
Usage (via Odoo shell):
odoo-shell -d <db_name> < regenerate_pos_hashes.py
"""
import logging
from hashlib import sha256
_logger = logging.getLogger(__name__)
def regenerate_all():
PosOrder = env['pos.order']
Company = env['res.company']
for company in Company.search([]):
if not company._is_accounting_unalterable():
continue
print(f"\n{'='*60}")
print(f" Company: {company.name}")
print(f"{'='*60}")
orders = PosOrder.search([
('state', 'in', ['paid', 'done', 'invoiced']),
('company_id', '=', company.id),
], order='date_order ASC, id ASC')
if not orders:
print(" No orders to process.")
continue
n = len(orders)
# ── Step 1: Reset all fields ─────────────────────────────
print("\n--- Resetting fields ---")
env.cr.execute("""
UPDATE pos_order
SET l10n_fr_hash = NULL,
l10n_fr_secure_sequence_number = NULL,
previous_order_id = NULL
WHERE id IN %s
""", (tuple(orders.ids),))
env.invalidate_all()
env.cr.commit()
print(f"{n} orders reset")
# ── Step 2: Assign sequence numbers ──────────────────────
print("\n--- Assigning sequence numbers ---")
for i, order in enumerate(orders, start=1):
env.cr.execute("""
UPDATE pos_order
SET l10n_fr_secure_sequence_number = %s
WHERE id = %s
""", (i, order.id))
env.invalidate_all()
env.cr.commit()
print(f" ✓ Sequence numbers 1 → {n} assigned")
# ── Step 3: Compute previous_order_id ────────────────────
print("\n--- Computing previous_order_id ---")
orders = PosOrder.search([
('state', 'in', ['paid', 'done', 'invoiced']),
('company_id', '=', company.id),
], order='l10n_fr_secure_sequence_number ASC')
orders._compute_previous_order()
env.cr.commit()
print(" ✓ OK")
# ── Step 4: Compute hashes (with cache invalidation after each write) ─
print("\n--- Computing hashes ---")
success = errors = 0
for idx in range(n):
order = PosOrder.search([
('company_id', '=', company.id),
('l10n_fr_secure_sequence_number', '=', idx + 1),
], limit=1)
if not order:
continue
try:
order._compute_string_to_hash()
prev = order.previous_order_id
prev_hash = prev.l10n_fr_hash if prev else ''
if not prev_hash:
prev_hash = ''
computed_hash = sha256(
(prev_hash + order.l10n_fr_string_to_hash).encode('utf-8')
).hexdigest()
env.cr.execute(
"UPDATE pos_order SET l10n_fr_hash = %s WHERE id = %s",
(computed_hash, order.id)
)
env.invalidate_all()
print(f"{order.name} (seq {idx+1})")
success += 1
except Exception as e:
print(f"{order.name} (seq {idx+1}) : {e}")
errors += 1
import traceback
traceback.print_exc()
env.cr.commit()
remaining = PosOrder.search_count([
('state', 'in', ['paid', 'done', 'invoiced']),
('company_id', '=', company.id),
'|', ('l10n_fr_hash', '=', False),
('l10n_fr_hash', '=', None),
])
print(f"\n Final result: {success} hashes written, {errors} errors, "
f"{remaining} remaining")
# Reset sequence for future orders
seq = company.l10n_fr_pos_cert_sequence_id
if seq:
env.cr.execute(
"UPDATE ir_sequence SET number_next = %s WHERE id = %s",
(n + 1, seq.id)
)
print(f" ✓ ir_sequence number_next set to {n + 1}")
print("\n✓ Done.")
regenerate_all()

View File

@@ -12,17 +12,6 @@ CLEANUP_SQL=$(cat <<'EOF'
DROP SEQUENCE IF EXISTS base_registry_signaling;
DROP SEQUENCE IF EXISTS base_cache_signaling;
-- Reset website templates to their original state.
-- Views with arch_fs (file source) that have been customized (arch_db not null)
-- are reset to use the file version, EXCEPT for actual website pages which
-- contain user content that must be preserved.
UPDATE ir_ui_view
SET arch_db = NULL
WHERE arch_fs IS NOT NULL
AND arch_fs LIKE 'website/%'
AND arch_db IS NOT NULL
AND id NOT IN (SELECT view_id FROM website_page);
-- Purge compiled frontend assets (CSS/JS bundles).
-- These cached files reference old asset versions and must be regenerated
-- by Odoo after migration to avoid broken stylesheets and scripts.
@@ -34,6 +23,76 @@ EOF
)
query_postgres_container "$CLEANUP_SQL" "$DB_NAME"
# ────────────────────────────────────────────────────────────
# Fix orphan ir_filters pointing to deleted ir_actions
#
# During OpenUpgrade migrations, ir_actions records can be deleted and
# recreated with new ids, while ir_filters.action_id (custom user filters)
# still points to the old, now-missing action. When a user opens the custom
# filters list, Odoo tries to resolve the display_name of the many2one
# action_id and raises "Record does not exist or has been deleted
# (ir.actions.actions(<id>,))". We detach such filters (set action_id = NULL),
# which keeps the filter usable and only drops the broken action link.
# ────────────────────────────────────────────────────────────
FIX_ORPHAN_FILTERS_SQL=$(cat <<'EOF'
DO $$
DECLARE
orphan_count INTEGER;
remaining INTEGER;
BEGIN
SELECT COUNT(*) INTO orphan_count
FROM ir_filters f
LEFT JOIN ir_actions a ON a.id = f.action_id
WHERE f.action_id IS NOT NULL AND a.id IS NULL;
IF orphan_count > 0 THEN
UPDATE ir_filters f
SET action_id = NULL
FROM (
SELECT f2.id
FROM ir_filters f2
LEFT JOIN ir_actions a ON a.id = f2.action_id
WHERE f2.action_id IS NOT NULL AND a.id IS NULL
) orphans
WHERE f.id = orphans.id;
RAISE NOTICE 'Detached % orphan ir_filters pointing to deleted ir_actions', orphan_count;
ELSE
RAISE NOTICE 'OK: no orphan ir_filters to fix';
END IF;
-- Verification
SELECT COUNT(*) INTO remaining
FROM ir_filters f
LEFT JOIN ir_actions a ON a.id = f.action_id
WHERE f.action_id IS NOT NULL AND a.id IS NULL;
IF remaining > 0 THEN
RAISE WARNING 'Still % orphan ir_filters remaining after fix', remaining;
END IF;
END $$;
EOF
)
echo "Fixing orphan ir_filters pointing to deleted ir_actions..."
query_postgres_container "$FIX_ORPHAN_FILTERS_SQL" "$DB_NAME"
# Reset modules still marked as 'to upgrade' before launching the Odoo shell
# scripts below. Loading the registry in `odoo shell` re-triggers the upgrade
# of these modules, which can fail on broken views (e.g. website_sale
# TypeError). The controlled `-u all` at the end of this script performs the
# real update afterwards. We deliberately do NOT touch 'to install' modules:
# forcing them to 'installed' would skip their install scripts entirely.
#
# Uncomment the SELECT below to trace which modules were pending upgrade
# before we neutralize their state (useful if the `-u all` above is removed).
# query_postgres_container "
# SELECT name FROM ir_module_module WHERE state = 'to upgrade' ORDER BY name;
# " "$DB_NAME" || true
query_postgres_container "
UPDATE ir_module_module
SET state = 'installed'
WHERE state = 'to upgrade';
" "$DB_NAME" || true
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHON_SCRIPT="${SCRIPT_DIR}/lib/python/fix_duplicated_views.py"
@@ -44,12 +103,40 @@ PYTHON_SCRIPT="${SCRIPT_DIR}/lib/python/cleanup_modules.py"
echo "Uninstall obsolete add-ons with script $PYTHON_SCRIPT ..."
exec_python_script_in_odoo_shell "$DB_NAME" "$DB_NAME" "$PYTHON_SCRIPT"
# ────────────────────────────────────────────────────────────
# Regenerate POS inalterability hashes if needed
# ────────────────────────────────────────────────────────────
HASHES_NEEDED=$(query_postgres_container "
SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'pos_order'
" "$DB_NAME")
if [[ "$HASHES_NEEDED" =~ ^[0-9]+$ && "$HASHES_NEEDED" -gt 0 ]]; then
HASHES_NEEDED=$(query_postgres_container "
SELECT COUNT(*)
FROM pos_order po
JOIN res_company rc ON rc.id = po.company_id
WHERE po.state IN ('paid', 'done', 'invoiced')
AND rc.l10n_fr_pos_cert_sequence_id IS NOT NULL
AND (po.l10n_fr_hash IS NULL OR po.l10n_fr_secure_sequence_number IS NULL)
" "$DB_NAME")
fi
if [[ "$HASHES_NEEDED" =~ ^[0-9]+$ && "$HASHES_NEEDED" -gt 0 ]]; then
echo ""
echo "Found $HASHES_NEEDED pos.order(s) with missing inalterability hash or sequence number."
echo "Regenerating all POS hashes..."
PYTHON_SCRIPT="${SCRIPT_DIR}/lib/python/regenerate_pos_hashes.py"
exec_python_script_in_odoo_shell "$DB_NAME" "$DB_NAME" "$PYTHON_SCRIPT"
echo "POS hash regeneration completed."
else
echo "No missing POS hashes detected."
fi
# Give back the right to user to access to the tables
# docker exec -u 70 "$DB_CONTAINER_NAME" pgm chown "$FINALE_SERVICE_NAME" "$DB_NAME"
# Launch Odoo with database in finale version to run all updates
run_compose --debug run "$ODOO_SERVICE" -u all --log-level=debug --stop-after-init --no-http
run_compose --debug run "$ODOO_SERVICE" -u all --log-level=debug --stop-after-init --no-http --load=base,web,openupgrade_framework
echo ""
echo "Running post-migration view validation..."

View File

@@ -60,11 +60,57 @@ echo "Retrieve missing addons..."
missing_addons=$(query_postgres_container "$SQL_MISSING_ADDONS" "$DB_NAME")
log_step "ADD-ONS CHECK"
echo "Installed add-ons not available in final Odoo version:"
echo "$missing_addons"
confirm_or_exit "Do you accept to migrate with these add-ons still installed?"
classify_missing_addons "$missing_addons"
if [[ ${#addons_obsolete[@]} -gt 0 ]]; then
log_info "Obsolete modules (${#addons_obsolete[@]}):"
printf "%s\n" "${addons_obsolete[@]}"
echo ""
fi
if [[ ${#addons_core[@]} -gt 0 ]]; then
log_info "Merged into Odoo Core (${#addons_core[@]}):"
printf "%s\n" "${addons_core[@]}"
echo ""
fi
if [[ ${#addons_renamed[@]} -gt 0 ]]; then
log_info "Renamed modules (${#addons_renamed[@]}):"
printf "%s\n" "${addons_renamed[@]}"
echo ""
fi
if [[ ${#addons_truly_missing[@]} -gt 0 ]]; then
log_warn "Truly missing modules (${#addons_truly_missing[@]}):"
printf "%s\n" "${addons_truly_missing[@]}"
echo ""
confirm_or_exit "Do you accept to migrate with these add-ons truly missing?"
else
log_info "No truly missing modules — all accounted for."
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

@@ -4,13 +4,20 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/common.sh"
readonly LOG_FILE="${SCRIPT_DIR}/migration.log"
if [[ -z "${_MIGRATION_LOGGING:-}" ]]; then
rm -f "$LOG_FILE"
export _MIGRATION_LOGGING=1
exec script -q -c "$(printf '%q ' "$0" "$@")" "$LOG_FILE"
fi
####################
# USAGE & ARGUMENTS
####################
usage() {
cat <<EOF >&2
Usage: $0 <origin_version> <final_version> <db_name> <service_name>
Usage: $0 <origin_version> <final_version> <db_name> <service_name> [--resume-from|-r <version>]
Arguments:
origin_version Origin Odoo version number (e.g., 12 for version 12.0)
@@ -18,29 +25,78 @@ Arguments:
db_name Name of the database to migrate
service_name Name of the origin Odoo service (docker compose service)
Example:
$0 14 16 elabore_20241208 odoo14
Options:
--resume-from, -r <version>
Resume migration from an already-migrated intermediate
database (e.g., ou15). Skips the initial DB copy and
prepare_db.sh phases. The intermediate DB must exist.
Examples:
$0 14 18 elabore_20241208 odoo14
$0 14 18 elabore_20241208 odoo14 --resume-from 15
$0 14 18 elabore_20241208 odoo14 -r 15
EOF
exit 1
}
if [[ $# -lt 4 ]]; then
log_error "Missing arguments. Expected 4, got $#."
log_error "Missing arguments. Expected at least 4, got $#."
usage
fi
check_required_commands
readonly ORIGIN_VERSION="$1"
readonly FINAL_VERSION="$2"
export ORIGIN_VERSION="$1"
readonly ORIGIN_VERSION
export FINAL_VERSION="$2"
readonly FINAL_VERSION
readonly ORIGIN_DB_NAME="$3"
readonly ORIGIN_SERVICE_NAME="$4"
# Parse optional --resume-from / -r flag
RESUME_FROM_VERSION=""
shift 4
while [[ $# -gt 0 ]]; do
case "$1" in
--resume-from|-r)
if [[ $# -lt 2 ]]; then
log_error "Option '$1' requires a version number argument."
usage
fi
RESUME_FROM_VERSION="$2"
shift 2
;;
*)
log_error "Unknown option: '$1'"
usage
;;
esac
done
readonly RESUME_FROM_VERSION
readonly COPY_DB_NAME="ou${ORIGIN_VERSION}"
export FINALE_DB_NAME="ou${FINAL_VERSION}"
readonly FINALE_DB_NAME
readonly FINALE_SERVICE_NAME="${FINALE_DB_NAME}"
# Validate --resume-from value if provided
if [[ -n "$RESUME_FROM_VERSION" ]]; then
if ! [[ "$RESUME_FROM_VERSION" =~ ^[0-9]+$ ]]; then
log_error "--resume-from value must be a numeric version number (got: '$RESUME_FROM_VERSION')."
exit 1
fi
if [[ "$RESUME_FROM_VERSION" -le "$ORIGIN_VERSION" ]]; then
log_error "--resume-from ($RESUME_FROM_VERSION) must be strictly greater than origin version ($ORIGIN_VERSION)."
log_error "To start a fresh migration, run without --resume-from."
exit 1
fi
if [[ "$RESUME_FROM_VERSION" -ge "$FINAL_VERSION" ]]; then
log_error "--resume-from ($RESUME_FROM_VERSION) must be strictly less than final version ($FINAL_VERSION)."
log_error "Nothing to migrate: checkpoint is at or beyond the target version."
exit 1
fi
fi
readarray -t postgres_containers < <(docker ps --format '{{.Names}}' | grep postgres || true)
if [[ ${#postgres_containers[@]} -eq 0 ]]; then
@@ -59,8 +115,13 @@ readonly POSTGRES_SERVICE_NAME
log_step "INPUT PARAMETERS"
log_info "Origin version .......... $ORIGIN_VERSION"
log_info "Final version ........... $FINAL_VERSION"
log_info "Origin DB name ........... $ORIGIN_DB_NAME"
log_info "Origin DB name .......... $ORIGIN_DB_NAME"
log_info "Origin service name ..... $ORIGIN_SERVICE_NAME"
if [[ -n "$RESUME_FROM_VERSION" ]]; then
log_info "Resume from version ..... $RESUME_FROM_VERSION (ou${RESUME_FROM_VERSION})"
else
log_info "Resume from version ..... none (full migration)"
fi
log_step "COMPUTED GLOBAL VARIABLES"
log_info "Copy DB name ............. $COPY_DB_NAME"
@@ -72,20 +133,42 @@ log_info "Postgres service name .... $POSTGRES_SERVICE_NAME"
log_step "CHECKS ALL NEEDED COMPONENTS ARE AVAILABLE"
db_exists=$(docker exec -it -u 70 "$POSTGRES_SERVICE_NAME" psql -tc "SELECT 1 FROM pg_database WHERE datname = '$ORIGIN_DB_NAME'" | tr -d '[:space:]')
if [[ "$db_exists" ]]; then
log_info "Database '$ORIGIN_DB_NAME' found."
else
log_error "Database '$ORIGIN_DB_NAME' not found in the local postgres service. Please add it and restart the upgrade process."
exit 1
fi
if [[ -n "$RESUME_FROM_VERSION" ]]; then
readonly RESUME_DB_NAME="ou${RESUME_FROM_VERSION}"
filestore_path="${DATASTORE_PATH}/${ORIGIN_SERVICE_NAME}/${FILESTORE_SUBPATH}/${ORIGIN_DB_NAME}"
if [[ -d "$filestore_path" ]]; then
log_info "Filestore '$filestore_path' found."
resume_db_exists=$(docker exec -u 70 "$POSTGRES_SERVICE_NAME" psql -tc "SELECT 1 FROM pg_database WHERE datname = '${RESUME_DB_NAME}'" | tr -d '[:space:]')
if [[ "$resume_db_exists" ]]; then
log_info "Checkpoint database '${RESUME_DB_NAME}' found."
else
log_error "Checkpoint database '${RESUME_DB_NAME}' not found in the local postgres service."
log_error "Ensure the migration up to version ${RESUME_FROM_VERSION} completed successfully before resuming."
exit 1
fi
resume_filestore_path="${DATASTORE_PATH}/${RESUME_DB_NAME}/${FILESTORE_SUBPATH}/${RESUME_DB_NAME}"
if [[ -d "$resume_filestore_path" ]]; then
log_info "Checkpoint filestore '${resume_filestore_path}' found."
else
log_error "Checkpoint filestore '${resume_filestore_path}' not found."
log_error "Ensure the filestore for '${RESUME_DB_NAME}' is intact before resuming."
exit 1
fi
else
log_error "Filestore '$filestore_path' not found, please add it and restart the upgrade process."
exit 1
db_exists=$(docker exec -u 70 "$POSTGRES_SERVICE_NAME" psql -tc "SELECT 1 FROM pg_database WHERE datname = '$ORIGIN_DB_NAME'" | tr -d '[:space:]')
if [[ "$db_exists" ]]; then
log_info "Database '$ORIGIN_DB_NAME' found."
else
log_error "Database '$ORIGIN_DB_NAME' not found in the local postgres service. Please add it and restart the upgrade process."
exit 1
fi
filestore_path="${DATASTORE_PATH}/${ORIGIN_SERVICE_NAME}/${FILESTORE_SUBPATH}/${ORIGIN_DB_NAME}"
if [[ -d "$filestore_path" ]]; then
log_info "Filestore '$filestore_path' found."
else
log_error "Filestore '$filestore_path' not found, please add it and restart the upgrade process."
exit 1
fi
fi
log_step "LAUNCH VIRGIN ODOO IN FINAL VERSION"
@@ -100,24 +183,36 @@ run_compose --debug run "$FINALE_SERVICE_NAME" -i base --stop-after-init --no-ht
log_info "Model database in final Odoo version created."
log_step "COPY ORIGINAL COMPONENTS"
if [[ -z "$RESUME_FROM_VERSION" ]]; then
log_step "COPY ORIGINAL COMPONENTS"
copy_database "$ORIGIN_DB_NAME" "$COPY_DB_NAME" "$COPY_DB_NAME"
log_info "Original database copied to ${COPY_DB_NAME}@${COPY_DB_NAME}."
copy_database "$ORIGIN_DB_NAME" "$COPY_DB_NAME" "$COPY_DB_NAME"
log_info "Original database copied to ${COPY_DB_NAME}@${COPY_DB_NAME}."
copy_filestore "$ORIGIN_SERVICE_NAME" "$ORIGIN_DB_NAME" "$COPY_DB_NAME" "$COPY_DB_NAME"
log_info "Original filestore copied."
copy_filestore "$ORIGIN_SERVICE_NAME" "$ORIGIN_DB_NAME" "$COPY_DB_NAME" "$COPY_DB_NAME"
log_info "Original filestore copied."
else
log_step "COPY ORIGINAL COMPONENTS — SKIPPED (resuming from checkpoint ou${RESUME_FROM_VERSION})"
fi
log_step "PATH OF MIGRATION"
readarray -t versions < <(seq $((ORIGIN_VERSION + 1)) "$FINAL_VERSION")
log_info "Migration path is ${versions[*]}"
if [[ -n "$RESUME_FROM_VERSION" ]]; then
readarray -t versions < <(seq $((RESUME_FROM_VERSION + 1)) "$FINAL_VERSION")
log_info "Resuming migration from ou${RESUME_FROM_VERSION} — path is ${versions[*]}"
else
readarray -t versions < <(seq $((ORIGIN_VERSION + 1)) "$FINAL_VERSION")
log_info "Migration path is ${versions[*]}"
fi
log_step "DATABASE PREPARATION"
"${SCRIPT_DIR}/scripts/prepare_db.sh" "$COPY_DB_NAME" "$COPY_DB_NAME" "$FINALE_DB_NAME" "$FINALE_SERVICE_NAME"
if [[ -z "$RESUME_FROM_VERSION" ]]; then
log_step "DATABASE PREPARATION"
"${SCRIPT_DIR}/scripts/prepare_db.sh" "$COPY_DB_NAME" "$COPY_DB_NAME" "$FINALE_DB_NAME" "$FINALE_SERVICE_NAME"
else
log_step "DATABASE PREPARATION — SKIPPED (resuming from checkpoint ou${RESUME_FROM_VERSION})"
fi
log_step "UPGRADE PROCESS"
@@ -137,3 +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

@@ -0,0 +1,10 @@
# Modules that became obsolete in version 13.0
obsolete: []
# Modules merged into Odoo Core in version 13.0
merged_in_core: []
# Modules renamed in version 13.0
renamed: []
# - old: old_name
# new: new_name

View File

@@ -0,0 +1,10 @@
# Modules that became obsolete in version 14.0
obsolete: []
# Modules merged into Odoo Core in version 14.0
merged_in_core: []
# Modules renamed in version 14.0
renamed: []
# - old: old_name
# new: new_name

View File

@@ -0,0 +1,16 @@
# Modules that became obsolete in version 15.0
obsolete:
- l10n_ch_base_bank
- l10n_ch_isrb
- project_timeline_task_dependency
- account_reconcile_reconciliation_date
# Modules merged into Odoo Core in version 15.0
merged_in_core:
- project_category
- project_deadline
# Modules renamed in version 15.0
renamed:
- old: crm_phone
new: crm_phonecall

View File

@@ -0,0 +1,32 @@
# Modules that became obsolete in version 16.0
obsolete:
- account_reconciliation_widget
- account_statement_import
- account_statement_import_file_reconciliation_widget
# Modules merged into Odoo Core in version 16.0
merged_in_core:
- account_balance_line
- l10n_ch_states
- project_task_dependency
- web_ir_actions_act_view_reload
- mail_activity_creator
- website_sale_require_login
# Modules renamed in version 16.0
renamed:
- old: account_statement_import_file_reconcile_oca
new: account_statement_import_file_reconcile_oca
- old: mass_editing
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

@@ -0,0 +1,13 @@
# Modules that became obsolete in version 17.0
obsolete: []
# Modules merged into Odoo Core in version 17.0
merged_in_core:
- project_list
- web_advanced_search
- web_listview_range_select
# Modules renamed in version 17.0
renamed: []
# - old: old_name
# new: new_name

View File

@@ -51,6 +51,58 @@ 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
# Copy filestores
copy_filestore ou16 ou16 ou17 ou17 || exit 1

View File

@@ -0,0 +1,36 @@
# Modules that became obsolete in version 18.0
obsolete:
- account_payment_paired_internal_transfer
- account_reconciliation_widget
# Modules merged into Odoo Core in version 18.0
merged_in_core:
- account_payment_partner
- hr_expense_report_merge_attachment
# Modules renamed in version 18.0
renamed:
# Refactor bank-payment-alternative
- old: account_payment_mode
new: account_payment_base_oca
- old: account_payment_sale
new: account_payment_base_oca_sale
- old: account_payment_order
new: account_payment_batch_oca
- old: account_payment_order_tier_validation
new: account_payment_batch_oca_tier_validation
- old: account_banking_pain_base
new: account_payment_sepa_base
- old: account_banking_sepa_credit_transfer
new: account_payment_sepa_credit_transfer
- old: account_banking_mandate
new: account_payment_mandate
- old: account_banking_mandate_sale
new: account_payment_mandate_sale
- old: account_banking_sepa_direct_debit
new: account_payment_sepa_direct_debit
# End refactor
- old: base_delivery_carrier_label
new: delivery_shipping_label_default
- old: l10n_fr_oca
new: l10n_fr_account_oca

View File

@@ -3,4 +3,406 @@ set -euo pipefail
echo "Post migration to 18.0..."
#compose --debug run ou18 -u base --stop-after-init --no-http
# ============================================================================
# BANK-PAYMENT -> BANK-PAYMENT-ALTERNATIVE DATA MIGRATION
# Source PR: https://github.com/OCA/bank-payment-alternative/pull/42
#
# Only executed if account_payment_mode table exists (module was installed).
# ============================================================================
# Check if account_payment_mode table exists (meaning the module was installed before migration)
BANK_PAYMENT_TABLE_EXISTS=$(query_postgres_container "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'account_payment_mode';" ou18 2>/dev/null | grep -E '^\s*[0-9]+' | tr -d ' ' || echo "0")
if [ "$BANK_PAYMENT_TABLE_EXISTS" -gt 0 ]; then
echo "Table account_payment_mode exists, proceeding with bank-payment data migration..."
BANK_PAYMENT_POST_SQL=$(cat <<'EOF'
DO $$
DECLARE
mode_rec RECORD;
new_line_id INTEGER;
journal_rec RECORD;
BEGIN
IF NOT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'account_payment_mode') THEN
RAISE NOTICE 'No account_payment_mode table found, skipping bank-payment migration';
RETURN;
END IF;
RAISE NOTICE 'Starting bank-payment to bank-payment-alternative migration...';
ALTER TABLE account_payment_method_line
ADD COLUMN IF NOT EXISTS old_payment_mode_id INT,
ADD COLUMN IF NOT EXISTS old_refund_payment_mode_id INT;
FOR mode_rec IN
SELECT id, name, company_id, payment_method_id,
fixed_journal_id AS journal_id, bank_account_link,
create_date, create_uid, write_date, write_uid,
show_bank_account, refund_payment_mode_id, active
FROM account_payment_mode
LOOP
INSERT INTO account_payment_method_line (
name, payment_method_id, bank_account_link, journal_id,
selectable, company_id, create_uid, create_date,
write_uid, write_date, show_bank_account,
old_payment_mode_id, old_refund_payment_mode_id, active
) VALUES (
to_jsonb(mode_rec.name),
mode_rec.payment_method_id,
mode_rec.bank_account_link,
mode_rec.journal_id,
true,
mode_rec.company_id,
mode_rec.create_uid,
mode_rec.create_date,
mode_rec.write_uid,
mode_rec.write_date,
mode_rec.show_bank_account,
mode_rec.id,
mode_rec.refund_payment_mode_id,
mode_rec.active
) RETURNING id INTO new_line_id;
IF mode_rec.bank_account_link = 'variable' THEN
IF EXISTS (SELECT FROM information_schema.tables
WHERE table_name = 'account_journal_account_payment_method_line_rel') THEN
FOR journal_rec IN
SELECT rel.journal_id
FROM account_payment_mode_variable_journal_rel rel
WHERE rel.payment_mode_id = mode_rec.id
LOOP
INSERT INTO account_journal_account_payment_method_line_rel
(account_payment_method_line_id, account_journal_id)
VALUES (new_line_id, journal_rec.journal_id)
ON CONFLICT DO NOTHING;
END LOOP;
END IF;
END IF;
RAISE NOTICE 'Migrated payment mode % -> payment method line %', mode_rec.id, new_line_id;
END LOOP;
UPDATE account_payment_method_line apml
SET refund_payment_method_line_id = apml2.id
FROM account_payment_method_line apml2
WHERE apml.old_refund_payment_mode_id IS NOT NULL
AND apml.old_refund_payment_mode_id = apml2.old_payment_mode_id;
UPDATE account_move am
SET preferred_payment_method_line_id = apml.id
FROM account_payment_mode apm, account_payment_method_line apml
WHERE am.payment_mode_id = apm.id
AND apm.id = apml.old_payment_mode_id
AND am.preferred_payment_method_line_id IS NULL;
RAISE NOTICE 'account_payment_base_oca migration completed';
END $$;
EOF
)
echo "Executing bank-payment base migration..."
query_postgres_container "$BANK_PAYMENT_POST_SQL" ou18 || exit 1
BANK_PAYMENT_BATCH_SQL=$(cat <<'EOF'
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'account_payment_mode') THEN
RETURN;
END IF;
IF NOT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'account_payment_order') THEN
RAISE NOTICE 'No account_payment_order table, skipping batch migration';
RETURN;
END IF;
RAISE NOTICE 'Starting account_payment_batch_oca migration...';
IF EXISTS (SELECT FROM information_schema.columns
WHERE table_name = 'account_payment_method' AND column_name = 'payment_order_only') THEN
UPDATE account_payment_method
SET payment_order_ok = payment_order_only
WHERE payment_order_only IS NOT NULL;
END IF;
UPDATE account_payment_method_line apml
SET payment_order_ok = apm.payment_order_ok,
no_debit_before_maturity = apm.no_debit_before_maturity,
default_payment_mode = apm.default_payment_mode,
default_invoice = apm.default_invoice,
default_target_move = apm.default_target_move,
default_date_type = apm.default_date_type,
default_date_prefered = apm.default_date_prefered,
group_lines = apm.group_lines
FROM account_payment_mode apm
WHERE apml.old_payment_mode_id IS NOT NULL
AND apm.id = apml.old_payment_mode_id;
IF EXISTS (SELECT FROM information_schema.tables
WHERE table_name = 'account_journal_account_payment_method_line_rel') THEN
DELETE FROM account_journal_account_payment_method_line_rel
WHERE account_payment_method_line_id IN (
SELECT id FROM account_payment_method_line WHERE old_payment_mode_id IS NOT NULL
);
INSERT INTO account_journal_account_payment_method_line_rel
(account_payment_method_line_id, account_journal_id)
SELECT apml.id, rel.account_journal_id
FROM account_journal_account_payment_mode_rel rel
JOIN account_payment_method_line apml ON rel.account_payment_mode_id = apml.old_payment_mode_id
ON CONFLICT DO NOTHING;
END IF;
UPDATE account_payment_order apo
SET payment_method_line_id = apml.id,
payment_method_code = apm_method.code
FROM account_payment_method_line apml,
account_payment_mode apm,
account_payment_method apm_method
WHERE apo.payment_mode_id = apm.id
AND apml.old_payment_mode_id = apm.id
AND apm_method.id = apml.payment_method_id;
RAISE NOTICE 'account_payment_batch_oca migration completed';
RAISE NOTICE 'NOTE: Payment lots for open orders must be generated manually via Odoo UI or script';
END $$;
EOF
)
echo "Executing bank-payment batch migration..."
query_postgres_container "$BANK_PAYMENT_BATCH_SQL" ou18 || exit 1
else
echo "Table account_payment_mode not found, skipping bank-payment migration."
fi
# ============================================================================
# FIX: stock_picking name='/' and missing POS picking type sequences
#
# Two issues can occur after migration:
#
# 1. stock_picking records with name='/' violate the new V18 unique constraint
# stock_picking_name_uniq (name, company_id). In previous versions this
# constraint did not exist, so multiple pickings could share name='/'.
#
# 2. POS picking types (pos_type_id on stock_warehouse) may lack a sequence_id.
# In V18, stock.picking.create() assigns the name from picking_type.sequence_id,
# but if sequence_id is NULL the name stays '/' and the unique constraint is
# violated on every new POS payment.
#
# The standard _create_missing_pos_picking_types() only fixes warehouses where
# pos_type_id is NULL — it does NOT fix existing pos_type_id records that are
# missing their sequence_id.
#
# Only executed if stock module tables exist.
# ============================================================================
STOCK_PICKING_TABLE_EXISTS=$(query_postgres_container "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'stock_picking';" ou18 2>/dev/null | grep -E '^\s*[0-9]+' | tr -d ' ' || echo "0")
if [ "$STOCK_PICKING_TABLE_EXISTS" -gt 0 ]; then
echo "stock_picking table exists, proceeding with POS picking fixes..."
# --- Step 1: Rename stock_picking records with name='/' ---
FIX_SLASH_PICKING_NAMES_SQL=$(cat <<'EOF'
DO $$
DECLARE
slash_count INTEGER;
BEGIN
IF NOT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'stock_picking') THEN
RAISE NOTICE 'stock_picking table not found, skipping slash name fix';
RETURN;
END IF;
SELECT COUNT(*) INTO slash_count FROM stock_picking WHERE name = '/';
IF slash_count > 0 THEN
UPDATE stock_picking SET name = 'MIGRATED-' || id WHERE name = '/';
RAISE NOTICE 'Renamed % stock_picking record(s) with name=/ to MIGRATED-<id>', slash_count;
ELSE
RAISE NOTICE 'No stock_picking with name=/ found, nothing to rename';
END IF;
END $$;
EOF
)
echo "Fixing stock_picking names with '/'..."
query_postgres_container "$FIX_SLASH_PICKING_NAMES_SQL" ou18 || exit 1
# --- Step 2: Create missing ir.sequence for POS picking types without sequence ---
FIX_POS_PICKING_TYPE_SEQUENCES_SQL=$(cat <<'EOF'
DO $$
DECLARE
pt_rec RECORD;
seq_id INTEGER;
seq_name TEXT;
seq_prefix TEXT;
BEGIN
IF NOT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'stock_picking_type') THEN
RAISE NOTICE 'stock_picking_type table not found, skipping POS sequence fix';
RETURN;
END IF;
IF NOT EXISTS (SELECT FROM information_schema.columns
WHERE table_name = 'stock_warehouse' AND column_name = 'pos_type_id') THEN
RAISE NOTICE 'stock_warehouse.pos_type_id column not found (point_of_sale not installed), skipping POS sequence fix';
RETURN;
END IF;
FOR pt_rec IN
SELECT spt.id AS picking_type_id,
spt.name AS picking_type_name,
COALESCE(spt.sequence_code, 'POS') AS sequence_code,
sw.code AS warehouse_code,
sw.name AS warehouse_name,
sw.company_id
FROM stock_picking_type spt
JOIN stock_warehouse sw ON sw.pos_type_id = spt.id
WHERE spt.sequence_id IS NULL OR spt.sequence_id = 0
LOOP
seq_prefix := pt_rec.warehouse_code || '/' || pt_rec.sequence_code || '/';
seq_name := pt_rec.warehouse_name || ' Picking POS';
INSERT INTO ir_sequence (
name, prefix, padding, company_id, implementation,
number_increment, number_next, active
) VALUES (
seq_name, seq_prefix, 5, pt_rec.company_id, 'standard',
1, 1, true
) RETURNING id INTO seq_id;
UPDATE stock_picking_type
SET sequence_id = seq_id
WHERE id = pt_rec.picking_type_id;
RAISE NOTICE 'Created ir.sequence % (%) for POS picking type % (ID:%) on warehouse %',
seq_name, seq_id, pt_rec.picking_type_name, pt_rec.picking_type_id, pt_rec.warehouse_name;
END LOOP;
END $$;
EOF
)
echo "Creating missing POS picking type sequences..."
query_postgres_container "$FIX_POS_PICKING_TYPE_SEQUENCES_SQL" ou18 || exit 1
# --- Step 3: Create missing PostgreSQL sequences for ir.sequence records ---
# When ir.sequence records are created via SQL INSERT (not ORM), the underlying
# PostgreSQL sequence (ir_sequence_NNN) is NOT created. This causes errors when
# Odoo tries to read or use the sequence.
FIX_MISSING_PG_SEQUENCES_SQL=$(cat <<'EOF'
DO $$
DECLARE
seq_rec RECORD;
seq_pg_name TEXT;
seq_count INTEGER;
BEGIN
seq_count := 0;
FOR seq_rec IN
SELECT id, number_increment, number_next
FROM ir_sequence
WHERE implementation = 'standard'
LOOP
seq_pg_name := 'ir_sequence_' || lpad(seq_rec.id::text, 3, '0');
IF NOT EXISTS (
SELECT 1 FROM pg_class
WHERE relkind = 'S' AND relname = seq_pg_name
) THEN
EXECUTE format('CREATE SEQUENCE %I INCREMENT BY %s START WITH %s',
seq_pg_name, seq_rec.number_increment, seq_rec.number_next);
seq_count := seq_count + 1;
RAISE NOTICE 'Created PostgreSQL sequence %', seq_pg_name;
END IF;
END LOOP;
IF seq_count > 0 THEN
RAISE NOTICE 'Created % missing PostgreSQL sequence(s)', seq_count;
ELSE
RAISE NOTICE 'All PostgreSQL sequences already exist, nothing to create';
END IF;
END $$;
EOF
)
echo "Creating missing PostgreSQL sequences for ir.sequence records..."
query_postgres_container "$FIX_MISSING_PG_SEQUENCES_SQL" ou18 || exit 1
# --- Step 4: Grant permissions on newly created PostgreSQL sequences ---
# Sequences created above are owned by the current DB user, but we ensure
# the Odoo DB user has proper access (needed when created by a different superuser).
GRANT_PG_SEQUENCES_SQL=$(cat <<'EOF'
DO $$
DECLARE
seq_rec RECORD;
seq_pg_name TEXT;
db_user TEXT;
grant_count INTEGER;
BEGIN
-- Determine the Odoo database user from the current connection
SELECT current_user INTO db_user;
grant_count := 0;
FOR seq_rec IN
SELECT id
FROM ir_sequence
WHERE implementation = 'standard'
LOOP
seq_pg_name := 'ir_sequence_' || lpad(seq_rec.id::text, 3, '0');
IF EXISTS (
SELECT 1 FROM pg_class
WHERE relkind = 'S' AND relname = seq_pg_name
) THEN
BEGIN
EXECUTE format('GRANT ALL ON SEQUENCE %I TO %I', seq_pg_name, db_user);
grant_count := grant_count + 1;
EXCEPTION WHEN insufficient_privilege THEN
RAISE NOTICE 'Cannot GRANT on % (not owner), skipping', seq_pg_name;
END;
END IF;
END LOOP;
RAISE NOTICE 'Granted permissions on % PostgreSQL sequence(s) to %', grant_count, db_user;
END $$;
EOF
)
echo "Granting permissions on PostgreSQL sequences..."
query_postgres_container "$GRANT_PG_SEQUENCES_SQL" ou18 || exit 1
# --- Step 5: Verification ---
VERIFY_POS_FIXES_SQL=$(cat <<'EOF'
DO $$
DECLARE
slash_count INTEGER;
missing_seq_count INTEGER;
BEGIN
-- Check remaining pickings with name='/'
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'stock_picking') THEN
SELECT COUNT(*) INTO slash_count FROM stock_picking WHERE name = '/';
IF slash_count > 0 THEN
RAISE WARNING 'Still % stock_picking record(s) with name=/', slash_count;
ELSE
RAISE NOTICE 'OK: No stock_picking with name=/ remaining';
END IF;
END IF;
-- Check POS picking types without sequence
IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'stock_picking_type')
AND EXISTS (SELECT FROM information_schema.columns
WHERE table_name = 'stock_warehouse' AND column_name = 'pos_type_id') THEN
SELECT COUNT(*) INTO missing_seq_count
FROM stock_picking_type spt
JOIN stock_warehouse sw ON sw.pos_type_id = spt.id
WHERE spt.sequence_id IS NULL OR spt.sequence_id = 0;
IF missing_seq_count > 0 THEN
RAISE WARNING 'Still % POS picking type(s) without sequence_id', missing_seq_count;
ELSE
RAISE NOTICE 'OK: All POS picking types have a sequence_id';
END IF;
END IF;
END $$;
EOF
)
echo "Verifying POS picking fixes..."
query_postgres_container "$VERIFY_POS_FIXES_SQL" ou18 || exit 1
else
echo "stock_picking table not found, skipping POS picking fixes."
fi
echo "Post migration to 18.0 completed!"

View File

@@ -6,14 +6,138 @@ echo "Prepare migration to 18.0..."
# Copy database
copy_database ou17 ou18 ou18 || exit 1
# ============================================================================
# BANK-PAYMENT -> BANK-PAYMENT-ALTERNATIVE MODULE RENAMING
# Migration from OCA/bank-payment to OCA/bank-payment-alternative
# Source PR: https://github.com/OCA/bank-payment-alternative/pull/42
#
# This renaming MUST be done BEFORE OpenUpgrade runs, so that the migration
# scripts in the new modules (account_payment_base_oca, account_payment_batch_oca)
# can properly migrate the data.
#
# Only executed if account_payment_mode module was installed before migration.
# ============================================================================
# Check if account_payment_mode module is installed
BANK_PAYMENT_INSTALLED=$(query_postgres_container "SELECT COUNT(*) FROM ir_module_module WHERE name = 'account_payment_mode' AND state = 'installed';" ou18 2>/dev/null | grep -E '^\s*[0-9]+' | tr -d ' ' || echo "0")
if [ "$BANK_PAYMENT_INSTALLED" -gt 0 ]; then
echo "Module account_payment_mode is installed, proceeding with bank-payment migration..."
BANK_PAYMENT_RENAME_SQL=$(cat <<'EOF'
DO $$
DECLARE
renamed_modules TEXT[][] := ARRAY[
['account_payment_mode', 'account_payment_base_oca'],
['account_banking_pain_base', 'account_payment_sepa_base'],
['account_banking_sepa_credit_transfer', 'account_payment_sepa_credit_transfer'],
['account_payment_order', 'account_payment_batch_oca']
];
merged_modules TEXT[][] := ARRAY[
['account_payment_partner', 'account_payment_base_oca']
];
old_name TEXT;
new_name TEXT;
old_module_id INTEGER;
deleted_count INTEGER;
BEGIN
FOR i IN 1..array_length(renamed_modules, 1) LOOP
old_name := renamed_modules[i][1];
new_name := renamed_modules[i][2];
SELECT id INTO old_module_id FROM ir_module_module WHERE name = old_name;
IF old_module_id IS NOT NULL THEN
RAISE NOTICE 'Renaming module: % -> %', old_name, new_name;
UPDATE ir_module_module SET name = new_name WHERE name = old_name;
UPDATE ir_model_data SET module = new_name WHERE module = old_name;
UPDATE ir_module_module_dependency SET name = new_name WHERE name = old_name;
END IF;
END LOOP;
FOR i IN 1..array_length(merged_modules, 1) LOOP
old_name := merged_modules[i][1];
new_name := merged_modules[i][2];
SELECT id INTO old_module_id FROM ir_module_module WHERE name = old_name;
IF old_module_id IS NOT NULL THEN
RAISE NOTICE 'Merging module: % -> %', old_name, new_name;
DELETE FROM ir_model_data
WHERE module = old_name
AND name IN (SELECT name FROM ir_model_data WHERE module = new_name);
GET DIAGNOSTICS deleted_count = ROW_COUNT;
IF deleted_count > 0 THEN
RAISE NOTICE ' Deleted % duplicate ir_model_data records', deleted_count;
END IF;
UPDATE ir_model_data SET module = new_name WHERE module = old_name;
UPDATE ir_module_module_dependency SET name = new_name WHERE name = old_name;
UPDATE ir_module_module SET state = 'uninstalled' WHERE name = old_name;
DELETE FROM ir_module_module WHERE name = old_name;
END IF;
END LOOP;
END $$;
EOF
)
echo "Executing bank-payment module renaming..."
query_postgres_container "$BANK_PAYMENT_RENAME_SQL" ou18 || exit 1
BANK_PAYMENT_PRE_SQL=$(cat <<'EOF'
UPDATE ir_model_data
SET noupdate = false
WHERE module = 'account_payment_base_oca'
AND name = 'view_account_invoice_report_search';
EOF
)
echo "Executing bank-payment pre-migration..."
query_postgres_container "$BANK_PAYMENT_PRE_SQL" ou18 || exit 1
else
echo "Module account_payment_mode not installed, skipping bank-payment migration."
fi
# ============================================================================
# FIX: Rename company-dependent columns before OpenUpgrade runs
# In Odoo 18, company-dependent fields are stored as JSONB columns.
# The ORM's _auto_init() tries to convert existing VARCHAR columns to JSONB,
# which fails if the data is not valid JSON.
# Solution: Rename the columns so Odoo creates new JSONB columns, then
# OpenUpgrade's convert_company_dependent() will migrate the data from ir.property.
#
# See: https://github.com/OCA/OpenUpgrade/issues/5449
# ============================================================================
COMPANY_DEPENDENT_FIX_SQL=$(cat <<'EOF'
DO $$
BEGIN
-- res.partner.barcode (base module)
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'res_partner' AND column_name = 'barcode') THEN
ALTER TABLE res_partner RENAME COLUMN barcode TO openupgrade_legacy_18_0_barcode;
RAISE NOTICE 'Renamed res_partner.barcode for company-dependent conversion';
END IF;
END $$;
EOF
)
echo "Fixing company-dependent columns for Odoo 18..."
query_postgres_container "$COMPANY_DEPENDENT_FIX_SQL" ou18 || exit 1
# Execute SQL pre-migration commands
PRE_MIGRATE_SQL=$(cat <<'EOF'
UPDATE account_analytic_plan SET default_applicability=NULL WHERE default_applicability='optional';
DELETE FROM ir_ui_view WHERE model = 'res.config.settings';
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