Skip to content

Commit

Permalink
[FIX] stock: add automated way to fix unreserve issue
Browse files Browse the repository at this point in the history
When there is a discrepancy of reserved quantity between `stock.quant.reserved_quantity` and `stock.move.line.product_qty` the UserError 'It is not possible to unreserve more products of %s than you
have in stock' is raised.
This discrepancy can be caused by past bug, customization or direct manipulation of `stock.quant` or `stock.move.line` by the users.
Once this discrepancy is present, it will not disappear by itself.

Currently, there is no functional method to fix these discrepancies, the user encountering this message is stuck until they contact the Odoo support team.

Ideally, we would fix every bug related to the reservation so that this UserError almost never appears.
However, even after multiple bugs fixed (ex: #144176 , #154327 ) and multiple years of feedback, the users keep encountering this error and keep getting stuck when validating/cancelling transfers.

So the idea is to re-introduce a server action like it was done for Odoo 13 & Odoo 14 2 years ago: d99e173 to the current stable version (except the latest one Odoo 17 as we do not have enough perspectives on this version).

The server action checks all the quants and their relative move line to check if match correctly. If not, it will remove the reservation from both.
It could remove the reservation from some unrelated `pickings` and `stock.move`

Forward-Port of #79180
Related to #62139

closes #155910

X-original-commit: e16cd27
Signed-off-by: David Fesquet (dafr) <dafr@odoo.com>
Co-authored-by: Arnold Moyaux <arm@odoo.com>
  • Loading branch information
DavidFesquet and amoyaux committed Mar 1, 2024
1 parent 32e35d8 commit e0e63be
Show file tree
Hide file tree
Showing 5 changed files with 201 additions and 7 deletions.
111 changes: 111 additions & 0 deletions addons/stock/data/stock_data.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,117 @@
<field name="name">Closest Location</field>
<field name="method">closest</field>
</record>
<record id="stock_quant_stock_move_line_desynchronization" model="ir.actions.server">
<field name="name">Correct inconsistencies for reservation</field>
<field name="model_id" ref="base.model_ir_actions_server"/>
<field name="state">code</field>
<field name="code">
MoveLines = env['stock.move.line'].sudo()
Quants = env['stock.quant'].sudo()
tracked_fields = ['product_id', 'location_id', 'lot_id', 'package_id', 'owner_id']
impacted_quants, impacted_move_lines = set(), set()
all_errors = {}

# Compute rounding precision for each UOM
uom_roundings = {
u['id']: u['rounding'] for u in env['uom.uom'].sudo().with_context(active_test=False).search_read([], ['rounding'])
}
products_rounding = {
p['id']: uom_roundings[p['uom_id'][0]]
for p in env['product.product'].sudo().with_context(active_test=False).search_read([], ['uom_id'])
}

# Move that bypass reservations
ignore_moves = env['stock.move'].sudo().search([('state', 'not in', ['draft', 'cancel', 'done'])])
ignore_moves = ignore_moves.filtered(lambda m: m._should_bypass_reservation()).ids

# Get move_lines with incorrect reservation (negative or invalid on state)
incorrect_lines_grouped = MoveLines.read_group(
[
'|',
('reserved_qty', '&lt;', 0),
'&amp;',
('reserved_qty', '!=', 0),
'|',
('state', 'in', ['done', 'draft', 'cancel']),
('move_id', '=', False),
],
tracked_fields + ['ids:array_agg(id)', 'reserved_qty:sum'],
tracked_fields,
lazy=False,
)
for lines_grp in incorrect_lines_grouped:
rd = products_rounding[lines_grp['product_id'][0]]
if float_compare(0, lines_grp['reserved_qty'], precision_rounding=rd) != 0:
impacted_move_lines.update(lines_grp['ids'])

# Get key to match between quants and sml
def get_key(res):
return res['product_id'], res['location_id'], res['lot_id'], res['package_id'], res['owner_id']

# Create a python dictionary containing all quants with reserved quantities in the following format:
# (product_id, location_id, lot_id, package_id, owner_id): (id, reserved_quantity, quantity, rounding)
all_quants = {
get_key(q): (q['id'], q['reserved_quantity'], q['quantity'], products_rounding[q['product_id'][0]])
for q in Quants.search_read([('reserved_quantity', '!=', 0)], tracked_fields + ['reserved_quantity', 'quantity'])
}

# Get all move_lines with reserved quantities
all_grouped_move_lines = MoveLines.read_group(
[
('move_id', 'not in', ignore_moves),
('state', 'not in', ['done', 'draft', 'cancel']),
('reserved_qty', '&gt;', 0),
('move_id', '!=', False),
],
tracked_fields + ['ids:array_agg(id)', 'reserved_qty:sum'],
tracked_fields,
lazy=False,
)

def check_quant(quant_key, quant_val=None, lines=None):
if quant_val is None and lines is None:
return
if quant_val is None:
quant_val = (None, 0, 0, products_rounding[quant_key[0][0]])
if lines is None:
lines = {'ids': [], 'reserved_qty': 0}

quant_id, quant_res, quant_qty, rounding = quant_val
err = False
# Quant reserved must be inferior or equal to the Quant quantity (Before Odoo 17)
err |= float_compare(quant_qty, quant_res, precision_rounding=rounding) == -1
# Quant reserved must be higher or equal to 0
err |= float_compare(0, quant_res, precision_rounding=rounding) == 1
# Quant reserved must be equal to Move reserved
err |= float_compare(lines['reserved_qty'], quant_res, precision_rounding=rounding) != 0
if err:
impacted_move_lines.update(lines['ids'])
if quant_id:
impacted_quants.add(quant_id)

# Check errors on Move Lines and Quants
for lines_grp in all_grouped_move_lines:
sq_key = get_key(lines_grp)
check_quant(sq_key, quant_val=all_quants.pop(sq_key, None), lines=lines_grp)
# Quants with reservation without move lines reserved on it
for sq_key, sq_val in all_quants.items():
check_quant(sq_key, quant_val=sq_val, lines=None)

if impacted_quants:
Quants.browse(impacted_quants).write({'reserved_quantity': 0})
if impacted_move_lines:
lines = MoveLines.browse(impacted_move_lines)
lines.with_context(bypass_reservation_update=True).write({'reserved_uom_qty': 0})
lines.move_id._recompute_state()
if impacted_quants or impacted_move_lines:
report = "Reserved quantity set to 0 for the following: \n- stock.quant: {}\n- stock.move.line: {}".format(
impacted_quants, impacted_move_lines
)
log(report, level="debug")
action = {'type': 'ir.actions.client', 'tag': 'reload'}
</field>
</record>
</data>
<data noupdate="1">
<!-- Resource: stock.location -->
Expand Down
14 changes: 13 additions & 1 deletion addons/stock/i18n/stock.pot
Original file line number Diff line number Diff line change
Expand Up @@ -3770,7 +3770,19 @@ msgstr ""
#: code:addons/stock/models/stock_quant.py:0
#, python-format
msgid ""
"It is not possible to unreserve more products of %s than you have in stock."
"It is not possible to reserve more products of %s than you have in stock.\n\n"
"You can fix the discrepancies by clicking on the button below.\n"
"The correction will remove the reservation of the impacted operations on all companies.\n"
"If the error persists, or you see this message appear often, "
"please submit a Support Ticket at https://www.odoo.com/help"
msgstr ""

#. module: stock
#: code:addons/stock/models/stock_quant.py:0
#, python-format
msgid ""
"It is not possible to unreserve more products of %s than you have in stock.\n"
"Please contact your system administrator to rectify this issue."
msgstr ""

#. module: stock
Expand Down
22 changes: 20 additions & 2 deletions addons/stock/models/stock_quant.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from psycopg2 import Error

from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.exceptions import RedirectWarning, UserError, ValidationError
from odoo.osv import expression
from odoo.tools import check_barcode_encoding, groupby
from odoo.tools.float_utils import float_compare, float_is_zero
Expand Down Expand Up @@ -834,6 +834,19 @@ def _update_available_quantity(self, product_id, location_id, quantity, lot_id=N
})
return self._get_available_quantity(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=False, allow_negative=True), in_date

