diff --git a/purchase_stock_price_unit_sync/README.rst b/purchase_stock_price_unit_sync/README.rst index c142598bdc0..bbb5193db69 100644 --- a/purchase_stock_price_unit_sync/README.rst +++ b/purchase_stock_price_unit_sync/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ============================== Purchase stock price unit sync ============================== @@ -17,7 +13,7 @@ Purchase stock price unit sync .. |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-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fpurchase--workflow-lightgray.png?logo=github @@ -35,6 +31,14 @@ Purchase stock price unit sync This module allows to sync picking cost prices with purchase order line price when moves are already done. +It does the same when the price is corrected on the **vendor bill**. +Odoo corrects a price difference with a child valuation layer worth the +difference times the quantity that has not left stock yet, and sends the +rest to the expense account, so the moves that already left keep the +cost that turned out to be wrong. Here the invoiced price is applied to +the whole receipt instead, which leaves the bill on the same footing as +changing the price on the purchase order. + Can be used with product_cost_price_avco_sync. **Table of contents** @@ -42,6 +46,44 @@ Can be used with product_cost_price_avco_sync. .. contents:: :local: +Configuration +============= + +The correction through the vendor bill needs +``product_cost_price_avco_sync`` installed: it is that module which +replays the valuation chain once the layer changes, re-pricing what +already left stock and correcting a valuation asked for a date before +the correction. Without it the bill keeps Odoo's standard behaviour. + +It applies to products with the **Average Cost (AVCO)** costing method +and **manual** inventory valuation. Refunds are left to Odoo, which +compensates them against the original bill with a logic of its own, and +so is automated valuation, where the journal entry of the layer is +already posted and restating it would pull stock valuation and +accounting apart. + +Usage +===== + +There are two moments where the real price of a purchase shows up after +the goods have already been received, and both are synced: + +- **The purchase order line.** Changing its price writes the new one on + the stock moves that are already done and on their valuation layers. +- **The vendor bill.** Posting it at a different price applies that + price to the whole receipt layer, instead of only to the part that has + not left stock yet, which is what Odoo does on its own. + +Correcting the same price in both places does not count it twice: +whichever runs second finds the layer already worth what it says and +does nothing. + +With ``product_cost_price_avco_sync`` installed, either of them replays +the valuation chain, so the outgoing moves valued in between are +re-priced and a stock valuation asked for a date before the correction +comes out right. Without it, only the layers themselves are written and +the vendor bill keeps Odoo's standard behaviour. + Bug Tracker =========== diff --git a/purchase_stock_price_unit_sync/__manifest__.py b/purchase_stock_price_unit_sync/__manifest__.py index fafc4b26c4d..dda3fd24404 100644 --- a/purchase_stock_price_unit_sync/__manifest__.py +++ b/purchase_stock_price_unit_sync/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Purchase stock price unit sync", "summary": "Update cost price in stock moves already done", - "version": "18.0.1.0.0", + "version": "18.0.1.1.0", "category": "Purchase", "website": "https://github.com/OCA/purchase-workflow", "author": "Tecnativa, Odoo Community Association (OCA)", diff --git a/purchase_stock_price_unit_sync/models/__init__.py b/purchase_stock_price_unit_sync/models/__init__.py index 9a32d2efa63..0536af65787 100644 --- a/purchase_stock_price_unit_sync/models/__init__.py +++ b/purchase_stock_price_unit_sync/models/__init__.py @@ -1,2 +1,3 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from . import account_move_line from . import purchase_order diff --git a/purchase_stock_price_unit_sync/models/account_move_line.py b/purchase_stock_price_unit_sync/models/account_move_line.py new file mode 100644 index 00000000000..ce252cd68ee --- /dev/null +++ b/purchase_stock_price_unit_sync/models/account_move_line.py @@ -0,0 +1,74 @@ +# Copyright 2026 Tecnativa - Carlos Dauden +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models +from odoo.tools import float_compare + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + def _is_price_unit_sync_restated(self, layer): + """Whether the price difference of this invoice line has to be applied + by restating the layer instead of by correcting only what is left of it. + + It needs `product_cost_price_avco_sync`, which is what replays the chain + once the layer changes: restating it on its own, without that replay, + would leave the layer inconsistent and would also throw away the + correction Odoo does make, which is worse than not doing anything. + + Refunds are left to Odoo, they have a compensation logic of their own, + and so is automated valuation, where the journal entry of the layer is + already posted and restating it would pull the two apart. + """ + self.ensure_one() + if not hasattr(self.env["stock.valuation.layer"], "_cost_price_avco_sync"): + return False + product = self.product_id.with_company(self.company_id) + return ( + not self.is_refund + and product.cost_method == "average" + and product.valuation != "real_time" + and layer.stock_move_id + and not layer.stock_valuation_layer_id + ) + + def _prepare_pdiff_vals( + self, layer, aml, layer_price_unit, out_qty_to_invoice, qty_to_correct + ): + """Apply the invoiced price to the whole layer, not only to what is + still on hand. + + Odoo corrects a price difference with a child layer worth the + difference times the quantity that has not left stock yet, and sends the + rest to the expense account. The stock ends up valued right, but the + moves that already left keep the cost that turned out to be wrong, so + the margin of what was sold, and any valuation asked for a date before + the invoice, stay wrong too. + + Writing the invoiced price on the layer instead makes + `product_cost_price_avco_sync` replay the chain, which corrects both, + and leaves the bill on the same footing as changing the price on the + purchase order, which this module already syncs. Odoo has done the hard + part by the time this runs: which layer this invoice line pays for, and + at what price, comes from its own matching of layers and bills. + """ + if not self._is_price_unit_sync_restated(layer): + return super()._prepare_pdiff_vals( + layer, aml, layer_price_unit, out_qty_to_invoice, qty_to_correct + ) + # Same conversion Odoo does to compare the invoiced price with the layer + price_unit = aml._get_gross_unit_price() / aml.currency_rate + price_unit = aml.product_uom_id._compute_price( + price_unit, self.product_id.uom_id + ) + precision = max( + aml.currency_id.decimal_places, + layer.currency_id.decimal_places, + self.env["decimal.precision"].precision_get("Product Price"), + ) + if float_compare(price_unit, layer_price_unit, precision_digits=precision): + layer.unit_cost = price_unit + # Nothing is left for Odoo to create: the correction is already in the + # layer, and adding its child layer on top would count it twice. + return [], [] diff --git a/purchase_stock_price_unit_sync/readme/CONFIGURE.md b/purchase_stock_price_unit_sync/readme/CONFIGURE.md new file mode 100644 index 00000000000..4dbd770d74b --- /dev/null +++ b/purchase_stock_price_unit_sync/readme/CONFIGURE.md @@ -0,0 +1,11 @@ +The correction through the vendor bill needs `product_cost_price_avco_sync` +installed: it is that module which replays the valuation chain once the layer +changes, re-pricing what already left stock and correcting a valuation asked +for a date before the correction. Without it the bill keeps Odoo's standard +behaviour. + +It applies to products with the **Average Cost (AVCO)** costing method and +**manual** inventory valuation. Refunds are left to Odoo, which compensates them +against the original bill with a logic of its own, and so is automated +valuation, where the journal entry of the layer is already posted and restating +it would pull stock valuation and accounting apart. diff --git a/purchase_stock_price_unit_sync/readme/DESCRIPTION.md b/purchase_stock_price_unit_sync/readme/DESCRIPTION.md index c643aa3d255..52cfa1ecc3a 100644 --- a/purchase_stock_price_unit_sync/readme/DESCRIPTION.md +++ b/purchase_stock_price_unit_sync/readme/DESCRIPTION.md @@ -1,4 +1,12 @@ This module allows to sync picking cost prices with purchase order line price when moves are already done. +It does the same when the price is corrected on the **vendor bill**. Odoo +corrects a price difference with a child valuation layer worth the difference +times the quantity that has not left stock yet, and sends the rest to the +expense account, so the moves that already left keep the cost that turned out +to be wrong. Here the invoiced price is applied to the whole receipt instead, +which leaves the bill on the same footing as changing the price on the purchase +order. + Can be used with product_cost_price_avco_sync. diff --git a/purchase_stock_price_unit_sync/readme/USAGE.md b/purchase_stock_price_unit_sync/readme/USAGE.md new file mode 100644 index 00000000000..929236e99bb --- /dev/null +++ b/purchase_stock_price_unit_sync/readme/USAGE.md @@ -0,0 +1,17 @@ +There are two moments where the real price of a purchase shows up after the +goods have already been received, and both are synced: + +- **The purchase order line.** Changing its price writes the new one on the + stock moves that are already done and on their valuation layers. +- **The vendor bill.** Posting it at a different price applies that price to the + whole receipt layer, instead of only to the part that has not left stock yet, + which is what Odoo does on its own. + +Correcting the same price in both places does not count it twice: whichever runs +second finds the layer already worth what it says and does nothing. + +With `product_cost_price_avco_sync` installed, either of them replays the +valuation chain, so the outgoing moves valued in between are re-priced and a +stock valuation asked for a date before the correction comes out right. Without +it, only the layers themselves are written and the vendor bill keeps Odoo's +standard behaviour. diff --git a/purchase_stock_price_unit_sync/static/description/index.html b/purchase_stock_price_unit_sync/static/description/index.html index 4aae97ea4a4..f875ddf99e5 100644 --- a/purchase_stock_price_unit_sync/static/description/index.html +++ b/purchase_stock_price_unit_sync/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Purchase stock price unit sync -
+
+

