addons-cm/stock_account_avco_negative_fix/models/stock_move.py

95 lines
4.8 KiB
Python

from odoo import models
from odoo.tools import float_compare
class StockMove(models.Model):
_inherit = "stock.move"
def _action_done(self, cancel_backorder=False):
"""Fix negative AVCO ``standard_price`` after a move is done.
Odoo core (``stock_account/models/stock_move.py``,
``product_price_update_before_done``) computes the new average cost
of an AVCO product as a weighted average between the current
valuation (``quantity_svl`` / ``standard_price``) and the incoming
move. When the product has non-positive stock (``quantity_svl <= 0``)
and the incoming quantity only partially compensates it, the
resulting weighted average can become negative or zero, corrupting
the product cost for every subsequent outgoing valuation.
This is a known, unfixed upstream bug: see
https://github.com/odoo/odoo/issues/187169
Rather than copying/overriding the core algorithm (which would break
compatibility with any OCA module -- present or future -- also
inheriting ``product_price_update_before_done``), we post-process the
final result of the whole ``_action_done`` flow:
- *Before* calling ``super()``, we detect the AVCO products that are
"at risk": incoming moves (``move._is_in()``) of a product with
``cost_method == 'average'`` whose stock valuation quantity
(``quantity_svl``) is already ``<= 0`` *before* this move is
processed. This is exactly the precondition under which the core's
weighted-average formula can degenerate to a negative or zero
result. We capture, for each such ``(company, product)``, the
incoming move's own ``price_unit`` (``move._get_price_unit()``).
- We let ``super()._action_done()`` run its full logic unmodified
(AVCO weighted average, FIFO branch, and the ``_run_fifo_vacuum()``
step that runs at the very end of ``_action_done``).
- *After* ``super()`` has fully completed, for every "at risk"
``(company, product)`` captured above, we unconditionally rewrite
``standard_price`` with the captured incoming ``price_unit`` (not
the product's configured purchase price).
Note this fix is intentionally based on the *precondition*
(``quantity_svl <= 0`` before the move) rather than on inspecting the
*final* ``standard_price`` sign. An earlier version of this fix only
rewrote ``standard_price`` when it was left ``<= 0`` after
``super()``, but that check could be silently bypassed: some module
reacting to the ``_run_fifo_vacuum()`` step (e.g. the OCA module
``product_cost_price_avco_sync``, which hooks into
``stock.valuation.layer.write()``) can rewrite ``standard_price`` to
a *different but still positive* value before our check ran,
preventing the fix from ever triggering even though the scenario
that causes the original bug did occur. Keying off the precondition
instead makes the fix robust regardless of what any other module
does afterwards, without depending on assumptions about whether the
vacuum runs in production (in practice, ``product_cost_price_avco_sync``
disables the vacuum outside of tests for performance reasons, since
it is only meaningful for FIFO -- but the fix should not rely on
that being permanently true).
"""
at_risk_price_unit = {}
for move in self.filtered(
lambda m: m._is_in()
and m.with_company(m.company_id).product_id.cost_method == "average"
):
company = move.company_id
product = move.product_id.with_company(company)
quantity_svl = product.sudo().quantity_svl
rounding = product.uom_id.rounding
if float_compare(quantity_svl, 0.0, precision_rounding=rounding) <= 0:
at_risk_price_unit[(company.id, product.id)] = move._get_price_unit()
res = super()._action_done(cancel_backorder=cancel_backorder)
if not at_risk_price_unit:
return res
companies = self.env["res.company"].browse(
{key[0] for key in at_risk_price_unit}
)
products = self.env["product.product"].browse(
{key[1] for key in at_risk_price_unit}
)
company_by_id = {company.id: company for company in companies}
product_by_id = {product.id: product for product in products}
for (company_id, product_id), price_unit in at_risk_price_unit.items():
company = company_by_id[company_id]
product = product_by_id[product_id]
product.with_company(company.id).with_context(
disable_auto_svl=True
).sudo().write({"standard_price": price_unit})
return res