def _raise_fix_unreserve_action(self, product_id):
action = self.env.ref('stock.stock_quant_stock_move_line_desynchronization', raise_if_not_found=False)
if action and self.user_has_groups('base.group_system'):
msg = _(
'It is not possible to reserve more products of %s than you have in stock.\n\n'
'You can fix the discrepancies by clicking on the button below.\n'
'The correction will remove the reservation of the impacted operations on all companies.\n'
'If the error persists, or you see this message appear often, '
'please submit a Support Ticket at https://www.odoo.com/help',
product_id.display_name
)
raise RedirectWarning(msg, action.id, _('Fix discrepancies'))

@api.model
def _update_reserved_quantity(self, product_id, location_id, quantity, lot_id=None, package_id=None, owner_id=None, strict=False):
""" Increase the reserved quantity, i.e. increase `reserved_quantity` for the set of quants
Expand Down Expand Up @@ -861,7 +874,12 @@ def _update_reserved_quantity(self, product_id, location_id, quantity, lot_id=No
# if we want to unreserve
available_quantity = sum(quants.mapped('reserved_quantity'))
if float_compare(abs(quantity), available_quantity, precision_rounding=rounding) > 0:
raise UserError(_('It is not possible to unreserve more products of %s than you have in stock.', product_id.display_name))
self._raise_fix_unreserve_action(product_id)
raise UserError(_(
'It is not possible to unreserve more products of %s than you have in stock.\n'
'Please contact your system administrator to rectify this issue.',
product_id.display_name
))
else:
return reserved_quants

Expand Down
6 changes: 3 additions & 3 deletions addons/stock/tests/test_quant.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.exceptions import ValidationError
from odoo.tests.common import Form, TransactionCase
from odoo.exceptions import AccessError, UserError
from odoo.exceptions import AccessError, RedirectWarning, UserError


class StockQuant(TransactionCase):
Expand Down Expand Up @@ -440,7 +440,7 @@ def test_increase_decrease_reserved_quantity_1(self):
with self.assertRaises(UserError):
self.env['stock.quant']._update_reserved_quantity(self.product, self.stock_location, 1.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product, self.stock_location), 0.0)
with self.assertRaises(UserError):
with self.assertRaises(RedirectWarning):
self.env['stock.quant']._update_reserved_quantity(self.product, self.stock_location, -1.0, strict=True)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product, self.stock_location), 0.0)

Expand Down Expand Up @@ -485,7 +485,7 @@ def test_mix_tracked_untracked_1(self):
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product_serial, self.stock_location, strict=True), 1.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product_serial, self.stock_location, lot_id=lot1), 2.0)

with self.assertRaises(UserError):
with self.assertRaises(RedirectWarning):
self.env['stock.quant']._update_reserved_quantity(self.product_serial, self.stock_location, -1.0, strict=True)

self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product_serial, self.stock_location), 2.0)
Expand Down
55 changes: 54 additions & 1 deletion addons/stock/tests/test_robustness.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from odoo.exceptions import UserError, ValidationError
from odoo.exceptions import RedirectWarning, UserError, ValidationError
from odoo.tests.common import TransactionCase


Expand Down Expand Up @@ -260,3 +260,56 @@ def test_lot_quantity_remains_unchanged_after_done(self):
moveA._set_lot_ids()

self.assertEqual(moveA.quantity_done, 5)

def test_unreserve_error(self):
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 5)

move = self.env['stock.move'].create({
'name': 'test_lot_id_product_id_mix_move_1',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 5.0,
})
move._action_confirm()
move._action_assign()
quant = self.env['stock.quant']._gather(
self.product1, self.stock_location, strict=True)
quant.sudo().write({"reserved_quantity": 0})
server_action = self.env.ref(
'stock.stock_quant_stock_move_line_desynchronization', raise_if_not_found=False)
if server_action:
with self.assertRaises(RedirectWarning):
move._do_unreserve()
else:
with self.assertRaises(UserError):
move._do_unreserve()

def test_unreserve_fix(self):
self.env['stock.quant']._update_available_quantity(
self.product1, self.stock_location, 5)

move = self.env['stock.move'].create({
'name': 'test_lot_id_product_id_mix_move_1',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 5.0,
})

move._action_confirm()
move._action_assign()

quant = self.env['stock.quant']._gather(
self.product1, self.stock_location, strict=True)
quant.sudo().write({"reserved_quantity": 0})
server_action = self.env.ref(
'stock.stock_quant_stock_move_line_desynchronization', raise_if_not_found=False)
if not server_action:
return
server_action.run()
self.assertEqual(move.reserved_availability, 0)
self.assertEqual(move.state, 'confirmed')
self.assertEqual(quant.reserved_quantity, 0)

6 comments on commit e0e63be

@eicher31
Copy link

@eicher31 eicher31 commented on e0e63be Mar 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @DavidFesquet,
This commit break the update all module:
./odoo-bin -d database -u all

I just just checked out to the previous commit and the command work correctly.

In advance, thanks for your support

@DavidFesquet
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @eicher31 , thank you for the feedback !
I have been trying it on my side with a new database, and unfortunately I do not see any error ...
Here are the commands I tried to reproduce the issue:

./odoo-bin --addons-path=addons -d test_error -i stock,account,purchase,sale
./odoo-bin --addons-path=addons -d test_error -u all 

Could I have a bit more info on this?

  • Does it happen with a specific database and/or modules installed ?
  • What is the error you get from the upgrade ?

Thank you for your time !

@hieulucky111
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @DavidFesquet,

I am also facing the issue when upgrade all.
Here is my error message.

raise ParseError(msg) from None  # Restart with "--log-handler odoo.tools.convert:DEBUG" for complete traceback
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
odoo.tools.convert.ParseError: while parsing /instances/odoo/odoo-16/odoo/addons/stock/data/stock_data.xml:16
forbidden opcode(s) in 'lambda': POP_JUMP_FORWARD_IF_NOT_NONE
View error context:
'-no context-'

I think the issue is happening with database with data.

Thanks

@DavidFesquet
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @hieulucky111 ,
Indeed, it seems to be an issue with safe_eval in Odoo and Python 3.11.
Concretely, with python 3.11 you can't do if value is None: in a server action.
It has been fixed for saas-16.2 and after with this PR: #136943

For now, if the issue is blocking, you can change your python version, I will check if it is possible to back port this commit to 15.0 and 16.0.
If it is not possible, I will try to edit the action so that the optcode POP_JUMP_FORWARD_IF_NOT_NONE is not used (don't think it is possible), and in the worth case, I will revert the commit ...

Thanks for your help !

@DavidFesquet
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Odoo 16 it will be fixed with this PR: #156438

For Odoo 15, it is a bit more complicated, the original PR that made Odoo support python3.11 did not target odoo 15.0, so I am unsure whether it needs to be handled or not ...

@hieulucky111
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @DavidFesquet,

Well noted with thanks for your quick response.

Please sign in to comment.