Purchase stock price unit sync

- - -Odoo Community Association - -
-

Purchase stock price unit sync

-

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

+

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

This module allows to sync picking cost prices with purchase order line price when moves are already done.

+

It does the same when the price is corrected on the vendor bill. +Odoo corrects a price difference with a child valuation layer worth the +difference times the quantity that has not left stock yet, and sends the +rest to the expense account, so the moves that already left keep the +cost that turned out to be wrong. Here the invoiced price is applied to +the whole receipt instead, which leaves the bill on the same footing as +changing the price on the purchase order.

Can be used with product_cost_price_avco_sync.

Table of contents

+
+

Configuration

+

The correction through the vendor bill needs +product_cost_price_avco_sync installed: it is that module which +replays the valuation chain once the layer changes, re-pricing what +already left stock and correcting a valuation asked for a date before +the correction. Without it the bill keeps Odoo’s standard behaviour.

+

It applies to products with the Average Cost (AVCO) costing method +and manual inventory valuation. Refunds are left to Odoo, which +compensates them against the original bill with a logic of its own, and +so is automated valuation, where the journal entry of the layer is +already posted and restating it would pull stock valuation and +accounting apart.

+
+
+

Usage

+

There are two moments where the real price of a purchase shows up after +the goods have already been received, and both are synced:

