diff --git a/account_invoice_default_journal/README.md b/account_invoice_default_journal/README.md new file mode 100644 index 0000000..547bf59 --- /dev/null +++ b/account_invoice_default_journal/README.md @@ -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. diff --git a/account_invoice_default_journal/__init__.py b/account_invoice_default_journal/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/account_invoice_default_journal/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_invoice_default_journal/__manifest__.py b/account_invoice_default_journal/__manifest__.py new file mode 100644 index 0000000..fb2f1ce --- /dev/null +++ b/account_invoice_default_journal/__manifest__.py @@ -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, +} diff --git a/account_invoice_default_journal/i18n/fr.po b/account_invoice_default_journal/i18n/fr.po new file mode 100644 index 0000000..2268e04 --- /dev/null +++ b/account_invoice_default_journal/i18n/fr.po @@ -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 !" diff --git a/account_invoice_default_journal/migrations/18.0.1.0.0/post-migration.py b/account_invoice_default_journal/migrations/18.0.1.0.0/post-migration.py new file mode 100644 index 0000000..e21220f --- /dev/null +++ b/account_invoice_default_journal/migrations/18.0.1.0.0/post-migration.py @@ -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, + ) diff --git a/account_invoice_default_journal/models/__init__.py b/account_invoice_default_journal/models/__init__.py new file mode 100644 index 0000000..d1bcad3 --- /dev/null +++ b/account_invoice_default_journal/models/__init__.py @@ -0,0 +1,2 @@ +from . import product_template +from . import account_move diff --git a/account_invoice_default_journal/models/account_move.py b/account_invoice_default_journal/models/account_move.py new file mode 100644 index 0000000..60067bc --- /dev/null +++ b/account_invoice_default_journal/models/account_move.py @@ -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 diff --git a/account_invoice_default_journal/models/product_template.py b/account_invoice_default_journal/models/product_template.py new file mode 100644 index 0000000..3b36eb9 --- /dev/null +++ b/account_invoice_default_journal/models/product_template.py @@ -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.", + ) diff --git a/account_invoice_default_journal/views/product_template_view.xml b/account_invoice_default_journal/views/product_template_view.xml new file mode 100644 index 0000000..9e7778f --- /dev/null +++ b/account_invoice_default_journal/views/product_template_view.xml @@ -0,0 +1,27 @@ + + + + + product.template.forced_journal.form.inherit + product.template + + + + + + + + + + + +