From 4c513577357b5cd55d483098ff6685e878c97c36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phan=20Sainl=C3=A9ger?= Date: Tue, 2 Jun 2026 12:30:18 +0200 Subject: [PATCH] [MIG] sale_outstanding: migrate to 18.0 --- sale_outstanding/.gitignore | 2 + sale_outstanding/README.md | 110 ++++++++ sale_outstanding/__init__.py | 1 + sale_outstanding/__manifest__.py | 33 +++ sale_outstanding/i18n/fr.po | 62 +++++ sale_outstanding/i18n/sale_outstanding.pot | 62 +++++ sale_outstanding/models/__init__.py | 1 + sale_outstanding/models/sale.py | 29 ++ sale_outstanding/tests/__init__.py | 4 + .../tests/test_sale_outstanding.py | 248 ++++++++++++++++++ sale_outstanding/views/sale_views.xml | 29 ++ 11 files changed, 581 insertions(+) create mode 100644 sale_outstanding/.gitignore create mode 100644 sale_outstanding/README.md create mode 100644 sale_outstanding/__init__.py create mode 100644 sale_outstanding/__manifest__.py create mode 100644 sale_outstanding/i18n/fr.po create mode 100644 sale_outstanding/i18n/sale_outstanding.pot create mode 100644 sale_outstanding/models/__init__.py create mode 100644 sale_outstanding/models/sale.py create mode 100644 sale_outstanding/tests/__init__.py create mode 100644 sale_outstanding/tests/test_sale_outstanding.py create mode 100644 sale_outstanding/views/sale_views.xml diff --git a/sale_outstanding/.gitignore b/sale_outstanding/.gitignore new file mode 100644 index 0000000..6da5887 --- /dev/null +++ b/sale_outstanding/.gitignore @@ -0,0 +1,2 @@ +*.*~ +*pyc diff --git a/sale_outstanding/README.md b/sale_outstanding/README.md new file mode 100644 index 0000000..cfb7cd7 --- /dev/null +++ b/sale_outstanding/README.md @@ -0,0 +1,110 @@ +# Sale Outstanding + +![Production/Stable](https://img.shields.io/badge/maturity-Production%2FStable-green.png) +![License: AGPL-3](https://img.shields.io/badge/licence-AGPL--3-blue.png) +![GitHub](https://img.shields.io/badge/github-elabore--coop%2Fsale--tools-lightgray.png?logo=github) + +This module computes and displays two financial indicators on sale orders: + +- **Outstanding (Untaxed)**: total amount of order lines that have been delivered but not yet invoiced. +- **To Do (Untaxed)**: total amount of order lines that have not yet been fully delivered + (excluding services billed on order with the `ordered_timesheet` policy). + +Both values are visible directly on the sale order form and in the list view +(columns hidden by default, toggleable via the column selector). + +## Table of Contents + +- [Description](#description) +- [Installation](#installation) +- [Configuration](#configuration) +- [Usage](#usage) +- [Known Issues / Roadmap](#known-issues--roadmap) +- [Bug Tracker](#bug-tracker) +- [Credits](#credits) + +## Description + +This module extends the `sale.order` model with two computed, stored monetary fields. + +### `sum_outstanding` — Outstanding (Untaxed) + +Computed as the sum across all order lines of: + +``` +qty_to_invoice × price_unit +``` + +This amount represents what has been delivered to the customer but not yet invoiced. + +### `sum_pending_work` — To Do (Untaxed) + +Computed as the sum across all order lines of: + +``` +(ordered_qty − delivered_qty) × price_unit +``` + +Services whose invoicing policy is `ordered_timesheet` are excluded from this calculation, +as they are invoiced upon order confirmation rather than upon delivery. + +This amount represents work that still needs to be delivered and has not yet been invoiced. + +### Display + +- **Form view**: both fields appear in an `oe_subtotal_footer` group placed after the order + totals block. +- **List view**: both columns are added after `amount_tax`, hidden by default, with column + footer totals. + +## Installation + +This module depends on: + +- `base` (Odoo core) +- `sale` (Odoo core) + +Use the standard Odoo module installation procedure to install `sale_outstanding`. + +## Configuration + +No specific configuration is required. The fields are computed automatically. + +To display the columns in the sale order list view, click the column selector icon +(top-right corner of the list) and enable **Outstanding (Untaxed)** and/or **To Do (Untaxed)**. + +## Usage + +1. Open a confirmed sale order for which deliveries have been (partially or fully) completed. +2. The **Outstanding (Untaxed)** field shows the amount delivered but not yet invoiced. +3. The **To Do (Untaxed)** field shows the amount of work not yet delivered. +4. In the sale order list view, enable the optional columns for a global overview of outstanding amounts. + +## Known Issues / Roadmap + +- Amounts are computed excluding taxes. A possible improvement would be to add tax-included variants. +- The filter on `service_policy == 'ordered_timesheet'` is hard-coded; a configurable setting + would allow more flexibility. + +## Bug Tracker + +Bugs are tracked on [our issue tracker](https://github.com/elabore-coop/sale-tools/issues). +If you encounter a problem, please check whether your issue has already been reported. +If you are the first to spot it, help us by providing detailed and actionable feedback. + +## Credits + +### Contributors + +- Stéphan Sainléger \ + +### Funders + +The development of this module has been financially supported by: + +- [Elabore](https://elabore.coop) +- [Datactivist](https://datactivist.coop) + +### Maintainer + +This module is maintained by [Elabore](https://elabore.coop). diff --git a/sale_outstanding/__init__.py b/sale_outstanding/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/sale_outstanding/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sale_outstanding/__manifest__.py b/sale_outstanding/__manifest__.py new file mode 100644 index 0000000..2e30d92 --- /dev/null +++ b/sale_outstanding/__manifest__.py @@ -0,0 +1,33 @@ +# Copyright 2022 Stéphan Sainléger (Elabore) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "sale_outstanding", + "version": "18.0.1.0.0", + "author": "Elabore", + "website": "https://elabore.coop", + "maintainer": "Stéphan Sainléger", + "license": "AGPL-3", + "category": "Tools", + "summary": "Calculates and displays the sale order outstanding and pending work.", + # any module necessary for this one to work correctly + "depends": [ + "base", + "sale", + ], + "external_dependencies": { + "python": [], + }, + # always loaded + "data": [ + "views/sale_views.xml", + ], + # only loaded in demonstration mode + "demo": [], + "test": ["tests/test_sale_outstanding.py"], + "installable": True, + # Install this module automatically if all dependency have been previously + # and independently installed. Used for synergetic or glue modules. + "auto_install": False, + "application": False, +} \ No newline at end of file diff --git a/sale_outstanding/i18n/fr.po b/sale_outstanding/i18n/fr.po new file mode 100644 index 0000000..4e3a9d0 --- /dev/null +++ b/sale_outstanding/i18n/fr.po @@ -0,0 +1,62 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_outstanding +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-11-24 18:04+0000\n" +"PO-Revision-Date: 2022-11-24 18:04+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: sale_outstanding +#: model:ir.model.fields,field_description:sale_outstanding.field_sale_order__display_name +msgid "Display Name" +msgstr "Nom affiché" + +#. module: sale_outstanding +#: model:ir.model.fields,field_description:sale_outstanding.field_sale_order__id +msgid "ID" +msgstr "" + +#. module: sale_outstanding +#: model:ir.model.fields,field_description:sale_outstanding.field_sale_order____last_update +msgid "Last Modified on" +msgstr "Dernière modification le" + +#. module: sale_outstanding +#: model_terms:ir.ui.view,arch_db:sale_outstanding.view_order_tree_inherit_outstanding +#: model_terms:ir.ui.view,arch_db:sale_outstanding.view_quotation_tree_inherit_outstanding +msgid "Outstanding Total" +msgstr "Total en cours HT" + +#. module: sale_outstanding +#: code:addons/sale_outstanding/models/sale.py:0 +#: model:ir.model.fields,field_description:sale_outstanding.field_sale_order__sum_outstanding +#, python-format +msgid "Outstanding Untaxed" +msgstr "En cours HT" + +#. module: sale_outstanding +#: model_terms:ir.ui.view,arch_db:sale_outstanding.view_order_tree_inherit_outstanding +#: model_terms:ir.ui.view,arch_db:sale_outstanding.view_quotation_tree_inherit_outstanding +msgid "Pending work Total" +msgstr "Total Reste à produire HT" + +#. module: sale_outstanding +#: model:ir.model,name:sale_outstanding.model_sale_order +msgid "Sales Order" +msgstr "Bon de commande" + +#. module: sale_outstanding +#: code:addons/sale_outstanding/models/sale.py:0 +#: model:ir.model.fields,field_description:sale_outstanding.field_sale_order__sum_pending_work +#, python-format +msgid "To Do Untaxed" +msgstr "Reste à produire HT" diff --git a/sale_outstanding/i18n/sale_outstanding.pot b/sale_outstanding/i18n/sale_outstanding.pot new file mode 100644 index 0000000..bb1fa8a --- /dev/null +++ b/sale_outstanding/i18n/sale_outstanding.pot @@ -0,0 +1,62 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_outstanding +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-11-24 18:02+0000\n" +"PO-Revision-Date: 2022-11-24 18:02+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: sale_outstanding +#: model:ir.model.fields,field_description:sale_outstanding.field_sale_order__display_name +msgid "Display Name" +msgstr "" + +#. module: sale_outstanding +#: model:ir.model.fields,field_description:sale_outstanding.field_sale_order__id +msgid "ID" +msgstr "" + +#. module: sale_outstanding +#: model:ir.model.fields,field_description:sale_outstanding.field_sale_order____last_update +msgid "Last Modified on" +msgstr "" + +#. module: sale_outstanding +#: model_terms:ir.ui.view,arch_db:sale_outstanding.view_order_tree_inherit_outstanding +#: model_terms:ir.ui.view,arch_db:sale_outstanding.view_quotation_tree_inherit_outstanding +msgid "Outstanding Total" +msgstr "" + +#. module: sale_outstanding +#: code:addons/sale_outstanding/models/sale.py:0 +#: model:ir.model.fields,field_description:sale_outstanding.field_sale_order__sum_outstanding +#, python-format +msgid "Outstanding Untaxed" +msgstr "" + +#. module: sale_outstanding +#: model_terms:ir.ui.view,arch_db:sale_outstanding.view_order_tree_inherit_outstanding +#: model_terms:ir.ui.view,arch_db:sale_outstanding.view_quotation_tree_inherit_outstanding +msgid "Pending work Total" +msgstr "" + +#. module: sale_outstanding +#: model:ir.model,name:sale_outstanding.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: sale_outstanding +#: code:addons/sale_outstanding/models/sale.py:0 +#: model:ir.model.fields,field_description:sale_outstanding.field_sale_order__sum_pending_work +#, python-format +msgid "To Do Untaxed" +msgstr "" diff --git a/sale_outstanding/models/__init__.py b/sale_outstanding/models/__init__.py new file mode 100644 index 0000000..7d5ef02 --- /dev/null +++ b/sale_outstanding/models/__init__.py @@ -0,0 +1 @@ +from . import sale \ No newline at end of file diff --git a/sale_outstanding/models/sale.py b/sale_outstanding/models/sale.py new file mode 100644 index 0000000..a4be208 --- /dev/null +++ b/sale_outstanding/models/sale.py @@ -0,0 +1,29 @@ +from odoo import _, api, fields, models + +class SaleOrder(models.Model): + _inherit = "sale.order" + + @api.depends("order_line.qty_delivered", "order_line.qty_invoiced", "order_line.qty_to_invoice", "order_line.price_unit") + def _compute_sum_outstanding(self): + for order in self: + lines_outstanding = order.order_line.mapped(lambda r:(r.qty_to_invoice * r.price_unit)) + order["sum_outstanding"] = sum(lines_outstanding) + + @api.depends("order_line.qty_delivered", "order_line.product_uom_qty", "order_line.price_unit") + def _compute_sum_pending_work(self): + for order in self: + lines_pending_work = order.order_line.mapped( + lambda r: ( + (r.product_uom_qty - r.qty_delivered) * r.price_unit + if not r.product_id + or "service_policy" not in r.product_id.product_tmpl_id._fields + or r.product_id.product_tmpl_id.service_policy != "ordered_prepaid" + else 0 + ) + ) + order["sum_pending_work"] = sum(lines_pending_work) + + + sum_outstanding = fields.Monetary(_("Outstanding Untaxed"), readonly=True, store=True, compute="_compute_sum_outstanding") + sum_pending_work = fields.Monetary(_("To Do Untaxed"), readonly=True, store=True, compute="_compute_sum_pending_work") + diff --git a/sale_outstanding/tests/__init__.py b/sale_outstanding/tests/__init__.py new file mode 100644 index 0000000..21e3a08 --- /dev/null +++ b/sale_outstanding/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Stéphan Sainléger (Elabore) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_sale_outstanding diff --git a/sale_outstanding/tests/test_sale_outstanding.py b/sale_outstanding/tests/test_sale_outstanding.py new file mode 100644 index 0000000..1bf6814 --- /dev/null +++ b/sale_outstanding/tests/test_sale_outstanding.py @@ -0,0 +1,248 @@ +# Copyright 2025 Stéphan Sainléger (Elabore) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.fields import Command +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestSaleOutstanding(TransactionCase): + """Tests for sum_outstanding and sum_pending_work computed fields.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + + cls.partner = cls.env["res.partner"].create({"name": "Test Customer"}) + + # Storable product — delivered on shipping (delivery policy) + cls.product_storable = cls.env["product.product"].create( + { + "name": "Storable Product", + "type": "consu", + "list_price": 100.0, + "invoice_policy": "delivery", + "taxes_id": [], + } + ) + + # Service product — invoiced on delivery (timesheet / manual delivery) + cls.product_service = cls.env["product.product"].create( + { + "name": "Service Product", + "type": "service", + "list_price": 50.0, + "invoice_policy": "delivery", + "service_policy": "delivered_timesheet", + "taxes_id": [], + } + ) + + # Service product with ordered_prepaid policy — must be excluded from pending work. + # service_policy is a computed field on product.template derived from + # invoice_policy + service_type. 'ordered_prepaid' = invoice_policy='order' + + # service_type='timesheet' (requires sale_project/sale_timesheet). + # We set the policy directly on the template when the field exists. + cls.product_service_ordered = cls.env["product.product"].create( + { + "name": "Service Ordered Prepaid", + "type": "service", + "list_price": 80.0, + "invoice_policy": "order", + "service_type": "timesheet", + "taxes_id": [], + } + ) + # Force service_policy to 'ordered_prepaid' if the field is available + # (provided by sale_project / sale_timesheet). + if "service_policy" in cls.env["product.template"]._fields: + cls.product_service_ordered.product_tmpl_id.service_policy = "ordered_prepaid" + + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + + def _make_order(self, lines): + """Create and confirm a sale order with the given line specs. + + Each entry in *lines* is a dict with keys: product, qty, price_unit. + Returns the confirmed sale.order record. + """ + order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": spec["product"].id, + "product_uom_qty": spec["qty"], + "price_unit": spec["price_unit"], + } + ) + for spec in lines + ], + } + ) + order.action_confirm() + return order + + # ------------------------------------------------------------------------- + # sum_outstanding + # ------------------------------------------------------------------------- + + def test_outstanding_zero_before_delivery(self): + """sum_outstanding is 0 when nothing has been delivered yet.""" + order = self._make_order( + [{"product": self.product_storable, "qty": 5.0, "price_unit": 100.0}] + ) + self.assertEqual( + order.sum_outstanding, + 0.0, + "No delivery done yet — outstanding should be 0.", + ) + + def test_outstanding_after_partial_delivery(self): + """sum_outstanding equals delivered-but-not-invoiced amount after partial delivery.""" + order = self._make_order( + [{"product": self.product_storable, "qty": 10.0, "price_unit": 100.0}] + ) + line = order.order_line + # Simulate partial delivery: 4 units delivered + line.qty_delivered = 4.0 + # qty_to_invoice == qty_delivered - qty_invoiced == 4 - 0 == 4 + self.assertAlmostEqual(order.sum_outstanding, 4.0 * 100.0) + + def test_outstanding_after_full_delivery(self): + """sum_outstanding equals full order amount when all is delivered but not invoiced.""" + order = self._make_order( + [{"product": self.product_storable, "qty": 5.0, "price_unit": 200.0}] + ) + order.order_line.qty_delivered = 5.0 + self.assertAlmostEqual(order.sum_outstanding, 5.0 * 200.0) + + def test_outstanding_zero_after_invoice(self): + """sum_outstanding drops to 0 once the delivered qty has been fully invoiced.""" + order = self._make_order( + [{"product": self.product_storable, "qty": 3.0, "price_unit": 100.0}] + ) + line = order.order_line + line.qty_delivered = 3.0 + # Simulate invoicing by setting qty_invoiced directly (bypass actual invoice flow) + line.qty_invoiced = 3.0 + # qty_to_invoice = max(0, qty_delivered - qty_invoiced) = 0 + self.assertAlmostEqual(order.sum_outstanding, 0.0) + + def test_outstanding_multi_line(self): + """sum_outstanding sums across all lines correctly.""" + order = self._make_order( + [ + {"product": self.product_storable, "qty": 2.0, "price_unit": 100.0}, + {"product": self.product_service, "qty": 3.0, "price_unit": 50.0}, + ] + ) + lines = order.order_line + lines[0].qty_delivered = 2.0 # outstanding: 2 × 100 = 200 + lines[1].qty_delivered = 1.0 # outstanding: 1 × 50 = 50 + self.assertAlmostEqual(order.sum_outstanding, 200.0 + 50.0) + + # ------------------------------------------------------------------------- + # sum_pending_work + # ------------------------------------------------------------------------- + + def test_pending_work_equals_full_qty_before_delivery(self): + """sum_pending_work equals full order amount when nothing has been delivered.""" + order = self._make_order( + [{"product": self.product_storable, "qty": 5.0, "price_unit": 100.0}] + ) + self.assertAlmostEqual( + order.sum_pending_work, + 5.0 * 100.0, + msg="No delivery done — pending work should equal full order amount.", + ) + + def test_pending_work_decreases_with_delivery(self): + """sum_pending_work decreases as deliveries are recorded.""" + order = self._make_order( + [{"product": self.product_storable, "qty": 10.0, "price_unit": 100.0}] + ) + order.order_line.qty_delivered = 7.0 + # Remaining: (10 - 7) × 100 = 300 + self.assertAlmostEqual(order.sum_pending_work, 3.0 * 100.0) + + def test_pending_work_zero_after_full_delivery(self): + """sum_pending_work is 0 once everything has been delivered.""" + order = self._make_order( + [{"product": self.product_storable, "qty": 5.0, "price_unit": 100.0}] + ) + order.order_line.qty_delivered = 5.0 + self.assertAlmostEqual(order.sum_pending_work, 0.0) + + def test_pending_work_excludes_ordered_prepaid_service(self): + """Products with service_policy='ordered_prepaid' are excluded from pending work.""" + if "service_policy" not in self.env["product.template"]._fields: + self.skipTest("service_policy field not available (sale_project not installed)") + order = self._make_order( + [ + {"product": self.product_storable, "qty": 2.0, "price_unit": 100.0}, + # This line must be ignored in the pending work computation + { + "product": self.product_service_ordered, + "qty": 5.0, + "price_unit": 80.0, + }, + ] + ) + # Only the storable product counts: 2 × 100 = 200 + self.assertAlmostEqual( + order.sum_pending_work, + 2.0 * 100.0, + msg=( + "ordered_prepaid service should be excluded; " + "only storable product pending work should be counted." + ), + ) + + def test_pending_work_includes_non_ordered_prepaid_service(self): + """Services with a delivery-based policy ARE included in pending work.""" + order = self._make_order( + [ + {"product": self.product_storable, "qty": 2.0, "price_unit": 100.0}, + {"product": self.product_service, "qty": 4.0, "price_unit": 50.0}, + ] + ) + # Both lines count: 2×100 + 4×50 = 400 + self.assertAlmostEqual(order.sum_pending_work, 200.0 + 200.0) + + def test_pending_work_multi_line_partial_delivery(self): + """sum_pending_work aggregates remaining work across multiple partially delivered lines.""" + order = self._make_order( + [ + {"product": self.product_storable, "qty": 10.0, "price_unit": 100.0}, + {"product": self.product_service, "qty": 8.0, "price_unit": 50.0}, + ] + ) + lines = order.order_line + lines[0].qty_delivered = 6.0 # remaining: (10-6) × 100 = 400 + lines[1].qty_delivered = 2.0 # remaining: (8-2) × 50 = 300 + self.assertAlmostEqual(order.sum_pending_work, 400.0 + 300.0) + + # ------------------------------------------------------------------------- + # Edge cases + # ------------------------------------------------------------------------- + + def test_empty_order_both_fields_zero(self): + """Both fields are 0 on an order with no lines.""" + order = self.env["sale.order"].create({"partner_id": self.partner.id}) + self.assertEqual(order.sum_outstanding, 0.0) + self.assertEqual(order.sum_pending_work, 0.0) + + def test_recompute_on_qty_change(self): + """Fields recompute correctly when ordered quantity is updated.""" + order = self._make_order( + [{"product": self.product_storable, "qty": 5.0, "price_unit": 100.0}] + ) + self.assertAlmostEqual(order.sum_pending_work, 5.0 * 100.0) + order.order_line.product_uom_qty = 8.0 + self.assertAlmostEqual(order.sum_pending_work, 8.0 * 100.0) diff --git a/sale_outstanding/views/sale_views.xml b/sale_outstanding/views/sale_views.xml new file mode 100644 index 0000000..13e7f07 --- /dev/null +++ b/sale_outstanding/views/sale_views.xml @@ -0,0 +1,29 @@ + + + + sale.order.view.form.inherit + sale.order + + + + + + + + + + + + + sale.order.list.outstanding + sale.order + + + + + + + + + +