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: odoo#144176 , odoo#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 odoo#79180
Related to odoo#62139

closes odoo#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)

0 comments on commit e0e63be

Please sign in to comment.