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)] diff --git a/purchase_stock_cost_update/README.rst b/purchase_stock_cost_update/README.rst new file mode 100644 index 00000000000..36b13403ee5 --- /dev/null +++ b/purchase_stock_cost_update/README.rst @@ -0,0 +1,142 @@ +.. 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/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-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=14.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. + +We want to fix those disalignments as soon as possible from the purchase +order. + +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. + +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 `__) +- `PyTech `__: + + - Simone Rubino + +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..f63e286f618 --- /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": "14.0.1.0.0", + "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..87a6a3a4726 --- /dev/null +++ b/purchase_stock_cost_update/models/purchase_order.py @@ -0,0 +1,225 @@ +# 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"
  • {line.display_name}: " + f"{valuation_difference}
  • " + ) + order.valuation_difference_report = ( + f"
      {valuation_difference_report}
    " + ) + + 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.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): + """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, + "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_cache( + ["value_svl", "quantity_svl"], self.product_id.ids + ) + ( + 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..fedd0fa4e08 --- /dev/null +++ b/purchase_stock_cost_update/readme/CONTEXT.md @@ -0,0 +1,5 @@ +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. + +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 new file mode 100644 index 00000000000..3c390c7a3bf --- /dev/null +++ b/purchase_stock_cost_update/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- David Vidal ([Moduon](https://www.moduon.team/)) +- [PyTech](https://www.pytech.it): + - Simone Rubino \<\> 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..53c19c423ea --- /dev/null +++ b/purchase_stock_cost_update/readme/USAGE.md @@ -0,0 +1,13 @@ +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. 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 00000000000..1dcc49c24f3 Binary files /dev/null and b/purchase_stock_cost_update/static/description/icon.png differ diff --git a/purchase_stock_cost_update/static/description/index.html b/purchase_stock_cost_update/static/description/index.html new file mode 100644 index 00000000000..54603dd51d4 --- /dev/null +++ b/purchase_stock_cost_update/static/description/index.html @@ -0,0 +1,485 @@ + + + + + +README.rst + + + +
    + + + +Odoo Community Association + +
    +

    Update costs from purchase

    + +

    Beta License: LGPL-3 OCA/purchase-workflow Translate me on Weblate Try me on Runboat

    +

    This module allows to adjust the valuation of the incoming goods related +to their purchase order from the purchase line itself.

    +

    Table of contents

    + +
    +

    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.

    +

    We want to fix those disalignments as soon as possible from the purchase +order.

    +
    +
    +

    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.
    • +
    +
    +
    +

    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

    + +
    +
    +

    Maintainers

    +

    This module is maintained by the OCA.

    + +Odoo Community Association + +

    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.

    +

    Current maintainers:

    +

    chienandalu 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/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..c7e76dd5c43 --- /dev/null +++ b/purchase_stock_cost_update/tests/test_purchase_stock_cost_update.py @@ -0,0 +1,509 @@ +# 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.SavepointCase): + @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.production.lot"].search( + [("name", "=", lot), ("product_id", "=", line.product_id.id)] + ) + line.lot_id = lot + 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( + 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", + "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 + 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] + 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. 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 + # 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 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=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. 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 will not adjust the product value 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 stays the same + purchase_order.action_create_invoice() + 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=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 + 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 the price stays the same + purchase_order.action_create_invoice() + 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=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 + 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.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 the price stays the same + purchase_order.action_create_invoice() + 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=140, price=1) + + 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 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() + 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=209, price=1.1) + + 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 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=285, price=15) + + def test_08_test_multiple_receptions_lots_and_delivers_fix_from_purchase( + self, uom=None + ): + """Change valuation from purchase for multiple lots""" + 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..3ca22ec6fc4 --- /dev/null +++ b/purchase_stock_cost_update/views/purchase_order_form_views.xml @@ -0,0 +1,44 @@ + + + purchase.order + + + + + + + + + + + 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 + + + + + + + + + + + + + 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, +)