From 5018881c1ce3946b55eeb5122386adb1f6f806ea Mon Sep 17 00:00:00 2001
From: David Vidal
Date: Tue, 7 Oct 2025 16:53:13 +0200
Subject: [PATCH 1/4] [ADD] purchase_stock_cost_update: new module
MT-12014
---
purchase_stock_cost_update/README.rst | 149 ++++++
purchase_stock_cost_update/__init__.py | 1 +
purchase_stock_cost_update/__manifest__.py | 17 +
purchase_stock_cost_update/i18n/es.po | 99 ++++
.../i18n/purchase_stock_cost_update.pot | 91 ++++
purchase_stock_cost_update/models/__init__.py | 2 +
.../models/purchase_order.py | 224 ++++++++
.../models/stock_valuation_layer.py | 10 +
.../readme/CONFIGURE.md | 9 +
purchase_stock_cost_update/readme/CONTEXT.md | 11 +
.../readme/CONTRIBUTORS.md | 1 +
.../readme/DESCRIPTION.md | 2 +
purchase_stock_cost_update/readme/ROADMAP.md | 1 +
purchase_stock_cost_update/readme/USAGE.md | 15 +
.../static/description/icon.png | Bin 0 -> 10254 bytes
.../static/description/index.html | 490 ++++++++++++++++++
purchase_stock_cost_update/tests/__init__.py | 1 +
.../tests/test_purchase_stock_cost_update.py | 490 ++++++++++++++++++
.../views/purchase_order_form_views.xml | 44 ++
.../views/stock_valuation_layer_views.xml | 23 +
20 files changed, 1680 insertions(+)
create mode 100644 purchase_stock_cost_update/README.rst
create mode 100644 purchase_stock_cost_update/__init__.py
create mode 100644 purchase_stock_cost_update/__manifest__.py
create mode 100644 purchase_stock_cost_update/i18n/es.po
create mode 100644 purchase_stock_cost_update/i18n/purchase_stock_cost_update.pot
create mode 100644 purchase_stock_cost_update/models/__init__.py
create mode 100644 purchase_stock_cost_update/models/purchase_order.py
create mode 100644 purchase_stock_cost_update/models/stock_valuation_layer.py
create mode 100644 purchase_stock_cost_update/readme/CONFIGURE.md
create mode 100644 purchase_stock_cost_update/readme/CONTEXT.md
create mode 100644 purchase_stock_cost_update/readme/CONTRIBUTORS.md
create mode 100644 purchase_stock_cost_update/readme/DESCRIPTION.md
create mode 100644 purchase_stock_cost_update/readme/ROADMAP.md
create mode 100644 purchase_stock_cost_update/readme/USAGE.md
create mode 100644 purchase_stock_cost_update/static/description/icon.png
create mode 100644 purchase_stock_cost_update/static/description/index.html
create mode 100644 purchase_stock_cost_update/tests/__init__.py
create mode 100644 purchase_stock_cost_update/tests/test_purchase_stock_cost_update.py
create mode 100644 purchase_stock_cost_update/views/purchase_order_form_views.xml
create mode 100644 purchase_stock_cost_update/views/stock_valuation_layer_views.xml
diff --git a/purchase_stock_cost_update/README.rst b/purchase_stock_cost_update/README.rst
new file mode 100644
index 00000000000..8fc1496fb2f
--- /dev/null
+++ b/purchase_stock_cost_update/README.rst
@@ -0,0 +1,149 @@
+.. image:: https://odoo-community.org/readme-banner-image
+ :target: https://odoo-community.org/get-involved?utm_source=readme
+ :alt: Odoo Community Association
+
+==========================
+Update costs from purchase
+==========================
+
+..
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ !! This file is generated by oca-gen-addon-readme !!
+ !! changes will be overwritten. !!
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ !! source digest: sha256:8857022463cda546792e3e86ae5308778764bd224adb15ef09810d274d0edbb3
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+
+.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
+ :target: https://odoo-community.org/page/development-status
+ :alt: Beta
+.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png
+ :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
+ :alt: License: LGPL-3
+.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fpurchase--workflow-lightgray.png?logo=github
+ :target: https://github.com/OCA/purchase-workflow/tree/16.0/purchase_stock_cost_update
+ :alt: OCA/purchase-workflow
+.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
+ :target: https://translation.odoo-community.org/projects/purchase-workflow-16-0/purchase-workflow-16-0-purchase_stock_cost_update
+ :alt: Translate me on Weblate
+.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
+ :target: https://runboat.odoo-community.org/builds?repo=OCA/purchase-workflow&target_branch=16.0
+ :alt: Try me on Runboat
+
+|badge1| |badge2| |badge3| |badge4| |badge5|
+
+This module allows to adjust the valuation of the incoming goods related
+to their purchase order from the purchase line itself.
+
+**Table of contents**
+
+.. contents::
+ :local:
+
+Use Cases / Context
+===================
+
+When a purchase order is confirmed, the value of the received goods is
+updated upon reception. It can happen anyway that the reception of the
+goods is confirmed before the final price is recorded in the
+corresponding purchase line.
+
+For that, Odoo will fix the valuation when the invoice for that purchase
+is confirmed, but that moment could be delayed for an uncertain period
+of time while thos stored goods are being selled with wrong margins and
+the value being discounted for a wrong price unit.
+
+We want to fix those disalignments as soon as possible from the purchase
+order while keeping the native mechanism to add later corrections from
+the invoice itself.
+
+Configuration
+=============
+
+In order to use this module, you need to have some storable products
+valued on average cost (AVCO).
+
+To do so:
+
+- Go to *Inventory > Configuration > Product Categories* and select one.
+- In the **Inventory valuation** section, select the **Costing method**
+ as **Average Cost (AVCO)**.
+- Now all the products in that category will have that valuation rules.
+
+Usage
+=====
+
+In order to test the module:
+
+- Go to *Purchase > Orders* and create a new quotation.
+- Add a product with AVCO valuation and set a price.
+- Validate the purchase order.
+- Receive the products.
+- The products are now valued at the price you set in the order line.
+- You can check it in *Inventory > Reporting > Valuation* (debug mode
+ needed).
+- Now change the price in the order line.
+- You'll see that the line has changed its color to yellow and a new
+ button *Fix valuation* shows up in the header.
+- When you click that button, every disaligned valuation will be fixed.
+ If you go to the *Valuation* report you'll see the adjustment layer.
+- After this, when you invoice the purchase you won't be able to edit
+ the price anymore.
+- You can anyway add an additional valuation when you post the new
+ invoice prices.
+
+Known issues / Roadmap
+======================
+
+- Only AVCO is supported
+
+Bug Tracker
+===========
+
+Bugs are tracked on `GitHub Issues `_.
+In case of trouble, please check there if your issue has already been reported.
+If you spotted it first, help us to smash it by providing a detailed and welcomed
+`feedback `_.
+
+Do not contact contributors directly about support or help with technical issues.
+
+Credits
+=======
+
+Authors
+-------
+
+* Moduon
+
+Contributors
+------------
+
+- David Vidal (`Moduon `__)
+
+Maintainers
+-----------
+
+This module is maintained by the OCA.
+
+.. image:: https://odoo-community.org/logo.png
+ :alt: Odoo Community Association
+ :target: https://odoo-community.org
+
+OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
+.. |maintainer-chienandalu| image:: https://github.com/chienandalu.png?size=40px
+ :target: https://github.com/chienandalu
+ :alt: chienandalu
+.. |maintainer-rafaelbn| image:: https://github.com/rafaelbn.png?size=40px
+ :target: https://github.com/rafaelbn
+ :alt: rafaelbn
+
+Current `maintainers `__:
+
+|maintainer-chienandalu| |maintainer-rafaelbn|
+
+This module is part of the `OCA/purchase-workflow `_ project on GitHub.
+
+You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
diff --git a/purchase_stock_cost_update/__init__.py b/purchase_stock_cost_update/__init__.py
new file mode 100644
index 00000000000..0650744f6bc
--- /dev/null
+++ b/purchase_stock_cost_update/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/purchase_stock_cost_update/__manifest__.py b/purchase_stock_cost_update/__manifest__.py
new file mode 100644
index 00000000000..0da53562010
--- /dev/null
+++ b/purchase_stock_cost_update/__manifest__.py
@@ -0,0 +1,17 @@
+# Copyright 2025 Moduon Team S.L.
+# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html).
+{
+ "name": "Update costs from purchase",
+ "summary": "Allows to update valuation layers once the purchase is received",
+ "version": "16.0.1.0.1",
+ "category": "Purchase Management",
+ "author": "Moduon, Odoo Community Association (OCA)",
+ "maintainers": ["chienandalu", "rafaelbn"],
+ "website": "https://github.com/OCA/purchase-workflow",
+ "license": "LGPL-3",
+ "depends": ["purchase_stock"],
+ "data": [
+ "views/purchase_order_form_views.xml",
+ "views/stock_valuation_layer_views.xml",
+ ],
+}
diff --git a/purchase_stock_cost_update/i18n/es.po b/purchase_stock_cost_update/i18n/es.po
new file mode 100644
index 00000000000..d55196cc80b
--- /dev/null
+++ b/purchase_stock_cost_update/i18n/es.po
@@ -0,0 +1,99 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * purchase_stock_cost_update
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 16.0+e\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2025-10-28 14:47+0000\n"
+"PO-Revision-Date: 2025-10-28 15:50+0100\n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+"X-Generator: Poedit 3.5\n"
+
+#. module: purchase_stock_cost_update
+#: model_terms:ir.ui.view,arch_db:purchase_stock_cost_update.purchase_order_form
+msgid " Details"
+msgstr " Detalles"
+
+#. module: purchase_stock_cost_update
+#: model_terms:ir.ui.view,arch_db:purchase_stock_cost_update.purchase_order_form
+msgid ""
+" There are lines (marked in yellow) which\n"
+" prices have changed since they where received in stock."
+msgstr ""
+" Hay líneas (resaltadas en amarillo) cuyos\n"
+" precios han cambiado desde que se recibieron en almacén."
+
+#. module: purchase_stock_cost_update
+#: model:ir.model.fields,field_description:purchase_stock_cost_update.field_stock_valuation_layer__cost_update_history
+msgid "Cost Update History"
+msgstr "Historial de actualizaciones de coste"
+
+#. module: purchase_stock_cost_update
+#: model_terms:ir.ui.view,arch_db:purchase_stock_cost_update.purchase_order_form
+msgid "Fix valuation"
+msgstr "Corregir valoración"
+
+#. module: purchase_stock_cost_update
+#. odoo-python
+#: code:addons/purchase_stock_cost_update/models/purchase_order.py:0
+#, python-format
+msgid "Price difference layer created from %(line)s"
+msgstr "Capa de diferencia de valoración creada desde %(line)s"
+
+#. module: purchase_stock_cost_update
+#: model:ir.model.fields,field_description:purchase_stock_cost_update.field_stock_valuation_layer__purchase_line_id
+msgid "Purchase Line"
+msgstr "Línea de compra"
+
+#. module: purchase_stock_cost_update
+#: model:ir.model,name:purchase_stock_cost_update.model_purchase_order
+msgid "Purchase Order"
+msgstr "Pedido de compra"
+
+#. module: purchase_stock_cost_update
+#: model:ir.model,name:purchase_stock_cost_update.model_purchase_order_line
+msgid "Purchase Order Line"
+msgstr "Línea de pedido de compra"
+
+#. module: purchase_stock_cost_update
+#: model:ir.model,name:purchase_stock_cost_update.model_stock_valuation_layer
+msgid "Stock Valuation Layer"
+msgstr "Capa de valoración de stock"
+
+#. module: purchase_stock_cost_update
+#: model_terms:ir.ui.view,arch_db:purchase_stock_cost_update.purchase_order_form
+msgid ""
+"This will make unreversible changes to the valuation layers related to the "
+"affected products"
+msgstr ""
+"Esto realizará cambios irreversibles en las capas de valoración "
+"relacionadas con los productos afectados"
+
+#. module: purchase_stock_cost_update
+#: model_terms:ir.ui.view,arch_db:purchase_stock_cost_update.stock_valuation_layer_form
+msgid "Update history"
+msgstr "Historial de actualizaciones"
+
+#. module: purchase_stock_cost_update
+#: model:ir.model.fields,field_description:purchase_stock_cost_update.field_purchase_order_line__valuation_difference
+msgid "Valuation Difference"
+msgstr "Diferencia de valoración"
+
+#. module: purchase_stock_cost_update
+#: model:ir.model.fields,field_description:purchase_stock_cost_update.field_purchase_order__valuation_difference_report
+msgid "Valuation Difference Report"
+msgstr "Informe de diferencias de valoración"
+
+#. module: purchase_stock_cost_update
+#: model:ir.model.fields,field_description:purchase_stock_cost_update.field_purchase_order__valuation_differs
+#: model:ir.model.fields,field_description:purchase_stock_cost_update.field_purchase_order_line__valuation_differs
+msgid "Valuation Differs"
+msgstr "La valoración difiere"
diff --git a/purchase_stock_cost_update/i18n/purchase_stock_cost_update.pot b/purchase_stock_cost_update/i18n/purchase_stock_cost_update.pot
new file mode 100644
index 00000000000..3dcd190ed8d
--- /dev/null
+++ b/purchase_stock_cost_update/i18n/purchase_stock_cost_update.pot
@@ -0,0 +1,91 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * purchase_stock_cost_update
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 16.0\n"
+"Report-Msgid-Bugs-To: \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: purchase_stock_cost_update
+#: model_terms:ir.ui.view,arch_db:purchase_stock_cost_update.purchase_order_form
+msgid " Details"
+msgstr ""
+
+#. module: purchase_stock_cost_update
+#: model_terms:ir.ui.view,arch_db:purchase_stock_cost_update.purchase_order_form
+msgid ""
+" There are lines (marked in yellow) which\n"
+" prices have changed since they where received in stock."
+msgstr ""
+
+#. module: purchase_stock_cost_update
+#: model:ir.model.fields,field_description:purchase_stock_cost_update.field_stock_valuation_layer__cost_update_history
+msgid "Cost Update History"
+msgstr ""
+
+#. module: purchase_stock_cost_update
+#: model_terms:ir.ui.view,arch_db:purchase_stock_cost_update.purchase_order_form
+msgid "Fix valuation"
+msgstr ""
+
+#. module: purchase_stock_cost_update
+#. odoo-python
+#: code:addons/purchase_stock_cost_update/models/purchase_order.py:0
+#, python-format
+msgid "Price difference layer created from %(line)s"
+msgstr ""
+
+#. module: purchase_stock_cost_update
+#: model:ir.model.fields,field_description:purchase_stock_cost_update.field_stock_valuation_layer__purchase_line_id
+msgid "Purchase Line"
+msgstr ""
+
+#. module: purchase_stock_cost_update
+#: model:ir.model,name:purchase_stock_cost_update.model_purchase_order
+msgid "Purchase Order"
+msgstr ""
+
+#. module: purchase_stock_cost_update
+#: model:ir.model,name:purchase_stock_cost_update.model_purchase_order_line
+msgid "Purchase Order Line"
+msgstr ""
+
+#. module: purchase_stock_cost_update
+#: model:ir.model,name:purchase_stock_cost_update.model_stock_valuation_layer
+msgid "Stock Valuation Layer"
+msgstr ""
+
+#. module: purchase_stock_cost_update
+#: model_terms:ir.ui.view,arch_db:purchase_stock_cost_update.purchase_order_form
+msgid ""
+"This will make unreversible changes to the valuation layers related to the "
+"affected products"
+msgstr ""
+
+#. module: purchase_stock_cost_update
+#: model_terms:ir.ui.view,arch_db:purchase_stock_cost_update.stock_valuation_layer_form
+msgid "Update history"
+msgstr ""
+
+#. module: purchase_stock_cost_update
+#: model:ir.model.fields,field_description:purchase_stock_cost_update.field_purchase_order_line__valuation_difference
+msgid "Valuation Difference"
+msgstr ""
+
+#. module: purchase_stock_cost_update
+#: model:ir.model.fields,field_description:purchase_stock_cost_update.field_purchase_order__valuation_difference_report
+msgid "Valuation Difference Report"
+msgstr ""
+
+#. module: purchase_stock_cost_update
+#: model:ir.model.fields,field_description:purchase_stock_cost_update.field_purchase_order__valuation_differs
+#: model:ir.model.fields,field_description:purchase_stock_cost_update.field_purchase_order_line__valuation_differs
+msgid "Valuation Differs"
+msgstr ""
diff --git a/purchase_stock_cost_update/models/__init__.py b/purchase_stock_cost_update/models/__init__.py
new file mode 100644
index 00000000000..7469b56cf75
--- /dev/null
+++ b/purchase_stock_cost_update/models/__init__.py
@@ -0,0 +1,2 @@
+from . import purchase_order
+from . import stock_valuation_layer
diff --git a/purchase_stock_cost_update/models/purchase_order.py b/purchase_stock_cost_update/models/purchase_order.py
new file mode 100644
index 00000000000..83e9db70891
--- /dev/null
+++ b/purchase_stock_cost_update/models/purchase_order.py
@@ -0,0 +1,224 @@
+# Copyright 2025 Moduon Team S.L.
+# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html).
+from odoo import _, api, fields, models
+from odoo.tools.float_utils import float_compare, float_is_zero
+
+
+class PurchaseOrder(models.Model):
+ _inherit = "purchase.order"
+
+ valuation_differs = fields.Boolean(compute="_compute_valuation_differs")
+ valuation_difference_report = fields.Html(
+ compute="_compute_valuation_difference_report"
+ )
+
+ @api.depends("order_line.price_unit")
+ def _compute_valuation_differs(self):
+ self.valuation_differs = False
+ # Other states won't change the lines costs
+ for order in self.filtered(lambda x: x.state == "purchase"):
+ order.valuation_differs = any(order.order_line.mapped("valuation_differs"))
+
+ @api.depends("order_line.valuation_difference")
+ def _compute_valuation_difference_report(self):
+ self.valuation_difference_report = False
+ for order in self.filtered("valuation_differs"):
+ valuation_difference_report = ""
+ for line in order.order_line.filtered("valuation_differs"):
+ valuation_difference = (
+ f"{line.currency_id.round(line.valuation_difference)}"
+ )
+ if line.valuation_difference > 0:
+ color = "success"
+ valuation_difference = f"+{valuation_difference}"
+ else:
+ color = "danger"
+ valuation_difference_report += (
+ f"
"
+ )
+
+ def action_apply_price_difference(self):
+ self.ensure_one()
+ if not self.valuation_differs or not self.user_has_groups(
+ "purchase.group_purchase_manager"
+ ):
+ return
+ for line in self.order_line.filtered("valuation_differs"):
+ line._apply_unit_price_difference()
+ self.order_line.valuation_differs = False
+
+
+class PurchaseOrderLine(models.Model):
+ _inherit = "purchase.order.line"
+
+ valuation_differs = fields.Boolean(compute="_compute_valuation_differs", store=True)
+ valuation_difference = fields.Float(
+ compute="_compute_valuation_differs", store=True
+ )
+
+ def _get_valued_in_moves(self):
+ self.ensure_one()
+ return self._get_po_line_moves().filtered(
+ lambda m: m.state == "done" and m.product_qty != 0
+ )
+
+ @api.depends("price_subtotal", "qty_invoiced")
+ def _compute_valuation_differs(self):
+ """We can only propagate the cost change when there are quantities received
+ and there aren't invoices"""
+ self.valuation_differs = False
+ # Only Average Cost (AVCO) is supported for value adjustment
+ for line in self.filtered(
+ lambda x: x.price_unit
+ and x.qty_received
+ and not x.qty_invoiced
+ and x.product_id.cost_method == "average"
+ ):
+ (price_unit, svl_qty, price_diff, _) = line._get_valuation_diff_values()
+ if (
+ float_compare(
+ price_diff, 0, precision_rounding=line.currency_id.rounding
+ )
+ != 0
+ ):
+ line.valuation_differs = True
+ line.valuation_difference = price_diff
+
+ def _get_valuation_diff_values(self):
+ self.ensure_one()
+ moves = self._get_valued_in_moves()
+ price_unit = self.price_subtotal / self.product_uom_qty
+ svl_qty = sum(moves.stock_valuation_layer_ids.mapped("remaining_qty"))
+ remaining_value = sum(moves.stock_valuation_layer_ids.mapped("remaining_value"))
+ price_diff = (price_unit * svl_qty) - remaining_value
+ return price_unit, svl_qty, price_diff, remaining_value
+
+ def _prepare_price_difference_svl(self):
+ """We might need to create an adjustment layer for the price difference when
+ some of the valued units are already gone"""
+ return {
+ "company_id": self.company_id.id,
+ "product_id": self.product_id.id,
+ "quantity": 0,
+ "unit_cost": 0,
+ "remaining_qty": 0,
+ "remaining_value": 0,
+ "stock_valuation_layer_id": False,
+ }
+
+ def _prepare_pdiff_svl_vals(self, corrected_layer, price_diff):
+ self.ensure_one()
+ price_diff = self.currency_id.round(price_diff)
+ return {
+ "purchase_line_id": self.id,
+ "company_id": self.company_id.id,
+ "product_id": self.product_id.id,
+ "quantity": 0,
+ "unit_cost": 0,
+ "remaining_qty": 0,
+ "remaining_value": 0,
+ "value": price_diff,
+ "price_diff_value": price_diff,
+ "stock_valuation_layer_id": corrected_layer.id,
+ "description": _("Price difference layer created from %(line)s")
+ % {"line": self.display_name},
+ }
+
+ def _get_purchase_line_pdiff_svl(self):
+ self.ensure_one()
+ return self.env["stock.valuation.layer"].search(
+ [("purchase_line_id", "=", self.id)]
+ )
+
+ def _log_svl_cost_update(self, layer, msg, origin_values=None):
+ """Builds the history of the costs updates for the given layer. We can tell
+ who, when and what values did change"""
+ previous_history = layer.cost_update_history or ""
+ change = ""
+ if origin_values:
+ change = (
+ f"value: {origin_values['value']} / "
+ f"unit cost: {origin_values['unit_cost']} => "
+ f"value: {layer.value} / "
+ f"unit cost: {layer.unit_cost}"
+ )
+ return (
+ f"{previous_history}"
+ f"- {fields.Datetime.now()} [{self.env.user.name}]: {msg} {change}\n"
+ )
+
+ def _apply_unit_price_difference(self):
+ """Fix valuation from the purchase price"""
+ self.ensure_one()
+ # Invalidate cache for the svl values of the product
+ self.product_id.invalidate_recordset(["value_svl", "quantity_svl"])
+ (
+ price_unit,
+ svl_qty,
+ price_diff,
+ remaining_value,
+ ) = self._get_valuation_diff_values()
+ unit_cost = price_diff / svl_qty
+ moves = self._get_valued_in_moves()
+ fields.first(moves.stock_valuation_layer_ids)
+ for move in moves:
+ valuation_layers = move.stock_valuation_layer_ids
+ for layer in valuation_layers:
+ origin_values, *_ = layer.read()
+ # We need to fix the original values
+ value = self.product_id._prepare_in_svl_vals(
+ layer.remaining_qty, unit_cost
+ )["value"]
+ # Avoid rounding issues getting the precise price_unit
+ current_unit_cost = 0
+ if layer.remaining_qty:
+ current_unit_cost = layer.remaining_value / layer.remaining_qty
+ # Case for returns
+ elif layer.quantity:
+ current_unit_cost = layer.value / layer.quantity
+ new_layer_unit_cost = current_unit_cost + unit_cost
+ if layer.value > 0:
+ layer.remaining_value += value
+ layer_remaining_qty = layer.remaining_qty + sum(
+ move.returned_move_ids.mapped("quantity_done")
+ )
+ new_layer_value = (new_layer_unit_cost * layer_remaining_qty) + (
+ layer.unit_cost * (layer.quantity - layer_remaining_qty)
+ )
+ layer.value = new_layer_unit_cost * layer.quantity
+ layer.unit_cost = new_layer_unit_cost
+ price_diff = new_layer_value - layer.value
+ if not float_is_zero(
+ price_diff, precision_rounding=self.currency_id.rounding
+ ):
+ vals = self._prepare_pdiff_svl_vals(layer, price_diff)
+ price_diff_svl = self._get_purchase_line_pdiff_svl()
+ if not price_diff_svl:
+ price_diff_svl = layer.create(vals)
+ else:
+ price_diff_svl.update(vals)
+ price_diff_svl.cost_update_history = self._log_svl_cost_update(
+ price_diff_svl,
+ f"Cost adjustment for {self.order_id.name} "
+ f"for a value of {price_diff}",
+ )
+ else:
+ layer.unit_cost = new_layer_unit_cost
+ layer.value = new_layer_unit_cost * layer.quantity
+ layer.cost_update_history = self._log_svl_cost_update(
+ layer,
+ f"Updated cost from {self.order_id.name}",
+ origin_values=origin_values,
+ )
+ # Now let's update the product cost
+ product = self.product_id.with_company(self.company_id.id)
+ if not float_is_zero(
+ product.quantity_svl, precision_rounding=product.uom_id.rounding
+ ):
+ product.sudo().with_context(disable_auto_svl=True).write(
+ {"standard_price": product.value_svl / product.quantity_svl}
+ )
diff --git a/purchase_stock_cost_update/models/stock_valuation_layer.py b/purchase_stock_cost_update/models/stock_valuation_layer.py
new file mode 100644
index 00000000000..d83a6875a69
--- /dev/null
+++ b/purchase_stock_cost_update/models/stock_valuation_layer.py
@@ -0,0 +1,10 @@
+# Copyright 2025 Moduon Team S.L.
+# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html).
+from odoo import fields, models
+
+
+class StockValuationLayer(models.Model):
+ _inherit = "stock.valuation.layer"
+
+ purchase_line_id = fields.Many2one(comodel_name="purchase.order.line")
+ cost_update_history = fields.Text(readonly=True)
diff --git a/purchase_stock_cost_update/readme/CONFIGURE.md b/purchase_stock_cost_update/readme/CONFIGURE.md
new file mode 100644
index 00000000000..61b8884ed74
--- /dev/null
+++ b/purchase_stock_cost_update/readme/CONFIGURE.md
@@ -0,0 +1,9 @@
+In order to use this module, you need to have some storable products valued on average
+cost (AVCO).
+
+To do so:
+
+- Go to *Inventory > Configuration > Product Categories* and select one.
+- In the **Inventory valuation** section, select the **Costing method** as **Average
+ Cost (AVCO)**.
+- Now all the products in that category will have that valuation rules.
diff --git a/purchase_stock_cost_update/readme/CONTEXT.md b/purchase_stock_cost_update/readme/CONTEXT.md
new file mode 100644
index 00000000000..b4cbeae5bc2
--- /dev/null
+++ b/purchase_stock_cost_update/readme/CONTEXT.md
@@ -0,0 +1,11 @@
+When a purchase order is confirmed, the value of the received goods is updated upon
+reception. It can happen anyway that the reception of the goods is confirmed before
+the final price is recorded in the corresponding purchase line.
+
+For that, Odoo will fix the valuation when the invoice for that purchase is confirmed,
+but that moment could be delayed for an uncertain period of time while thos stored
+goods are being selled with wrong margins and the value being discounted for a wrong
+price unit.
+
+We want to fix those disalignments as soon as possible from the purchase order while
+keeping the native mechanism to add later corrections from the invoice itself.
diff --git a/purchase_stock_cost_update/readme/CONTRIBUTORS.md b/purchase_stock_cost_update/readme/CONTRIBUTORS.md
new file mode 100644
index 00000000000..9f0a2a789c4
--- /dev/null
+++ b/purchase_stock_cost_update/readme/CONTRIBUTORS.md
@@ -0,0 +1 @@
+- David Vidal ([Moduon](https://www.moduon.team/))
diff --git a/purchase_stock_cost_update/readme/DESCRIPTION.md b/purchase_stock_cost_update/readme/DESCRIPTION.md
new file mode 100644
index 00000000000..74c7193eae5
--- /dev/null
+++ b/purchase_stock_cost_update/readme/DESCRIPTION.md
@@ -0,0 +1,2 @@
+This module allows to adjust the valuation of the incoming goods related to their
+purchase order from the purchase line itself.
diff --git a/purchase_stock_cost_update/readme/ROADMAP.md b/purchase_stock_cost_update/readme/ROADMAP.md
new file mode 100644
index 00000000000..e23735c5a18
--- /dev/null
+++ b/purchase_stock_cost_update/readme/ROADMAP.md
@@ -0,0 +1 @@
+- Only AVCO is supported
diff --git a/purchase_stock_cost_update/readme/USAGE.md b/purchase_stock_cost_update/readme/USAGE.md
new file mode 100644
index 00000000000..4d7b424d7dd
--- /dev/null
+++ b/purchase_stock_cost_update/readme/USAGE.md
@@ -0,0 +1,15 @@
+In order to test the module:
+
+- Go to *Purchase > Orders* and create a new quotation.
+- Add a product with AVCO valuation and set a price.
+- Validate the purchase order.
+- Receive the products.
+- The products are now valued at the price you set in the order line.
+- You can check it in *Inventory > Reporting > Valuation* (debug mode needed).
+- Now change the price in the order line.
+- You'll see that the line has changed its color to yellow and a new button
+ *Fix valuation* shows up in the header.
+- When you click that button, every disaligned valuation will be fixed. If you go to the
+ *Valuation* report you'll see the adjustment layer.
+- After this, when you invoice the purchase you won't be able to edit the price anymore.
+- You can anyway add an additional valuation when you post the new invoice prices.
diff --git a/purchase_stock_cost_update/static/description/icon.png b/purchase_stock_cost_update/static/description/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..1dcc49c24f364e9adf0afbc6fc0bac6dbecdeb11
GIT binary patch
literal 10254
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
When a purchase order is confirmed, the value of the received goods is
+updated upon reception. It can happen anyway that the reception of the
+goods is confirmed before the final price is recorded in the
+corresponding purchase line.
+
For that, Odoo will fix the valuation when the invoice for that purchase
+is confirmed, but that moment could be delayed for an uncertain period
+of time while thos stored goods are being selled with wrong margins and
+the value being discounted for a wrong price unit.
+
We want to fix those disalignments as soon as possible from the purchase
+order while keeping the native mechanism to add later corrections from
+the invoice itself.
Bugs are tracked on GitHub Issues.
+In case of trouble, please check there if your issue has already been reported.
+If you spotted it first, help us to smash it by providing a detailed and welcomed
+feedback.
+
Do not contact contributors directly about support or help with technical issues.
OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
+
diff --git a/purchase_stock_cost_update/tests/__init__.py b/purchase_stock_cost_update/tests/__init__.py
new file mode 100644
index 00000000000..261498a3b05
--- /dev/null
+++ b/purchase_stock_cost_update/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_purchase_stock_cost_update
diff --git a/purchase_stock_cost_update/tests/test_purchase_stock_cost_update.py b/purchase_stock_cost_update/tests/test_purchase_stock_cost_update.py
new file mode 100644
index 00000000000..3e2682ebd6a
--- /dev/null
+++ b/purchase_stock_cost_update/tests/test_purchase_stock_cost_update.py
@@ -0,0 +1,490 @@
+# Copyright 2025 Moduon Team S.L.
+# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.html).
+import logging
+from functools import partial
+
+from odoo.fields import Date
+from odoo.tests import Form, common
+
+_logger = logging.getLogger(__name__)
+
+
+class PurchasStockCostUpdateCase(common.TransactionCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.categ_fruits_avco = cls.env["product.category"].create(
+ {
+ "name": "Fruits",
+ "property_cost_method": "average",
+ }
+ )
+ cls.peach = cls.env["product.product"].create(
+ {
+ "name": "Peach of Teruel",
+ "type": "product",
+ "uom_id": cls.env.ref("uom.product_uom_kgm").id,
+ "uom_po_id": cls.env.ref("uom.product_uom_kgm").id,
+ "categ_id": cls.categ_fruits_avco.id,
+ }
+ )
+ cls.grapes = cls.env["product.product"].create(
+ {
+ "name": "Moscatel Grapes",
+ "type": "product",
+ "uom_id": cls.env.ref("uom.product_uom_kgm").id,
+ "uom_po_id": cls.env.ref("uom.product_uom_kgm").id,
+ "categ_id": cls.categ_fruits_avco.id,
+ }
+ )
+ cls.raspberry = cls.env["product.product"].create(
+ {
+ "name": "raspberry",
+ "type": "product",
+ "uom_id": cls.env.ref("uom.product_uom_kgm").id,
+ "uom_po_id": cls.env.ref("uom.product_uom_kgm").id,
+ "categ_id": cls.categ_fruits_avco.id,
+ "tracking": "lot",
+ }
+ )
+ cls.crown_melon = cls.env["product.product"].create(
+ {
+ "name": "crown_melon",
+ "type": "product",
+ "uom_id": cls.env.ref("uom.product_uom_kgm").id,
+ "uom_po_id": cls.env.ref("uom.product_uom_kgm").id,
+ "categ_id": cls.categ_fruits_avco.id,
+ "tracking": "lot",
+ }
+ )
+ cls.supplier_frutas_calanda = cls.env["res.partner"].create(
+ {
+ "name": "Frutas Calanda",
+ }
+ )
+ # Weirdest UoM ever... but it's convenient for our tests
+ cls.dekakilogram = cls.env["uom.uom"].create(
+ {
+ "name": "dakg",
+ "category_id": cls.env.ref("uom.product_uom_kgm").category_id.id,
+ "uom_type": "bigger",
+ "factor_inv": 10,
+ }
+ )
+ cls.warehouse = cls.env.ref("stock.warehouse0")
+ cls.warehouse.write(
+ {"delivery_steps": "ship_only", "reception_steps": "one_step"}
+ )
+
+ def _new_purchase_order(self, supplier, lines_map=None):
+ po_form = Form(self.env["purchase.order"])
+ po_form.partner_id = supplier
+ for product, values in lines_map:
+ with po_form.order_line.new() as line:
+ line.product_id = product
+ if values.get("uom"):
+ line.product_uom = values["uom"]
+ line.product_qty = values["qty"]
+ line.price_unit = values["price"]
+ if values.get("discount"):
+ line.discount = values["discount"]
+ return po_form.save()
+
+ def _validate_purchase_reception(self, pickings, lot=None):
+ lot = lot or "001"
+ for picking in pickings.filtered(lambda x: x.state not in ["done", "cancel"]):
+ tracked_lines = picking.move_line_ids.filtered(
+ lambda x: x.product_id.tracking == "lot"
+ )
+ if picking.picking_type_code == "incoming":
+ tracked_lines.lot_name = lot
+ else:
+ for line in tracked_lines:
+ lot = self.env["stock.lot"].search(
+ [("name", "=", lot), ("product_id", "=", line.product_id.id)]
+ )
+ line.lot_id = lot
+ picking.action_set_quantities_to_reservation()
+ picking._action_done()
+
+ def _partial_return(self, picking, qty):
+ stock_return_picking_form = Form(
+ self.env["stock.return.picking"].with_context(
+ active_ids=picking.ids,
+ active_id=picking.id,
+ active_model="stock.picking",
+ )
+ )
+ stock_return_picking = stock_return_picking_form.save()
+ stock_return_picking.product_return_moves.quantity = qty
+ stock_return_picking_action = stock_return_picking.create_returns()
+ return_pick = self.env["stock.picking"].browse(
+ stock_return_picking_action["res_id"]
+ )
+ self._validate_purchase_reception(return_pick)
+
+ def _deliver_to_customer(self, product, qty, lot=None):
+ picking_out_form = Form(self.env["stock.picking"])
+ picking_out_form.picking_type_id = self.env.ref("stock.picking_type_out")
+ with picking_out_form.move_ids_without_package.new() as move:
+ move.product_id = product
+ move.product_uom_qty = qty
+ picking = picking_out_form.save()
+ picking.action_confirm()
+ self._validate_purchase_reception(picking, lot=lot)
+ return picking
+
+ def _valuation_layers(self, product):
+ return self.env["stock.valuation.layer"].search(
+ [("product_id", "=", product.id)]
+ )
+
+ def _log_svls(self, product, title=None):
+ """Tool to quickly log the svls values"""
+ fields = [
+ "value",
+ "quantity",
+ "unit_cost",
+ "reference",
+ "remaining_value",
+ "remaining_qty",
+ ]
+ totals = ["value", "quantity", "remaining_value", "remaining_qty"]
+ layers = self._valuation_layers(product)
+ layers_values = layers.read(fields)
+ if not layers_values:
+ return
+ header = list(layers_values[0].keys())
+ footer = [sum(layers.mapped(f)) if f in totals else "" for f in header]
+ rows = [list(layer.values()) for layer in layers_values]
+ table = [header, *rows]
+ widths = [max(len(str(item)) + 3 for item in col) for col in zip(*table)]
+ separator = ["─" * w for w in widths]
+ table.insert(1, separator)
+ table.append(separator)
+ if len(rows) > 1:
+ table.append(footer)
+ if title:
+ _logger.info(
+ f"\033[1m\033[3m[{product.name} "
+ f"price: {product.standard_price} value: {product.value_svl}]"
+ f"\033[0m {title}\033[0m"
+ )
+ header = "".join(
+ str(item).rjust(width) for item, width in zip(table[0], widths)
+ )
+ # Print in bold
+ _logger.info("".join(separator))
+ _logger.info(f"\033[1m{header}\033[0m")
+ for row in table[1:]:
+ _logger.info(
+ "".join(str(item).rjust(width) for item, width in zip(row, widths))
+ )
+
+ def assertValuation(self, product, valuation, price):
+ self.assertAlmostEqual(product.value_svl, valuation)
+ self.assertAlmostEqual(product.standard_price, price)
+
+ def _assert_valuation_layers_count(self, product, length):
+ svls_length = self.env["stock.valuation.layer"].search_count(
+ [("product_id", "=", product.id)]
+ )
+ self.assertEqual(svls_length, length)
+
+ def _purchase_and_receive(self, lines_map=None, lot_name=None):
+ """Automatic process of the order map"""
+ # It's always a good time for peaches
+ if not lines_map:
+ lines_map = [(self.peach, {"price": 1.1, "qty": 100})]
+ purchase_order = self._new_purchase_order(
+ self.supplier_frutas_calanda, lines_map
+ )
+ purchase_order.button_confirm()
+ self._validate_purchase_reception(purchase_order.picking_ids, lot=lot_name)
+ return purchase_order
+
+ def test_01_update_reception_cost_from_vendor_bill(self):
+ """Regular Odoo behavior
+ 1. We'll confirm the purchase order with an initial unit price
+ 2. Then we receive the goods which will value those entries.
+ 3. And later the cost/value will be fixed from the vendor bill
+ Expected result: the valuation is adjusted once the vendor bill is posted.
+ """
+ purchase_order = self._purchase_and_receive()
+ # 5. The purchase team sets the price at last and the price difference flags are
+ # raised.
+ purchase_order.order_line.price_unit = 1.2
+ self.assertTrue(purchase_order.valuation_differs)
+ self.assertValuation(self.peach, valuation=110, price=1.1)
+ self._log_svls(self.peach, title="1. Initial valuation after reception")
+ # 6. The order is invoiced right on and the prices are then fixed
+ purchase_order.action_create_invoice()
+ purchase_order.invoice_ids.invoice_date = Date.today()
+ purchase_order.invoice_ids._post()
+ self.assertValuation(self.peach, valuation=120, price=1.2)
+ self._log_svls(self.peach, title="2. Price unit changed in invoice")
+
+ def test_02_update_reception_cost_from_the_purchase_line(self):
+ """The main goal for this module is to be able to update the product cost
+ as soon as possible, as we might not be able to have a vendor bill up until
+ way after the reception. So in this case:
+ 1. We'll confirm the purchase order with an initial unit price
+ 2. Then we receive the goods which will value those entries.
+ 3. We'll be able to fix the product valuation before the vendor bill is
+ issued.
+ 4. Once the vendor bill is issued there won't be any price differences to
+ fix anymore.
+ """
+ purchase_order = self._purchase_and_receive()
+ # 5. The purchase team sets the price at last and the price difference flags are
+ # raised.
+ self._log_svls(self.peach, title="1. Initial valuation after reception")
+ purchase_order.order_line.price_unit = 1.2
+ self.assertTrue(purchase_order.valuation_differs)
+ self.assertValuation(self.peach, valuation=110, price=1.1)
+ # 6. Now we can press the "Fix valuation" button to adjust those differences
+ purchase_order.action_apply_price_difference()
+ self.assertValuation(self.peach, valuation=120, price=1.2)
+ self._log_svls(self.peach, title="2. Price changed and fixed in PO")
+ # 7. The order is invoiced later and the valuation layers remain the same
+ purchase_order.action_create_invoice()
+ purchase_order.invoice_ids.invoice_date = Date.today()
+ purchase_order.invoice_ids._post()
+ self.assertValuation(self.peach, valuation=120, price=1.2)
+ self._log_svls(self.peach, title="3. Remains the same after invoice")
+
+ def test_03_update_reception_cost_from_the_purchase_line_and_from_invoice(self):
+ """In this case we want to ensure that the core invoice valuation adjustment
+ still works after the valuation was updated from the PO.
+ 1. We'll confirm the purchase order with an initial unit price
+ 2. Then we receive the goods which will value those entries.
+ 3. We'll be able to fix the product valuation before the vendor bill is
+ issued.
+ 4. Once the vendor bill is issued the purchase team adds a new price
+ difference that which adjust the product value as well when the bill
+ is posted.
+ """
+ purchase_order = self._purchase_and_receive()
+ # 5. The purchase team sets the price at last and the price difference flags are
+ # raised.
+ purchase_order.order_line.price_unit = 1.2
+ self.assertTrue(purchase_order.valuation_differs)
+ self.assertAlmostEqual(self.peach.standard_price, 1.1)
+ self.assertValuation(self.peach, valuation=110, price=1.1)
+ # 6. Now we can press the "Fix valuation" button to adjust those differences
+ purchase_order.action_apply_price_difference()
+ self.assertValuation(self.peach, valuation=120, price=1.2)
+ # 7. The order is invoiced and later the value is fixed again
+ purchase_order.action_create_invoice()
+ purchase_order.invoice_ids.invoice_date = Date.today()
+ purchase_order.invoice_ids.invoice_line_ids.price_unit = 1.3
+ purchase_order.invoice_ids._post()
+ self.assertValuation(self.peach, valuation=130, price=1.3)
+
+ def test_04a_update_reception_cost_with_returns_from_the_invoice(self):
+ """Let's do it harder. Now we'll do some extra pickings that will add new
+ valuation layers to fix"""
+ purchase_order = self._purchase_and_receive()
+ # 5. More units are added to the purchase order. We take them in
+ purchase_order.order_line.product_qty = 150
+ self._validate_purchase_reception(purchase_order.picking_ids)
+ self.assertValuation(self.peach, valuation=165, price=1.1)
+ # 5. The purchase team sets the price at last and the price difference flags are
+ # raised.
+ self._partial_return(purchase_order.picking_ids[0], 10)
+ self.assertFalse(purchase_order.valuation_differs)
+ purchase_order.order_line.price_unit = 1
+ self.assertTrue(purchase_order.valuation_differs)
+ # 7. The order is invoiced later and we change the price once again
+ purchase_order.action_create_invoice()
+ purchase_order.invoice_ids.invoice_date = Date.today()
+ purchase_order.invoice_ids.invoice_line_ids.price_unit = 1.3
+ purchase_order.invoice_ids._post()
+ self.assertValuation(self.peach, valuation=182, price=1.3)
+
+ def test_04b_update_reception_cost_from_the_purchase_line(self):
+ """Let's do it harder. Now we'll do some extra pickings that will add new
+ valuation layers to fix"""
+ purchase_order = self._purchase_and_receive()
+ # 5. More units are added to the purchase order. We take them in
+ purchase_order.order_line.product_qty = 150
+ self._validate_purchase_reception(purchase_order.picking_ids)
+ self.assertValuation(self.peach, valuation=165, price=1.1)
+ # 5. The purchase team sets the price at last and the price difference flags are
+ # raised.
+ self._partial_return(purchase_order.picking_ids[0], 10)
+ self.assertFalse(purchase_order.valuation_differs)
+ # Now let's decrease the value. The valuation should change accordingly
+ purchase_order.order_line.price_unit = 1
+ purchase_order.action_apply_price_difference()
+ self.assertValuation(self.peach, valuation=140, price=1)
+ # 7. The order is invoiced later and we change the price once again
+ purchase_order.action_create_invoice()
+ purchase_order.invoice_ids.invoice_date = Date.today()
+ purchase_order.invoice_ids.invoice_line_ids.price_unit = 1.3
+ purchase_order.invoice_ids._post()
+ self.assertValuation(self.peach, valuation=182, price=1.3)
+
+ def test_05_full_history(self):
+ """Even harder. A full history tracing the valuation all along"""
+ # Receive 100 kg of peaches at 1.1 - Peaches value: 110
+ self._purchase_and_receive()
+ self.assertAlmostEqual(self.peach.value_svl, 110)
+ self.assertValuation(self.peach, valuation=110, price=1.1)
+ # Let's receive 100 kg more - Peachs value: 220
+ purchase_2 = self._purchase_and_receive()
+ self.assertValuation(self.peach, valuation=220, price=1.1)
+ # Let's deliver 50 peaches
+ self._deliver_to_customer(self.peach, 10)
+ self.assertValuation(self.peach, valuation=209, price=1.1)
+ # Our last order prices has been raised!
+ # The value of the stock is now:
+ # 99 (1.1 * 90) The FIFO putaway strategy discounts value from these layers
+ # + 200 (2.0 * 100)
+ # The AVCO price is also changed!
+ purchase_2.order_line.price_unit = 2
+ purchase_2.action_apply_price_difference()
+ self.assertValuation(self.peach, valuation=299, price=1.57)
+ purchase_2.action_create_invoice()
+ purchase_2.invoice_ids.invoice_date = Date.today()
+ purchase_2.invoice_ids._post()
+ # The value should remain untouched after the invoice
+ self.assertValuation(self.peach, valuation=299, price=1.57)
+
+ def test_06_full_history_fix_in_invoicing(self):
+ """The same as the former case, but we fix it in the invoice. The resulting
+ valuation should be the same!"""
+ # Receive 100 kg of peaches at 1.1 - Peaches value: 110
+ self._purchase_and_receive()
+ self.assertAlmostEqual(self.peach.value_svl, 110)
+ self.assertValuation(self.peach, valuation=110, price=1.1)
+ # Let's receive 100 kg more - Peachs value: 220
+ purchase_2 = self._purchase_and_receive()
+ self.assertValuation(self.peach, valuation=220, price=1.1)
+ # Let's deliver 50 peaches
+ self._deliver_to_customer(self.peach, 10)
+ self.assertValuation(self.peach, valuation=209, price=1.1)
+ purchase_2.order_line.price_unit = 2
+ purchase_2.action_create_invoice()
+ purchase_2.invoice_ids.invoice_date = Date.today()
+ purchase_2.invoice_ids._post()
+ self.assertValuation(self.peach, valuation=299, price=1.57)
+
+ def _test_multiple_receptions_lots_and_delivers(self, uom=None):
+ if not uom:
+ uom = self.env.ref("uom.product_uom_kgm")
+ uom_qty = partial(
+ self.env.ref("uom.product_uom_kgm")._compute_quantity, to_unit=uom
+ )
+
+ def uom_price(price):
+ return price * uom.factor_inv
+
+ # 1. We order raspberries and expensive melons at this price tag
+ purchase_order_1 = self._purchase_and_receive(
+ lines_map=[
+ (
+ self.raspberry,
+ {"price": uom_price(10), "qty": uom_qty(10), "uom": uom},
+ ),
+ (
+ self.crown_melon,
+ {"price": uom_price(100), "qty": uom_qty(10), "uom": uom},
+ ),
+ ],
+ lot_name="001",
+ )
+ self.assertValuation(self.raspberry, valuation=100, price=10)
+ self.assertValuation(self.crown_melon, valuation=1000, price=100)
+ # 2. Second purchase order with different pricing
+ purchase_order_2 = self._purchase_and_receive(
+ lines_map=[
+ (
+ self.raspberry,
+ {"price": uom_price(20), "qty": uom_qty(10), "uom": uom},
+ ),
+ (
+ self.crown_melon,
+ {"price": uom_price(200), "qty": uom_qty(10), "uom": uom},
+ ),
+ ],
+ lot_name="002",
+ )
+ self.assertValuation(self.raspberry, valuation=300, price=15)
+ self.assertValuation(self.crown_melon, valuation=3000, price=150)
+ # Sell 1 unit for lot 001: the valuations is fixed
+ self._deliver_to_customer(self.raspberry, 1, lot="001")
+ self.assertValuation(self.raspberry, valuation=285, price=15)
+ # Change the price of raspberries in the first purchase order
+ purchase_order_1.order_line.filtered(
+ lambda x: x.product_id == self.raspberry
+ ).price_unit = uom_price(5)
+ self.assertTrue(purchase_order_1.valuation_differs)
+ return purchase_order_1, purchase_order_2
+
+ def test_07_test_multiple_receptions_lots_and_delivers_fix_from_invoice(
+ self, uom=None
+ ):
+ """Odoo standard valuation fix"""
+ purchase_order_1, _po2 = self._test_multiple_receptions_lots_and_delivers(
+ uom=uom
+ )
+ purchase_order_1.action_create_invoice()
+ purchase_order_1.invoice_ids.invoice_date = Date.today()
+ purchase_order_1.invoice_ids._post()
+ self.assertValuation(self.raspberry, valuation=240, price=12.63)
+
+ def test_08_test_multiple_receptions_lots_and_delivers_fix_from_purchase(
+ self, uom=None
+ ):
+ """Odoo standard valuation fix"""
+ purchase_order_1, _po2 = self._test_multiple_receptions_lots_and_delivers(
+ uom=uom
+ )
+ purchase_order_1.action_apply_price_difference()
+ self.assertValuation(self.raspberry, valuation=240, price=12.63)
+ purchase_order_1.action_create_invoice()
+ purchase_order_1.invoice_ids.invoice_date = Date.today()
+ purchase_order_1.invoice_ids._post()
+ self.assertValuation(self.raspberry, valuation=240, price=12.63)
+
+ def test_09_test_multiple_receptions_and_delivers_uom_fix_from_invoice(self):
+ """Now the same as in 07 but with different units of measure"""
+ self.test_07_test_multiple_receptions_lots_and_delivers_fix_from_invoice(
+ uom=self.dekakilogram
+ )
+
+ def test_10_test_multiple_receptions_and_delivers_uom_fix_from_purchase(self):
+ """Now the same as in 07 but with different units of measure"""
+ self.test_08_test_multiple_receptions_lots_and_delivers_fix_from_purchase(
+ uom=self.dekakilogram
+ )
+
+ def test_11_discount_roundings(self):
+ # TODO: From v17, `discount` is available as a core feature
+ if "discount" not in self.env["purchase.order.line"]:
+ _logger.info(
+ "purchase_discount unavailable: skipping discount rounding tests..."
+ )
+ return
+ self._purchase_and_receive(
+ lines_map=[(self.raspberry, {"price": 3.68, "qty": 1})]
+ )
+ self.assertValuation(self.raspberry, valuation=3.68, price=3.68)
+ self._deliver_to_customer(self.raspberry, 1)
+ self.assertValuation(self.raspberry, valuation=0, price=3.68)
+ purchase_order = self._purchase_and_receive(
+ lines_map=[(self.raspberry, {"price": 4.90, "qty": 150, "discount": 25})]
+ )
+ self.assertValuation(self.raspberry, valuation=551.25, price=3.68)
+ self._partial_return(purchase_order.picking_ids[0], 50)
+ self.assertFalse(purchase_order.valuation_differs)
+ purchase_order.order_line.write({"price_unit": 6, "discount": 0})
+ self.assertTrue(purchase_order.valuation_differs)
+ purchase_order.action_apply_price_difference()
+ self._log_svls(self.raspberry)
+ self.assertValuation(self.raspberry, valuation=600, price=6)
+ purchase_order.action_create_invoice()
+ purchase_order.invoice_ids.invoice_date = Date.today()
+ purchase_order.invoice_ids._post()
+ self.assertValuation(self.raspberry, valuation=600, price=6)
diff --git a/purchase_stock_cost_update/views/purchase_order_form_views.xml b/purchase_stock_cost_update/views/purchase_order_form_views.xml
new file mode 100644
index 00000000000..9c848dbe6aa
--- /dev/null
+++ b/purchase_stock_cost_update/views/purchase_order_form_views.xml
@@ -0,0 +1,44 @@
+
+
+ purchase.order
+
+
+
+
+
+ There are lines (marked in yellow) which
+ prices have changed since they where received in stock.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ valuation_differs
+
+
+
+
diff --git a/purchase_stock_cost_update/views/stock_valuation_layer_views.xml b/purchase_stock_cost_update/views/stock_valuation_layer_views.xml
new file mode 100644
index 00000000000..fee4eaf0eb1
--- /dev/null
+++ b/purchase_stock_cost_update/views/stock_valuation_layer_views.xml
@@ -0,0 +1,23 @@
+
+
+ stock.valuation.layer
+
+
+
+
+
+
+
+
+
+
+
+
+
From f21ae00f606cdea2b19b328208537d626113a748 Mon Sep 17 00:00:00 2001
From: Simone Rubino
Date: Mon, 20 Apr 2026 15:15:05 +0200
Subject: [PATCH 2/4] [IMP] purchase_stock_cost_update: black, isort, prettier
---
.../odoo/addons/purchase_stock_cost_update | 1 +
setup/purchase_stock_cost_update/setup.py | 6 ++++++
2 files changed, 7 insertions(+)
create mode 120000 setup/purchase_stock_cost_update/odoo/addons/purchase_stock_cost_update
create mode 100644 setup/purchase_stock_cost_update/setup.py
diff --git a/setup/purchase_stock_cost_update/odoo/addons/purchase_stock_cost_update b/setup/purchase_stock_cost_update/odoo/addons/purchase_stock_cost_update
new file mode 120000
index 00000000000..226efb49723
--- /dev/null
+++ b/setup/purchase_stock_cost_update/odoo/addons/purchase_stock_cost_update
@@ -0,0 +1 @@
+../../../../purchase_stock_cost_update
\ No newline at end of file
diff --git a/setup/purchase_stock_cost_update/setup.py b/setup/purchase_stock_cost_update/setup.py
new file mode 100644
index 00000000000..28c57bb6403
--- /dev/null
+++ b/setup/purchase_stock_cost_update/setup.py
@@ -0,0 +1,6 @@
+import setuptools
+
+setuptools.setup(
+ setup_requires=['setuptools-odoo'],
+ odoo_addon=True,
+)
From d9a6de9b7773260a467c0b3402a53a0761dbc1c1 Mon Sep 17 00:00:00 2001
From: Simone Rubino
Date: Mon, 20 Apr 2026 15:16:15 +0200
Subject: [PATCH 3/4] [MIG] purchase_stock_cost_update: Migration to 14.0
---
purchase_stock_cost_update/README.rst | 25 +++---
purchase_stock_cost_update/__manifest__.py | 2 +-
.../models/purchase_order.py | 11 +--
purchase_stock_cost_update/readme/CONTEXT.md | 8 +-
.../readme/CONTRIBUTORS.md | 2 +
purchase_stock_cost_update/readme/USAGE.md | 2 -
.../static/description/index.html | 21 ++---
.../tests/test_purchase_stock_cost_update.py | 81 ++++++++++++-------
.../views/purchase_order_form_views.xml | 2 +-
9 files changed, 78 insertions(+), 76 deletions(-)
diff --git a/purchase_stock_cost_update/README.rst b/purchase_stock_cost_update/README.rst
index 8fc1496fb2f..36b13403ee5 100644
--- a/purchase_stock_cost_update/README.rst
+++ b/purchase_stock_cost_update/README.rst
@@ -21,13 +21,13 @@ Update costs from purchase
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
:alt: License: LGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fpurchase--workflow-lightgray.png?logo=github
- :target: https://github.com/OCA/purchase-workflow/tree/16.0/purchase_stock_cost_update
+ :target: https://github.com/OCA/purchase-workflow/tree/14.0/purchase_stock_cost_update
:alt: OCA/purchase-workflow
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
- :target: https://translation.odoo-community.org/projects/purchase-workflow-16-0/purchase-workflow-16-0-purchase_stock_cost_update
+ :target: https://translation.odoo-community.org/projects/purchase-workflow-14-0/purchase-workflow-14-0-purchase_stock_cost_update
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
- :target: https://runboat.odoo-community.org/builds?repo=OCA/purchase-workflow&target_branch=16.0
+ :target: https://runboat.odoo-community.org/builds?repo=OCA/purchase-workflow&target_branch=14.0
:alt: Try me on Runboat
|badge1| |badge2| |badge3| |badge4| |badge5|
@@ -48,14 +48,8 @@ updated upon reception. It can happen anyway that the reception of the
goods is confirmed before the final price is recorded in the
corresponding purchase line.
-For that, Odoo will fix the valuation when the invoice for that purchase
-is confirmed, but that moment could be delayed for an uncertain period
-of time while thos stored goods are being selled with wrong margins and
-the value being discounted for a wrong price unit.
-
We want to fix those disalignments as soon as possible from the purchase
-order while keeping the native mechanism to add later corrections from
-the invoice itself.
+order.
Configuration
=============
@@ -87,10 +81,6 @@ In order to test the module:
button *Fix valuation* shows up in the header.
- When you click that button, every disaligned valuation will be fixed.
If you go to the *Valuation* report you'll see the adjustment layer.
-- After this, when you invoice the purchase you won't be able to edit
- the price anymore.
-- You can anyway add an additional valuation when you post the new
- invoice prices.
Known issues / Roadmap
======================
@@ -103,7 +93,7 @@ Bug Tracker
Bugs are tracked on `GitHub Issues `_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
-`feedback `_.
+`feedback `_.
Do not contact contributors directly about support or help with technical issues.
@@ -119,6 +109,9 @@ Contributors
------------
- David Vidal (`Moduon `__)
+- `PyTech `__:
+
+ - Simone Rubino
Maintainers
-----------
@@ -144,6 +137,6 @@ Current `maintainers `__:
|maintainer-chienandalu| |maintainer-rafaelbn|
-This module is part of the `OCA/purchase-workflow `_ project on GitHub.
+This module is part of the `OCA/purchase-workflow `_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
diff --git a/purchase_stock_cost_update/__manifest__.py b/purchase_stock_cost_update/__manifest__.py
index 0da53562010..f63e286f618 100644
--- a/purchase_stock_cost_update/__manifest__.py
+++ b/purchase_stock_cost_update/__manifest__.py
@@ -3,7 +3,7 @@
{
"name": "Update costs from purchase",
"summary": "Allows to update valuation layers once the purchase is received",
- "version": "16.0.1.0.1",
+ "version": "14.0.1.0.0",
"category": "Purchase Management",
"author": "Moduon, Odoo Community Association (OCA)",
"maintainers": ["chienandalu", "rafaelbn"],
diff --git a/purchase_stock_cost_update/models/purchase_order.py b/purchase_stock_cost_update/models/purchase_order.py
index 83e9db70891..87a6a3a4726 100644
--- a/purchase_stock_cost_update/models/purchase_order.py
+++ b/purchase_stock_cost_update/models/purchase_order.py
@@ -62,9 +62,9 @@ class PurchaseOrderLine(models.Model):
def _get_valued_in_moves(self):
self.ensure_one()
- return self._get_po_line_moves().filtered(
- lambda m: m.state == "done" and m.product_qty != 0
- )
+ return self.move_ids.filtered(
+ lambda m: m.product_id == self.product_id
+ ).filtered(lambda m: m.state == "done" and m.product_qty != 0)
@api.depends("price_subtotal", "qty_invoiced")
def _compute_valuation_differs(self):
@@ -122,7 +122,6 @@ def _prepare_pdiff_svl_vals(self, corrected_layer, price_diff):
"remaining_qty": 0,
"remaining_value": 0,
"value": price_diff,
- "price_diff_value": price_diff,
"stock_valuation_layer_id": corrected_layer.id,
"description": _("Price difference layer created from %(line)s")
% {"line": self.display_name},
@@ -155,7 +154,9 @@ def _apply_unit_price_difference(self):
"""Fix valuation from the purchase price"""
self.ensure_one()
# Invalidate cache for the svl values of the product
- self.product_id.invalidate_recordset(["value_svl", "quantity_svl"])
+ self.product_id.invalidate_cache(
+ ["value_svl", "quantity_svl"], self.product_id.ids
+ )
(
price_unit,
svl_qty,
diff --git a/purchase_stock_cost_update/readme/CONTEXT.md b/purchase_stock_cost_update/readme/CONTEXT.md
index b4cbeae5bc2..fedd0fa4e08 100644
--- a/purchase_stock_cost_update/readme/CONTEXT.md
+++ b/purchase_stock_cost_update/readme/CONTEXT.md
@@ -2,10 +2,4 @@ When a purchase order is confirmed, the value of the received goods is updated u
reception. It can happen anyway that the reception of the goods is confirmed before
the final price is recorded in the corresponding purchase line.
-For that, Odoo will fix the valuation when the invoice for that purchase is confirmed,
-but that moment could be delayed for an uncertain period of time while thos stored
-goods are being selled with wrong margins and the value being discounted for a wrong
-price unit.
-
-We want to fix those disalignments as soon as possible from the purchase order while
-keeping the native mechanism to add later corrections from the invoice itself.
+We want to fix those disalignments as soon as possible from the purchase order.
diff --git a/purchase_stock_cost_update/readme/CONTRIBUTORS.md b/purchase_stock_cost_update/readme/CONTRIBUTORS.md
index 9f0a2a789c4..3c390c7a3bf 100644
--- a/purchase_stock_cost_update/readme/CONTRIBUTORS.md
+++ b/purchase_stock_cost_update/readme/CONTRIBUTORS.md
@@ -1 +1,3 @@
- David Vidal ([Moduon](https://www.moduon.team/))
+- [PyTech](https://www.pytech.it):
+ - Simone Rubino \<\>
diff --git a/purchase_stock_cost_update/readme/USAGE.md b/purchase_stock_cost_update/readme/USAGE.md
index 4d7b424d7dd..53c19c423ea 100644
--- a/purchase_stock_cost_update/readme/USAGE.md
+++ b/purchase_stock_cost_update/readme/USAGE.md
@@ -11,5 +11,3 @@ In order to test the module:
*Fix valuation* shows up in the header.
- When you click that button, every disaligned valuation will be fixed. If you go to the
*Valuation* report you'll see the adjustment layer.
-- After this, when you invoice the purchase you won't be able to edit the price anymore.
-- You can anyway add an additional valuation when you post the new invoice prices.
diff --git a/purchase_stock_cost_update/static/description/index.html b/purchase_stock_cost_update/static/description/index.html
index 61e6dbfc965..54603dd51d4 100644
--- a/purchase_stock_cost_update/static/description/index.html
+++ b/purchase_stock_cost_update/static/description/index.html
@@ -374,7 +374,7 @@
updated upon reception. It can happen anyway that the reception of the
goods is confirmed before the final price is recorded in the
corresponding purchase line.
-
For that, Odoo will fix the valuation when the invoice for that purchase
-is confirmed, but that moment could be delayed for an uncertain period
-of time while thos stored goods are being selled with wrong margins and
-the value being discounted for a wrong price unit.
We want to fix those disalignments as soon as possible from the purchase
-order while keeping the native mechanism to add later corrections from
-the invoice itself.
Bugs are tracked on GitHub Issues.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
-feedback.
diff --git a/purchase_stock_cost_update/tests/test_purchase_stock_cost_update.py b/purchase_stock_cost_update/tests/test_purchase_stock_cost_update.py
index 3e2682ebd6a..c7e76dd5c43 100644
--- a/purchase_stock_cost_update/tests/test_purchase_stock_cost_update.py
+++ b/purchase_stock_cost_update/tests/test_purchase_stock_cost_update.py
@@ -9,7 +9,7 @@
_logger = logging.getLogger(__name__)
-class PurchasStockCostUpdateCase(common.TransactionCase):
+class PurchasStockCostUpdateCase(common.SavepointCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
@@ -100,12 +100,21 @@ def _validate_purchase_reception(self, pickings, lot=None):
tracked_lines.lot_name = lot
else:
for line in tracked_lines:
- lot = self.env["stock.lot"].search(
+ lot = self.env["stock.production.lot"].search(
[("name", "=", lot), ("product_id", "=", line.product_id.id)]
)
line.lot_id = lot
- picking.action_set_quantities_to_reservation()
- picking._action_done()
+ picking.action_assign()
+ transfer_wizard_action = picking.button_validate()
+ self.assertEqual(
+ transfer_wizard_action.get("res_model"), "stock.immediate.transfer"
+ )
+ transfer_wizar = Form(
+ self.env[transfer_wizard_action["res_model"]].with_context(
+ transfer_wizard_action["context"]
+ )
+ ).save()
+ transfer_wizar.process()
def _partial_return(self, picking, qty):
stock_return_picking_form = Form(
@@ -145,7 +154,6 @@ def _log_svls(self, product, title=None):
"value",
"quantity",
"unit_cost",
- "reference",
"remaining_value",
"remaining_qty",
]
@@ -154,6 +162,12 @@ def _log_svls(self, product, title=None):
layers_values = layers.read(fields)
if not layers_values:
return
+ for layer_values in layers_values:
+ layer_values["reference"] = (
+ self.env["stock.valuation.layer"]
+ .browse(layer_values["id"])
+ .stock_move_id.reference
+ )
header = list(layers_values[0].keys())
footer = [sum(layers.mapped(f)) if f in totals else "" for f in header]
rows = [list(layer.values()) for layer in layers_values]
@@ -207,8 +221,8 @@ def test_01_update_reception_cost_from_vendor_bill(self):
"""Regular Odoo behavior
1. We'll confirm the purchase order with an initial unit price
2. Then we receive the goods which will value those entries.
- 3. And later the cost/value will be fixed from the vendor bill
- Expected result: the valuation is adjusted once the vendor bill is posted.
+ 3. Later the cost/value will not be fixed from the vendor bill
+ Expected result: the valuation is not adjusted.
"""
purchase_order = self._purchase_and_receive()
# 5. The purchase team sets the price at last and the price difference flags are
@@ -217,17 +231,16 @@ def test_01_update_reception_cost_from_vendor_bill(self):
self.assertTrue(purchase_order.valuation_differs)
self.assertValuation(self.peach, valuation=110, price=1.1)
self._log_svls(self.peach, title="1. Initial valuation after reception")
- # 6. The order is invoiced right on and the prices are then fixed
+ # 6. The order is invoiced right on and the prices stay the same
purchase_order.action_create_invoice()
purchase_order.invoice_ids.invoice_date = Date.today()
purchase_order.invoice_ids._post()
- self.assertValuation(self.peach, valuation=120, price=1.2)
- self._log_svls(self.peach, title="2. Price unit changed in invoice")
+ self.assertValuation(self.peach, valuation=110, price=1.1)
+ self._log_svls(self.peach, title="2. Price unit has not changed in invoice")
def test_02_update_reception_cost_from_the_purchase_line(self):
"""The main goal for this module is to be able to update the product cost
- as soon as possible, as we might not be able to have a vendor bill up until
- way after the reception. So in this case:
+ as soon as possible. So in this case:
1. We'll confirm the purchase order with an initial unit price
2. Then we receive the goods which will value those entries.
3. We'll be able to fix the product valuation before the vendor bill is
@@ -261,7 +274,7 @@ def test_03_update_reception_cost_from_the_purchase_line_and_from_invoice(self):
3. We'll be able to fix the product valuation before the vendor bill is
issued.
4. Once the vendor bill is issued the purchase team adds a new price
- difference that which adjust the product value as well when the bill
+ difference that will not adjust the product value when the bill
is posted.
"""
purchase_order = self._purchase_and_receive()
@@ -274,12 +287,14 @@ def test_03_update_reception_cost_from_the_purchase_line_and_from_invoice(self):
# 6. Now we can press the "Fix valuation" button to adjust those differences
purchase_order.action_apply_price_difference()
self.assertValuation(self.peach, valuation=120, price=1.2)
- # 7. The order is invoiced and later the value is fixed again
+ # 7. The order is invoiced and later the value stays the same
purchase_order.action_create_invoice()
- purchase_order.invoice_ids.invoice_date = Date.today()
- purchase_order.invoice_ids.invoice_line_ids.price_unit = 1.3
+ with Form(purchase_order.invoice_ids) as invoice_form:
+ invoice_form.invoice_date = Date.today()
+ with invoice_form.invoice_line_ids.edit(0) as line:
+ line.price_unit = 1.3
purchase_order.invoice_ids._post()
- self.assertValuation(self.peach, valuation=130, price=1.3)
+ self.assertValuation(self.peach, valuation=120, price=1.2)
def test_04a_update_reception_cost_with_returns_from_the_invoice(self):
"""Let's do it harder. Now we'll do some extra pickings that will add new
@@ -295,12 +310,14 @@ def test_04a_update_reception_cost_with_returns_from_the_invoice(self):
self.assertFalse(purchase_order.valuation_differs)
purchase_order.order_line.price_unit = 1
self.assertTrue(purchase_order.valuation_differs)
- # 7. The order is invoiced later and we change the price once again
+ # 7. The order is invoiced later and the price stays the same
purchase_order.action_create_invoice()
- purchase_order.invoice_ids.invoice_date = Date.today()
- purchase_order.invoice_ids.invoice_line_ids.price_unit = 1.3
+ with Form(purchase_order.invoice_ids) as invoice_form:
+ invoice_form.invoice_date = Date.today()
+ with invoice_form.invoice_line_ids.edit(0) as line:
+ line.price_unit = 1.3
purchase_order.invoice_ids._post()
- self.assertValuation(self.peach, valuation=182, price=1.3)
+ self.assertValuation(self.peach, valuation=154, price=1.1)
def test_04b_update_reception_cost_from_the_purchase_line(self):
"""Let's do it harder. Now we'll do some extra pickings that will add new
@@ -312,18 +329,20 @@ def test_04b_update_reception_cost_from_the_purchase_line(self):
self.assertValuation(self.peach, valuation=165, price=1.1)
# 5. The purchase team sets the price at last and the price difference flags are
# raised.
- self._partial_return(purchase_order.picking_ids[0], 10)
+ self._partial_return(purchase_order.picking_ids.sorted("id")[0], 10)
self.assertFalse(purchase_order.valuation_differs)
# Now let's decrease the value. The valuation should change accordingly
purchase_order.order_line.price_unit = 1
purchase_order.action_apply_price_difference()
self.assertValuation(self.peach, valuation=140, price=1)
- # 7. The order is invoiced later and we change the price once again
+ # 7. The order is invoiced later and the price stays the same
purchase_order.action_create_invoice()
- purchase_order.invoice_ids.invoice_date = Date.today()
- purchase_order.invoice_ids.invoice_line_ids.price_unit = 1.3
+ with Form(purchase_order.invoice_ids) as invoice_form:
+ invoice_form.invoice_date = Date.today()
+ with invoice_form.invoice_line_ids.edit(0) as line:
+ line.price_unit = 1.3
purchase_order.invoice_ids._post()
- self.assertValuation(self.peach, valuation=182, price=1.3)
+ self.assertValuation(self.peach, valuation=140, price=1)
def test_05_full_history(self):
"""Even harder. A full history tracing the valuation all along"""
@@ -352,7 +371,7 @@ def test_05_full_history(self):
self.assertValuation(self.peach, valuation=299, price=1.57)
def test_06_full_history_fix_in_invoicing(self):
- """The same as the former case, but we fix it in the invoice. The resulting
+ """The same as the former case, but we also check after the invoice. The resulting
valuation should be the same!"""
# Receive 100 kg of peaches at 1.1 - Peaches value: 110
self._purchase_and_receive()
@@ -368,7 +387,7 @@ def test_06_full_history_fix_in_invoicing(self):
purchase_2.action_create_invoice()
purchase_2.invoice_ids.invoice_date = Date.today()
purchase_2.invoice_ids._post()
- self.assertValuation(self.peach, valuation=299, price=1.57)
+ self.assertValuation(self.peach, valuation=209, price=1.1)
def _test_multiple_receptions_lots_and_delivers(self, uom=None):
if not uom:
@@ -425,19 +444,19 @@ def uom_price(price):
def test_07_test_multiple_receptions_lots_and_delivers_fix_from_invoice(
self, uom=None
):
- """Odoo standard valuation fix"""
+ """Odoo standard valuation does not change valuation in the invoice"""
purchase_order_1, _po2 = self._test_multiple_receptions_lots_and_delivers(
uom=uom
)
purchase_order_1.action_create_invoice()
purchase_order_1.invoice_ids.invoice_date = Date.today()
purchase_order_1.invoice_ids._post()
- self.assertValuation(self.raspberry, valuation=240, price=12.63)
+ self.assertValuation(self.raspberry, valuation=285, price=15)
def test_08_test_multiple_receptions_lots_and_delivers_fix_from_purchase(
self, uom=None
):
- """Odoo standard valuation fix"""
+ """Change valuation from purchase for multiple lots"""
purchase_order_1, _po2 = self._test_multiple_receptions_lots_and_delivers(
uom=uom
)
diff --git a/purchase_stock_cost_update/views/purchase_order_form_views.xml b/purchase_stock_cost_update/views/purchase_order_form_views.xml
index 9c848dbe6aa..3ca22ec6fc4 100644
--- a/purchase_stock_cost_update/views/purchase_order_form_views.xml
+++ b/purchase_stock_cost_update/views/purchase_order_form_views.xml
@@ -23,7 +23,7 @@
/>
From 8c2bc33cfe413120be3ae276bb82a9a482ef31c4 Mon Sep 17 00:00:00 2001
From: Simone Rubino
Date: Thu, 7 May 2026 16:06:33 +0200
Subject: [PATCH 4/4] [FIX] purchase_discount: Do not round discounted price
---
purchase_discount/models/stock_move.py | 51 ++++++++++++++++---
.../tests/test_purchase_discount.py | 44 ++++++++++++++++
2 files changed, 87 insertions(+), 8 deletions(-)
diff --git a/purchase_discount/models/stock_move.py b/purchase_discount/models/stock_move.py
index 34b79b1370e..dac5cadf57e 100644
--- a/purchase_discount/models/stock_move.py
+++ b/purchase_discount/models/stock_move.py
@@ -1,12 +1,38 @@
# Copyright 2018 Tecnativa - Pedro M. Baeza
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+from contextlib import contextmanager, suppress
+
from odoo import models
class StockMove(models.Model):
_inherit = "stock.move"
+ @contextmanager
+ def _ensure_product_price_precision(self, po_line, discounted_price):
+ """
+ Check if price_unit has enough decimals to store the precise discounted price.
+
+ If not, use more decimals and restore the old ones after yield.
+ """
+ price_unit_precision = price_unit_precision_digits = False
+ if po_line.price_unit != discounted_price:
+ # We have to update the `decimal.precision` record
+ # because it is directly used in `super._get_price_unit`
+ price_unit_precision = (
+ self.env["decimal.precision"]
+ .sudo()
+ .search([("name", "=", "Product Price")])
+ )
+ price_unit_precision_digits = price_unit_precision.digits
+ price_unit_precision.digits += 8
+
+ yield
+
+ if price_unit_precision and price_unit_precision_digits:
+ price_unit_precision.digits = price_unit_precision_digits
+
def _get_price_unit(self):
"""Get correct price with discount replacing current price_unit
value before calling super and restoring it later for assuring
@@ -15,17 +41,26 @@ def _get_price_unit(self):
HACK: This is needed while https://github.com/odoo/odoo/pull/29983
is not merged.
"""
- if hasattr(self.env, "ocb"):
- return super()._get_price_unit()
price_unit = False
po_line = self.purchase_line_id.sudo()
+ price = po_line._get_discounted_price_unit()
if po_line and self.product_id == po_line.product_id:
- price = po_line._get_discounted_price_unit()
- if price != po_line.price_unit:
- # Only change value if it's different
- price_unit = po_line.price_unit
- po_line.price_unit = price
- res = super()._get_price_unit()
+ precision_context = self._ensure_product_price_precision(po_line, price)
+ else:
+ precision_context = suppress()
+
+ with precision_context:
+ if hasattr(self.env, "ocb"):
+ res = super()._get_price_unit()
+ else:
+ if po_line and self.product_id == po_line.product_id:
+ if price != po_line.price_unit:
+ # Only change value if it's different
+ price_unit = po_line.price_unit
+ po_line.price_unit = price
+
+ res = super()._get_price_unit()
+
if price_unit:
po_line.price_unit = price_unit
return res
diff --git a/purchase_discount/tests/test_purchase_discount.py b/purchase_discount/tests/test_purchase_discount.py
index 0c937912ca6..5a9656ac99d 100644
--- a/purchase_discount/tests/test_purchase_discount.py
+++ b/purchase_discount/tests/test_purchase_discount.py
@@ -139,6 +139,50 @@ def test_move_price_unit(self):
self.assertAlmostEqual(self.po_line_1.price_unit, 10.0)
self.assertAlmostEqual(self.po_line_1.discount, 50.0)
+ def test_move_price_unit_rounding(self):
+ """The stock valuation is correct when discount needs more precision."""
+ purchase_order = self.purchase_order
+ product = self.product_1
+ self.assertEqual(product.cost_method, "average")
+
+ # Create PO
+ purchase_order.order_line = False
+ with common.Form(purchase_order) as po_form:
+ with po_form.order_line.new() as line:
+ line.product_id = product
+ line.product_qty = 150
+ line.price_unit = 4.9
+ line.discount = 25
+ purchase_order.button_confirm()
+ picking = self.purchase_order.picking_ids
+
+ # Receive the picking
+ picking.action_assign()
+ transfer_wizard_action = picking.button_validate()
+ self.assertEqual(
+ transfer_wizard_action.get("res_model"), "stock.immediate.transfer"
+ )
+ transfer_wizard = common.Form(
+ self.env[transfer_wizard_action["res_model"]].with_context(
+ transfer_wizard_action["context"]
+ )
+ ).save()
+ transfer_wizard.process()
+
+ # If the product price is rounded:
+ # 4.9 * (1 - 0.25) = 3.675 ~= 3.68
+ # => 3.68 * 150 = 552
+ # But the correct value is: 150 * 4.9 * (1 - 0.25) = 551.25
+ self.assertRecordValues(
+ product,
+ [
+ {
+ "value_svl": 551.25,
+ "standard_price": 3.68,
+ }
+ ],
+ )
+
def test_report_price_unit(self):
rec = self.env["purchase.report"].search(
[("product_id", "=", self.product_1.id)]