[MIG] account_invoice_default_journal: migrate to 18.0

This commit is contained in:
Stéphan Sainléger
2026-10-20 20:56:02 +02:00
parent 626f5638c7
commit 78c6de7ecb
9 changed files with 531 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
# account_invoice_default_journal
Forces the journal of an invoice from a journal configured on its products.
Two company-dependent journal fields are added on the product form, under the
**Accounting** tab:
- `forced_journal_id` ("Forced sale journal", restricted to sale journals), used for
customer invoices and credit notes (`out_invoice`, `out_refund`).
- `forced_purchase_journal_id` ("Forced purchase journal", restricted to purchase
journals), used for vendor bills and refunds (`in_invoice`, `in_refund`).
When Odoo looks for the default journal of an invoice
(`account.move._search_default_journal()`), the journal forced by the invoice lines'
products takes precedence over the company default. A move belongs to a single journal,
so a `UserError` is raised if the lines force two different journals — split the
document into two invoices in that case.
Both fields are company-dependent: each company configures its own forced journal on the
same product.
## Installation
`account` is the only dependency, and it is part of Odoo core. Use the standard Odoo
module installation procedure to install `account_invoice_default_journal`.
### Required access group
Both fields are added inside the **Accounting** tab of the product form, which comes
from `account.product_template_form_view`. In Odoo 18.0 that tab is restricted to the
technical group `account.group_account_readonly`, and neither
`account.group_account_invoice` ("Invoicing") nor `account.group_account_manager`
("Administrator") implies it — only `account.group_account_user` ("Show Full Accounting
Features") does. Odoo 16.0 also allowed `account.group_account_invoice` on that tab, so
the restriction is tighter than it used to be.
On an Invoicing-only installation, meaning without the `account_accountant` module,
there is no standard way to obtain that group: it has to be granted explicitly.
Otherwise both fields stay invisible even though the module is correctly installed.
Upgrading an Odoo 16.0 database: the values of both fields were stored in `ir.property`,
a model removed in Odoo 17.0. The module ships
`migrations/18.0.1.0.0/post-migration.py`, which converts them to the new `jsonb`
storage automatically during the upgrade.
## Configuration
No configuration is needed. Set the journal on each product that needs one; a product
with no forced journal does not affect journal selection.
## Usage
1. Open the product form and go to the **Accounting** tab.
2. Set **Forced sale journal** and/or **Forced purchase journal**.
The forced journal is then applied when Odoo computes the journal of a new invoice,
which happens only if the invoice is created together with its lines **and** without an
explicit journal in the creation values. That is the case for invoices generated from
another document, such as a sale order.
It is **not** the case when editing an invoice in the user interface: see the limitation
below. Read it before relying on this module for manual invoicing.
## Known issues / Roadmap
### The forced journal is a default, not a constraint
The module plugs into `account.move._search_default_journal()`, which supplies a
**default** value. A default is overridable by design, so the forced journal is only
honored on one of the three ways an invoice can get its journal:
- Created in a single call, with its lines, and no `journal_id` in the creation values:
the forced journal **is** applied.
- Created in a single call with an explicit `journal_id` in the creation values: the
forced journal is **not** applied.
- Created first, then lines added afterwards: the forced journal is **not** applied.
Two independent Odoo behaviors cause the two failing cases:
- An explicit value in the creation values short-circuits the computation entirely, so
the default is never requested. The web client sends `journal_id` because the field is
present and editable in the invoice form, which puts manual invoicing in this case.
- `_compute_journal_id` declares
`@api.depends('move_type', 'origin_payment_id', 'statement_line_id')`. It does not
depend on `invoice_line_ids`, so adding a product never invalidates the journal. It
also skips any move whose journal already has a valid type, which is true of the
company default sale journal.
Both behaviors are identical in Odoo 16.0, so this is a limitation of the module's
design rather than a regression introduced by the 18.0 migration.
Lifting it means moving from a default to an actual constraint, by overriding `create`
and `write`, or by refusing to save a mismatched journal. Both change behavior for
existing users and neither is implemented.
### Other
- `_search_default_journal()` calls `super()` first, so a company with no journal of the
expected type still raises before the forced journal is considered.
## Bug Tracker
Bugs are tracked on
[our issues website](https://github.com/elabore-coop/account-tools/issues). In case of
trouble, please check there if your issue has already been reported. If you spotted it
first, help us smashing it by providing a detailed and welcomed feedback.
## Credits
### Contributors
- Stéphan SAINLEGER - [Github](https://github.com/stephansainleger)
- Nicolas JEUDY - [Github](https://github.com/njeudy)
### Funders
The development of this module has been financially supported by:
- [Lokavaluto](https://lokavaluto.fr)
- [Mycéliandre](https://myceliandre.fr)
- [Elabore](https://elabore.coop)
### Maintainer
This module is maintained by Elabore.

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,22 @@
# Copyright 2022 Nicolas JEUDY
# Copyright 2026 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "Account Invoice Default Journal",
"version": "18.0.1.0.0",
"category": "Accounting",
"summary": "Force the invoice journal from a journal set on the product",
"author": "Nicolas JEUDY, Elabore",
"website": "https://git.elabore.coop/elabore/account-tools",
"license": "AGPL-3",
"depends": [
"account",
],
"data": [
"views/product_template_view.xml",
],
"installable": True,
"auto_install": False,
"application": False,
}

View File

@@ -0,0 +1,64 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * account_invoice_default_journal
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-20 15:05+0000\n"
"PO-Revision-Date: 2026-10-20 15:05+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: account_invoice_default_journal
#: model:ir.model.fields,field_description:account_invoice_default_journal.field_product_product__forced_purchase_journal_id
#: model:ir.model.fields,field_description:account_invoice_default_journal.field_product_template__forced_purchase_journal_id
msgid "Forced purchase journal"
msgstr "Forcer le journal d'achat"
#. module: account_invoice_default_journal
#: model:ir.model.fields,field_description:account_invoice_default_journal.field_product_product__forced_journal_id
#: model:ir.model.fields,field_description:account_invoice_default_journal.field_product_template__forced_journal_id
msgid "Forced sale journal"
msgstr "Forcer le journal de vente"
#. module: account_invoice_default_journal
#: model:ir.model,name:account_invoice_default_journal.model_account_move
msgid "Journal Entry"
msgstr "Pièce comptable"
#. module: account_invoice_default_journal
#: model:ir.model.fields,help:account_invoice_default_journal.field_product_product__forced_journal_id
#: model:ir.model.fields,help:account_invoice_default_journal.field_product_template__forced_journal_id
msgid ""
"Journal to use on customer invoices containing this product, instead of the "
"company default sale journal."
msgstr ""
"Journal à utiliser sur les factures client contenant cet article, à la place "
"du journal de vente par défaut de la société."
#. module: account_invoice_default_journal
#: model:ir.model.fields,help:account_invoice_default_journal.field_product_product__forced_purchase_journal_id
#: model:ir.model.fields,help:account_invoice_default_journal.field_product_template__forced_purchase_journal_id
msgid ""
"Journal to use on vendor bills containing this product, instead of the "
"company default purchase journal."
msgstr ""
"Journal à utiliser sur les factures fournisseur contenant cet article, à la "
"place du journal d'achat par défaut de la société."
#. module: account_invoice_default_journal
#: model:ir.model,name:account_invoice_default_journal.model_product_template
msgid "Product"
msgstr "Produit"
#. module: account_invoice_default_journal
#. odoo-python
#: code:addons/account_invoice_default_journal/models/account_move.py:0
msgid "You can not have 2 forced journals on the same invoice !"
msgstr "Vous ne pouvez pas avoir 2 journaux forcés sur la même facture !"

View File

@@ -0,0 +1,198 @@
# Copyright 2026 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
"""
Carry 16.0 ``ir.property`` values over to the 18.0 ``jsonb`` storage.
Odoo 17.0 removed the ``ir.property`` model: the value of a
``company_dependent`` field now lives in a ``jsonb`` column on the model's own
table, keyed by company id, with the cross-record fallback stored in
``ir.default``. Nothing converts the values of a *custom* company-dependent
field automatically, so a 16.0 database upgraded to 18.0 would silently lose
every forced journal configured on a product.
This script reads the legacy ``ir_property`` rows of the two fields this module
owns and rewrites them in the 18.0 format. It is idempotent and a no-op on a
fresh 18.0 install, where the ``ir_property`` table no longer exists.
"""
import logging
_logger = logging.getLogger(__name__)
MODEL = "product.template"
TABLE = "product_template"
FIELDS = ("forced_journal_id", "forced_purchase_journal_id")
def _legacy_table_exists(cr):
"""
Tell whether the 16.0 ``ir_property`` table is still present.
:param cr: database cursor.
:return: ``True`` when the legacy table can be read, ``False`` on a fresh
18.0 install or once the table has been dropped.
"""
cr.execute("SELECT to_regclass('public.ir_property') IS NOT NULL")
return cr.fetchone()[0]
def _field_id(cr, field_name):
"""
Return the ``ir_model_fields`` id of a field of :data:`MODEL`.
Only the ``product.template`` definition is looked up on purpose: the
``product.product`` counterpart is a delegated related field and never
carries ``ir.property`` rows of its own.
:param cr: database cursor.
:param str field_name: name of the field to look up.
:return: the field id, or ``None`` when the field is unknown to the
database (module never installed before the upgrade).
"""
cr.execute(
"SELECT id FROM ir_model_fields WHERE model = %s AND name = %s",
(MODEL, field_name),
)
row = cr.fetchone()
return row[0] if row else None
def _migrate_per_record_values(cr, field_name, field_id):
"""
Move per-product legacy values into the ``jsonb`` column.
Existing keys in the target column win, which makes the operation
idempotent and safe to re-run after a partial upgrade. Properties pointing
at a journal that no longer exists are dropped, since the ``jsonb``
storage of a company-dependent many2one has no foreign key to protect it.
:param cr: database cursor.
:param str field_name: name of the company-dependent field to fill.
:param int field_id: ``ir_model_fields`` id of that field.
:return: number of products updated.
"""
cr.execute(
f"""
WITH legacy AS (
SELECT
split_part(ip.res_id, ',', 2)::integer AS product_tmpl_id,
ip.company_id AS company_id,
split_part(ip.value_reference, ',', 2)::integer AS journal_id
FROM ir_property ip
WHERE ip.fields_id = %s
AND ip.type = 'many2one'
AND ip.value_reference IS NOT NULL
AND ip.res_id IS NOT NULL
AND ip.company_id IS NOT NULL
AND split_part(ip.res_id, ',', 1) = %s
),
checked AS (
SELECT legacy.*
FROM legacy
JOIN account_journal aj ON aj.id = legacy.journal_id
JOIN {TABLE} pt ON pt.id = legacy.product_tmpl_id
JOIN res_company rc ON rc.id = legacy.company_id
),
grouped AS (
SELECT
product_tmpl_id,
jsonb_object_agg(company_id::text, journal_id) AS value
FROM checked
GROUP BY product_tmpl_id
)
UPDATE {TABLE} pt
SET {field_name} = grouped.value || COALESCE(pt.{field_name}, '{{}}'::jsonb)
FROM grouped
WHERE pt.id = grouped.product_tmpl_id
""",
(field_id, MODEL),
)
return cr.rowcount
def _migrate_fallback_values(cr, field_name, field_id):
"""
Move legacy cross-record defaults into ``ir_default``.
An ``ir_property`` row without ``res_id`` was a default applying to every
record of the model. In 18.0 that role belongs to ``ir.default``, which is
what :meth:`odoo.fields.Field.get_company_dependent_fallback` reads.
Rows are only inserted when no default exists yet for the same field and
company, keeping the operation idempotent.
:param cr: database cursor.
:param str field_name: name of the company-dependent field.
:param int field_id: ``ir_model_fields`` id of that field.
:return: number of defaults created.
"""
cr.execute(
"""
INSERT INTO ir_default (field_id, user_id, company_id, condition, json_value)
SELECT %s, NULL, ip.company_id, NULL,
split_part(ip.value_reference, ',', 2)
FROM ir_property ip
JOIN account_journal aj
ON aj.id = split_part(ip.value_reference, ',', 2)::integer
JOIN res_company rc ON rc.id = ip.company_id
WHERE ip.fields_id = %s
AND ip.type = 'many2one'
AND ip.value_reference IS NOT NULL
AND ip.res_id IS NULL
AND ip.company_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM ir_default d
WHERE d.field_id = %s
AND d.company_id = ip.company_id
AND d.user_id IS NULL
AND d.condition IS NULL
)
""",
(field_id, field_id, field_id),
)
if cr.rowcount:
_logger.info(
"%s: converted %s company fallback(s) from ir.property to ir.default",
field_name,
cr.rowcount,
)
return cr.rowcount
def migrate(cr, version):
"""
Convert the module's legacy ``ir.property`` rows to the 18.0 storage.
Runs after the module schema has been updated, so the ``jsonb`` columns
this script writes into already exist.
:param cr: database cursor.
:param str version: version the module is upgraded from. Falsy on a fresh
install, in which case there is nothing to convert.
"""
if not version:
return
if not _legacy_table_exists(cr):
_logger.info(
"ir_property table is gone, nothing to convert for %s",
", ".join(FIELDS),
)
return
for field_name in FIELDS:
field_id = _field_id(cr, field_name)
if not field_id:
_logger.warning(
"%s.%s not found in ir_model_fields, skipping its conversion",
MODEL,
field_name,
)
continue
updated = _migrate_per_record_values(cr, field_name, field_id)
_migrate_fallback_values(cr, field_name, field_id)
_logger.info(
"%s.%s: %s product(s) migrated from ir.property to jsonb storage",
MODEL,
field_name,
updated,
)

View File

@@ -0,0 +1,2 @@
from . import product_template
from . import account_move

View File

@@ -0,0 +1,64 @@
# Copyright 2022 Nicolas JEUDY
# Copyright 2026 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, models
from odoo.exceptions import UserError
class AccountMove(models.Model):
_inherit = "account.move"
def _get_forced_journal(self):
"""
Return the journal forced by the products of this move.
The journal is read from the product of each invoice line:
``forced_journal_id`` for customer documents, and
``forced_purchase_journal_id`` for vendor documents. Products
carrying no forced journal are ignored.
Only the first record of ``self`` is considered, mirroring the
single-record call sites in ``_search_default_journal``.
:return: the single forced ``account.journal`` record when exactly one
product forces a journal. Otherwise the move's current
``journal_id``, which is also what non-invoice moves return. An
empty ``account.journal`` recordset when ``self`` is empty.
:raises UserError: if the lines force more than one distinct journal,
since a move belongs to exactly one journal.
"""
journal_model = self.env["account.journal"]
for record in self:
if record.move_type in ("out_invoice", "out_refund"):
forced_journals = record.invoice_line_ids.product_id.forced_journal_id
elif record.move_type in ("in_invoice", "in_refund"):
forced_journals = (
record.invoice_line_ids.product_id.forced_purchase_journal_id
)
else:
forced_journals = journal_model
if len(forced_journals) > 1:
raise UserError(
_("You can not have 2 forced journals on the same invoice !")
)
if len(forced_journals) == 1:
return forced_journals
return record.journal_id
return journal_model
def _search_default_journal(self):
"""
Prefer the journal forced by the move products over the default one.
Extends the standard journal lookup so that a journal configured on a
product takes precedence. Falls back to the standard behavior when no
product forces a journal.
:return: the ``account.journal`` record to default to.
"""
res = super()._search_default_journal()
forced_journal = self._get_forced_journal()
if forced_journal:
return forced_journal
return res

View File

@@ -0,0 +1,28 @@
# Copyright 2022 Nicolas JEUDY
# Copyright 2026 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class ProductTemplate(models.Model):
_inherit = "product.template"
forced_journal_id = fields.Many2one(
"account.journal",
company_dependent=True,
check_company=True,
string="Forced sale journal",
domain="[('type', '=', 'sale')]",
help="Journal to use on customer invoices containing this product, "
"instead of the company default sale journal.",
)
forced_purchase_journal_id = fields.Many2one(
"account.journal",
company_dependent=True,
check_company=True,
string="Forced purchase journal",
domain="[('type', '=', 'purchase')]",
help="Journal to use on vendor bills containing this product, "
"instead of the company default purchase journal.",
)

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Copyright 2022 Nicolas JEUDY
Copyright 2026 Elabore
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-->
<odoo>
<record model="ir.ui.view" id="view_product_template_forced_journal_form">
<field name="name">product.template.forced_journal.form.inherit</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="account.product_template_form_view" />
<field name="arch" type="xml">
<!--
No `domain` attribute here on purpose: a view-level domain
overrides the one generated from the field definition, which
already combines the journal type with the `check_company`
company domain.
-->
<field name="property_account_income_id" position="after">
<field name="forced_journal_id" />
</field>
<field name="property_account_expense_id" position="after">
<field name="forced_purchase_journal_id" />
</field>
</field>
</record>
</odoo>