+
    +
  • The purchase order line. Changing its price writes the new one on +the stock moves that are already done and on their valuation layers.
  • +
  • The vendor bill. Posting it at a different price applies that +price to the whole receipt layer, instead of only to the part that has +not left stock yet, which is what Odoo does on its own.
  • +
+

Correcting the same price in both places does not count it twice: +whichever runs second finds the layer already worth what it says and +does nothing.

+

With product_cost_price_avco_sync installed, either of them replays +the valuation chain, so the outgoing moves valued in between are +re-priced and a stock valuation asked for a date before the correction +comes out right. Without it, only the layers themselves are written and +the vendor bill keeps Odoo’s standard behaviour.

+
-

Bug Tracker

+

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 @@ -399,15 +437,15 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Tecnativa
-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -434,6 +472,5 @@

Maintainers

-
diff --git a/purchase_stock_price_unit_sync/tests/__init__.py b/purchase_stock_price_unit_sync/tests/__init__.py index 40febbe558a..7c2591cc0c9 100644 --- a/purchase_stock_price_unit_sync/tests/__init__.py +++ b/purchase_stock_price_unit_sync/tests/__init__.py @@ -1,3 +1,2 @@ -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). - +from . import test_bill_price_difference from . import test_purchase_stock_price_unit_sync diff --git a/purchase_stock_price_unit_sync/tests/test_bill_price_difference.py b/purchase_stock_price_unit_sync/tests/test_bill_price_difference.py new file mode 100644 index 00000000000..d4386725f9c --- /dev/null +++ b/purchase_stock_price_unit_sync/tests/test_bill_price_difference.py @@ -0,0 +1,159 @@ +# Copyright 2026 Tecnativa - Carlos Dauden +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import unittest + +from odoo import Command, fields + +from odoo.addons.base.tests.common import BaseCommon + + +class TestBillPriceDifference(BaseCommon): + """The price corrected on the vendor bill is applied to the whole receipt. + + It only happens when `product_cost_price_avco_sync` is installed, which is + what replays the valuation chain, so the tests skip themselves when it is + not there. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.stock_location = cls.env.ref("stock.stock_location_stock") + cls.customer_location = cls.env.ref("stock.stock_location_customers") + cls.picking_type_out = cls.env.ref("stock.picking_type_out") + cls.partner = cls.env["res.partner"].create({"name": "Bill diff partner"}) + cls.categ = cls.env["product.category"].create( + { + "name": "Bill diff AVCO", + "property_cost_method": "average", + "property_valuation": "manual_periodic", + } + ) + cls.product = cls.env["product.product"].create( + { + "name": "Product billed at another price", + "type": "consu", + "is_storable": True, + "categ_id": cls.categ.id, + "standard_price": 0.0, + "purchase_method": "receive", + } + ) + + def setUp(self): + super().setUp() + if not hasattr(self.env["stock.valuation.layer"], "_cost_price_avco_sync"): + raise unittest.SkipTest("product_cost_price_avco_sync is not installed") + + def _receive(self, quantity, price): + order = self.env["purchase.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product.id, + "product_qty": quantity, + "price_unit": price, + "name": self.product.name, + "date_planned": fields.Datetime.now(), + } + ) + ], + } + ) + order.button_confirm() + picking = order.picking_ids[:1] + picking.move_line_ids[:1].quantity = quantity + picking.move_line_ids.picked = True + picking._action_done() + return order, picking + + def _deliver(self, quantity): + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_out.id, + "partner_id": self.partner.id, + "location_id": self.stock_location.id, + "location_dest_id": self.customer_location.id, + "move_ids": [ + Command.create( + { + "name": self.product.name, + "product_id": self.product.id, + "product_uom_qty": quantity, + "product_uom": self.product.uom_id.id, + "location_id": self.stock_location.id, + "location_dest_id": self.customer_location.id, + } + ) + ], + } + ) + picking.action_confirm() + picking.action_assign() + picking.move_line_ids[:1].quantity = quantity + picking.move_line_ids.picked = True + picking._action_done() + return picking + + def _bill(self, order, price_unit): + bill = self.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": self.partner.id, + "invoice_date": fields.Date.today(), + } + ) + bill.purchase_id = order + bill._onchange_purchase_auto_complete() + bill.invoice_line_ids.price_unit = price_unit + bill.action_post() + return bill + + def _layers(self): + return self.env["stock.valuation.layer"].search( + [("product_id", "=", self.product.id)], order="id" + ) + + def test_bill_price_difference_restates_the_receipt(self): + order, picking = self._receive(10.0, 10.0) + self._deliver(4.0) + receipt_layer = picking.move_ids.stock_valuation_layer_ids + out_layer = self._layers().filtered(lambda svl: svl.quantity < 0) + self.assertAlmostEqual(out_layer.unit_cost, 10.0, 2) + as_of = receipt_layer.create_date + + self._bill(order, 12.0) + + # The receipt itself, not only what is still on hand + self.assertAlmostEqual(receipt_layer.unit_cost, 12.0, 2) + self.assertAlmostEqual(receipt_layer.value, 120.0, 2) + self.assertAlmostEqual(receipt_layer.remaining_value, 72.0, 2) + # What already left follows, which is what the margin reads + self.assertAlmostEqual(out_layer.unit_cost, 12.0, 2) + self.assertAlmostEqual(out_layer.value, -48.0, 2) + # And Odoo's own child layer is not added on top + self.assertFalse(self._layers().filtered("stock_valuation_layer_id")) + self.assertAlmostEqual(self.product.standard_price, 12.0, 2) + # A valuation asked for a date before the bill comes out corrected: + # 120 received minus the 48 delivered, instead of the 60 it showed + # before the bill was posted + self.assertAlmostEqual( + self.product.with_context(to_date=as_of).value_svl, 72.0, 2 + ) + + def test_bill_at_the_price_already_synced_changes_nothing(self): + """Correcting the purchase order and then billing at that same price + must not correct it twice.""" + order, _picking = self._receive(10.0, 10.0) + self._deliver(4.0) + order.order_line.price_unit = 12.0 + value_before = sum(self._layers().mapped("value")) + + self._bill(order, 12.0) + + self.assertFalse(self._layers().filtered("stock_valuation_layer_id")) + self.assertAlmostEqual(sum(self._layers().mapped("value")), value_before, 2) + self.assertAlmostEqual(self.product.standard_price, 12.0, 2)