[FIX] product, decimal_precision, stock, mrp: Prevent quant reservation issue

One can set a Unit of Measure precision rounding more
precise than the overall Decimal Accuracy. This can trigger the error
'It is not possible to unreserve more products', as there will be
inconsistencies on reserved quantities due to decimal roundings.
We add 2 warnings on Units of Measure and Decimal Accuracy.
Therefore, users are warned when their UOM/decimal changes can
cause inconsistencies in quants reservation.

Also, we want to prevent the issue at the root by ensuring that both the move
and the quant have the same reserved quantity.
The contrary can happen e.g. when a product is defined in Liters (rounding .001)
but used in an operation in ml (rounding .01, so e.g. 187.5ml).
The conversions between the 2 UOM can cause the differences of reservations.

In _prepare_move_line_vals we make sure uom_quantity_back_to_product_uom
is not more precise than the overall Decimal Accuracy. That way, if
the reserved quantity changes between UOM conversions, the move line created
is in the quant/product UOM, and we do not have move line's reserved quantity >
quantity in stock.
We do the same for _update_reserved_quantity (stock.move), when updating
a move line.

In _update_move_lines, because of UOM conversions, new_quantity_done
can differ from ml.product_uom_qty, which triggers the creation of an extra
move line, leading to reserved quantity > quantity in stock. To prevent
that, we compare the reserved quantities in the product UOM.

opw 2206902 (Example 1)
opw 2171541
opw 2221227
opw 2233937
opw 2198775
and many more

closes odoo/odoo#62279

X-original-commit: 478a42beb5876171afeab7df27fa69548edbf849
Signed-off-by: Arnold Moyaux <amoyaux@users.noreply.github.com>
This commit is contained in:
Fanny He
2020-11-24 19:06:39 +00:00
parent eff6ce5e48
commit ccb6b26336
7 changed files with 369 additions and 4 deletions
+104
View File
@@ -1403,3 +1403,107 @@ class TestMrpOrder(TestMrpCommon):
mo_copy = mo_form.save()
mo_copy.button_mark_done()
self.assertEqual(mo_copy.state, 'done')
def test_product_produce_different_uom(self):
""" Check that for products tracked by lots,
with component product UOM different from UOM used in the BOM,
we do not create a new move line due to extra reserved quantity
caused by decimal rounding conversions.
"""
# the overall decimal accuracy is set to 3 digits
precision = self.env.ref('product.decimal_product_uom')
precision.digits = 3
# define L and ml, L has rounding .001 but ml has rounding .01
# when producing e.g. 187.5ml, it will be rounded to .188L
categ_test = self.env['uom.category'].create({'name': 'Volume Test'})
uom_L = self.env['uom.uom'].create({
'name': 'Test Liters',
'category_id': categ_test.id,
'uom_type': 'reference',
'rounding': 0.001
})
uom_ml = self.env['uom.uom'].create({
'name': 'Test ml',
'category_id': categ_test.id,
'uom_type': 'smaller',
'rounding': 0.01,
'factor_inv': 0.001,
})
# create a product component and the final product using the component
product_comp = self.env['product.product'].create({
'name': 'Product Component',
'type': 'product',
'tracking': 'lot',
'categ_id': self.env.ref('product.product_category_all').id,
'uom_id': uom_L.id,
'uom_po_id': uom_L.id,
})
product_final = self.env['product.product'].create({
'name': 'Product Final',
'type': 'product',
'tracking': 'lot',
'categ_id': self.env.ref('product.product_category_all').id,
'uom_id': uom_L.id,
'uom_po_id': uom_L.id,
})
# the products are tracked by lot, so we go through _generate_consumed_move_line
lot_final = self.env['stock.production.lot'].create({
'name': 'Lot Final',
'product_id': product_final.id,
'company_id': self.env.company.id,
})
lot_comp = self.env['stock.production.lot'].create({
'name': 'Lot Component',
'product_id': product_comp.id,
'company_id': self.env.company.id,
})
# update the quantity on hand for Component, in a lot
self.stock_location = self.env.ref('stock.stock_location_stock')
self.env['stock.quant']._update_available_quantity(product_comp, self.stock_location, 1, lot_id=lot_comp)
# create a BOM for Final, using Component
test_bom = self.env['mrp.bom'].create({
'product_id': product_final.id,
'product_tmpl_id': product_final.product_tmpl_id.id,
'product_uom_id': uom_L.id,
'product_qty': 1.0,
'type': 'normal',
'bom_line_ids': [(0, 0, {
'product_id': product_comp.id,
'product_qty': 375.00,
'product_uom_id': uom_ml.id
})],
})
# create a MO for this BOM
mo_product_final_form = Form(self.env['mrp.production'])
mo_product_final_form.product_id = product_final
mo_product_final_form.product_uom_id = uom_L
mo_product_final_form.bom_id = test_bom
mo_product_final_form.product_qty = 0.5
mo_product_final_form = mo_product_final_form.save()
mo_product_final_form.action_confirm()
mo_product_final_form.action_assign()
self.assertEqual(mo_product_final_form.reservation_state, 'assigned')
# produce
res_dict = mo_product_final_form.button_mark_done()
self.assertEqual(res_dict.get('res_model'), 'mrp.immediate.production')
wizard = Form(self.env[res_dict['res_model']].with_context(res_dict['context'])).save()
wizard.process()
# check that in _generate_consumed_move_line,
# we do not create an extra move line because
# of a conversion 187.5ml = 0.188L
# thus creating an extra line with 'product_uom_qty': 0.5
self.assertEqual(len(mo_product_final_form.move_raw_ids.move_line_ids), 1, 'One move line should exist for the MO.')
+25
View File
@@ -2340,6 +2340,15 @@ msgid ""
"current partner"
msgstr ""
#. module: product
#: code:addons/product/models/uom_uom.py:0
#, python-format
msgid ""
"This rounding precision is higher than the Decimal Accuracy (%s digits).\n"
"This may cause inconsistencies in computations.\n"
"Please set a precision between %s and 1."
msgstr ""
#. module: product
#: model:ir.model.fields,help:product.field_product_supplierinfo__product_code
msgid ""
@@ -2661,6 +2670,13 @@ msgstr ""
msgid "Volume unit of measure label"
msgstr ""
#. module: product
#: code:addons/product/models/decimal_precision.py:0
#: code:addons/product/models/uom_uom.py:0
#, python-format
msgid "Warning!"
msgstr ""
#. module: product
#: model_terms:ir.ui.view,arch_db:product.product_template_search_view
msgid "Warnings"
@@ -2694,6 +2710,15 @@ msgstr ""
msgid "White"
msgstr ""
#. module: product
#: code:addons/product/models/decimal_precision.py:34
#, python-format
msgid "You are setting a Decimal Accuracy less precise than the UOMs:\n"
"%s\n"
"This may cause inconsistencies in computations.\n"
"Please increase the rounding of those units of measure, or the digits of this Decimal Accuracy."
msgstr ""
#. module: product
#: model_terms:ir.actions.act_window,help:product.product_pricelist_action2
msgid ""
+1
View File
@@ -5,6 +5,7 @@
from . import res_config_settings
from . import decimal_precision
from . import uom_uom
# don't try to be a good boy and sort imports alphabetically.
# `product.template` should be initialised before `product.product`
@@ -15,3 +15,26 @@ class DecimalPrecision(models.Model):
for precision in self):
raise ValidationError(_("You cannot define the decimal precision of 'Account' as greater than the rounding factor of the company's main currency"))
return True
@api.onchange('digits')
def _onchange_digits(self):
if self.name != "Product Unit of Measure": # precision_get() relies on this name
return
# We are changing the precision of UOM fields; check whether the
# precision is equal or higher than existing units of measure.
rounding = 1.0 / 10.0**self.digits
dangerous_uom = self.env['uom.uom'].search([('rounding', '<', rounding)])
if dangerous_uom:
uom_descriptions = [
" - %s (id=%s, precision=%s)" % (uom.name, uom.id, uom.rounding)
for uom in dangerous_uom
]
return {'warning': {
'title': _('Warning!'),
'message': _(
"You are setting a Decimal Accuracy less precise than the UOMs:\n"
"%s\n"
"This may cause inconsistencies in computations.\n"
"Please increase the rounding of those units of measure, or the digits of this Decimal Accuracy."
) % ('\n'.join(uom_descriptions)),
}}
+21
View File
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models, _
class UoM(models.Model):
_inherit = 'uom.uom'
@api.onchange('rounding')
def _onchange_rounding(self):
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
if self.rounding < 1.0 / 10.0**precision:
return {'warning': {
'title': _('Warning!'),
'message': _(
"This rounding precision is higher than the Decimal Accuracy"
" (%s digits).\nThis may cause inconsistencies in computations.\n"
"Please set a precision between %s and 1."
) % (precision, 1.0 / 10.0**precision),
}}
+9 -4
View File
@@ -1167,9 +1167,10 @@ class StockMove(models.Model):
'company_id': self.company_id.id,
}
if quantity:
uom_quantity = self.product_id.uom_id._compute_quantity(quantity, self.product_uom, rounding_method='HALF-UP')
uom_quantity_back_to_product_uom = self.product_uom._compute_quantity(uom_quantity, self.product_id.uom_id, rounding_method='HALF-UP')
rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure')
uom_quantity = self.product_id.uom_id._compute_quantity(quantity, self.product_uom, rounding_method='HALF-UP')
uom_quantity = float_round(uom_quantity, precision_digits=rounding)
uom_quantity_back_to_product_uom = self.product_uom._compute_quantity(uom_quantity, self.product_id.uom_id, rounding_method='HALF-UP')
if float_compare(quantity, uom_quantity_back_to_product_uom, precision_digits=rounding) == 0:
vals = dict(vals, product_uom_qty=uom_quantity)
else:
@@ -1211,9 +1212,9 @@ class StockMove(models.Model):
taken_quantity = self.product_uom._compute_quantity(taken_quantity_move_uom, self.product_id.uom_id, rounding_method='HALF-UP')
quants = []
rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure')
if self.product_id.tracking == 'serial':
rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure')
if float_compare(taken_quantity, int(taken_quantity), precision_digits=rounding) != 0:
taken_quantity = 0
@@ -1231,7 +1232,11 @@ class StockMove(models.Model):
for reserved_quant, quantity in quants:
to_update = self.move_line_ids.filtered(lambda ml: ml._reservation_is_updatable(quantity, reserved_quant))
if to_update:
to_update[0].with_context(bypass_reservation_update=True).product_uom_qty += self.product_id.uom_id._compute_quantity(quantity, to_update[0].product_uom_id, rounding_method='HALF-UP')
uom_quantity = self.product_id.uom_id._compute_quantity(quantity, to_update[0].product_uom_id, rounding_method='HALF-UP')
uom_quantity = float_round(uom_quantity, precision_digits=rounding)
uom_quantity_back_to_product_uom = to_update[0].product_uom_id._compute_quantity(uom_quantity, self.product_id.uom_id, rounding_method='HALF-UP')
if to_update and float_compare(quantity, uom_quantity_back_to_product_uom, precision_digits=rounding) == 0:
to_update[0].with_context(bypass_reservation_update=True).product_uom_qty += uom_quantity
else:
if self.product_id.tracking == 'serial':
for i in range(0, int(quantity)):
+186
View File
@@ -6,6 +6,9 @@ from datetime import timedelta
from odoo.addons.stock.tests.common import TestStockCommon
from odoo.exceptions import UserError
from odoo.tests import Form
from odoo.tools import float_is_zero, float_compare
from odoo.tests.common import Form
class TestPickShip(TestStockCommon):
@@ -2159,6 +2162,189 @@ class TestStockUOM(TestStockCommon):
self.assertEqual(len(back_order_in), 1.00, 'There should be one back order created')
self.assertEqual(back_order_in.move_lines.product_qty, 91640.00, 'There should be one back order created')
def test_move_product_with_different_uom(self):
""" Product defined in g with 0.01 rounding
Decimal Accuracy (DA) 3 digits.
Quantity on hand: 149.88g
Picking of 1kg
kg has 0.0001 rounding
Due to conversions, we may end up reserving 150g
(more than the quantity in stock), we check that
we reserve less than the quantity in stock
"""
precision = self.env.ref('product.decimal_product_uom')
precision.digits = 3
precision_digits = precision.digits
self.uom_kg.rounding = 0.0001
self.uom_gm.rounding = 0.01
product_G = self.env['product.product'].create({
'name': 'Product G',
'type': 'product',
'categ_id': self.env.ref('product.product_category_all').id,
'uom_id': self.uom_gm.id,
'uom_po_id': self.uom_gm.id,
})
stock_location = self.env['stock.location'].browse(self.stock_location)
self.env['stock.quant']._update_available_quantity(product_G, stock_location, 149.88)
self.assertEqual(len(product_G.stock_quant_ids), 1, 'One quant should exist for the product.')
quant = product_G.stock_quant_ids
# transfer 1kg of product_G
picking = self.env['stock.picking'].create({
'location_id': self.stock_location,
'location_dest_id': self.customer_location,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
})
move = self.env['stock.move'].create({
'name': 'test_reserve_product_G',
'location_id': self.stock_location,
'location_dest_id': self.customer_location,
'picking_id': picking.id,
'product_id': product_G.id,
'product_uom': self.uom_kg.id,
'product_uom_qty': 1,
})
self.assertEqual(move.product_uom.id, self.uom_kg.id)
self.assertEqual(move.product_uom_qty, 1.0)
picking.action_confirm()
picking.action_assign()
self.assertEqual(product_G.uom_id.rounding, 0.01)
self.assertEqual(move.product_uom.rounding, 0.0001)
self.assertEqual(len(picking.move_line_ids), 1, 'One move line should exist for the picking.')
move_line = picking.move_line_ids
# check that we do not reserve more (in the same UOM) than the quantity in stock
self.assertEqual(float_compare(move_line.product_qty, quant.quantity, precision_digits=precision_digits), -1, "We do not reserve more (in the same UOM) than the quantity in stock")
# check that we reserve the same quantity in the ml and the quant
self.assertTrue(float_is_zero(move_line.product_qty - quant.reserved_quantity, precision_digits=precision_digits))
def test_update_product_move_line_with_different_uom(self):
""" Check that when the move line and corresponding
product have different UOM with possibly conflicting
precisions, we do not reserve more than the quantity
in stock. Similar initial configuration as
test_move_product_with_different_uom.
"""
precision = self.env.ref('product.decimal_product_uom')
precision.digits = 3
precision_digits = precision.digits
self.uom_kg.rounding = 0.0001
self.uom_gm.rounding = 0.01
product_LtDA = self.env['product.product'].create({
'name': 'Product Less than DA',
'type': 'product',
'categ_id': self.env.ref('product.product_category_all').id,
'uom_id': self.uom_gm.id,
'uom_po_id': self.uom_gm.id,
})
product_GtDA = self.env['product.product'].create({
'name': 'Product Greater than DA',
'type': 'product',
'categ_id': self.env.ref('product.product_category_all').id,
'uom_id': self.uom_gm.id,
'uom_po_id': self.uom_gm.id,
})
stock_location = self.env['stock.location'].browse(self.stock_location)
# quantity in hand converted to kg is not more precise than the DA
self.env['stock.quant']._update_available_quantity(product_LtDA, stock_location, 149)
# quantity in hand converted to kg is more precise than the DA
self.env['stock.quant']._update_available_quantity(product_GtDA, stock_location, 149.88)
self.assertEqual(len(product_LtDA.stock_quant_ids), 1, 'One quant should exist for the product.')
self.assertEqual(len(product_GtDA.stock_quant_ids), 1, 'One quant should exist for the product.')
quant_LtDA = product_LtDA.stock_quant_ids
quant_GtDA = product_GtDA.stock_quant_ids
# create 2 moves of 1kg
move_LtDA = self.env['stock.move'].create({
'name': 'test_reserve_product_LtDA',
'location_id': self.stock_location,
'location_dest_id': self.customer_location,
'product_id': product_LtDA.id,
'product_uom': self.uom_kg.id,
'product_uom_qty': 1,
})
move_GtDA = self.env['stock.move'].create({
'name': 'test_reserve_product_GtDA',
'location_id': self.stock_location,
'location_dest_id': self.customer_location,
'product_id': product_GtDA.id,
'product_uom': self.uom_kg.id,
'product_uom_qty': 1,
})
self.assertEqual(move_LtDA.state, 'draft')
self.assertEqual(move_GtDA.state, 'draft')
move_LtDA._action_confirm()
move_GtDA._action_confirm()
self.assertEqual(move_LtDA.state, 'confirmed')
self.assertEqual(move_GtDA.state, 'confirmed')
# check availability, less than initial demand
move_LtDA._action_assign()
move_GtDA._action_assign()
self.assertEqual(move_LtDA.state, 'partially_available')
self.assertEqual(move_GtDA.state, 'partially_available')
# the initial demand is 1kg
self.assertEqual(move_LtDA.product_uom.id, self.uom_kg.id)
self.assertEqual(move_GtDA.product_uom.id, self.uom_kg.id)
self.assertEqual(move_LtDA.product_uom_qty, 1.0)
self.assertEqual(move_GtDA.product_uom_qty, 1.0)
# one move line is created
self.assertEqual(len(move_LtDA.move_line_ids), 1)
self.assertEqual(len(move_GtDA.move_line_ids), 1)
# increase quantity by 0.14988 kg (more precise than DA)
self.env['stock.quant']._update_available_quantity(product_LtDA, stock_location, 149.88)
self.env['stock.quant']._update_available_quantity(product_GtDA, stock_location, 149.88)
# _update_reserved_quantity is called on a move only in _action_assign
move_LtDA._action_assign()
move_GtDA._action_assign()
# as the move line for LtDA and its corresponding quant can be
# in different UOMs, a new move line can be created
# from _update_reserved_quantity
move_lines_LtDA = self.env["stock.move.line"].search([
('product_id', '=', quant_LtDA.product_id.id),
('location_id', '=', quant_LtDA.location_id.id),
('lot_id', '=', quant_LtDA.lot_id.id),
('package_id', '=', quant_LtDA.package_id.id),
('owner_id', '=', quant_LtDA.owner_id.id),
('product_qty', '!=', 0)
])
reserved_on_move_lines_LtDA = sum(move_lines_LtDA.mapped('product_qty'))
move_lines_GtDA = self.env["stock.move.line"].search([
('product_id', '=', quant_GtDA.product_id.id),
('location_id', '=', quant_GtDA.location_id.id),
('lot_id', '=', quant_GtDA.lot_id.id),
('package_id', '=', quant_GtDA.package_id.id),
('owner_id', '=', quant_GtDA.owner_id.id),
('product_qty', '!=', 0)
])
reserved_on_move_lines_GtDA = sum(move_lines_GtDA.mapped('product_qty'))
# check that we do not reserve more (in the same UOM) than the quantity in stock
self.assertEqual(float_compare(reserved_on_move_lines_LtDA, quant_LtDA.quantity, precision_digits=precision_digits), -1, "We do not reserve more (in the same UOM) than the quantity in stock")
self.assertEqual(float_compare(reserved_on_move_lines_GtDA, quant_GtDA.quantity, precision_digits=precision_digits), -1, "We do not reserve more (in the same UOM) than the quantity in stock")
# check that we reserve the same quantity in the ml and the quant
self.assertTrue(float_is_zero(reserved_on_move_lines_LtDA - quant_LtDA.reserved_quantity, precision_digits=precision_digits))
self.assertTrue(float_is_zero(reserved_on_move_lines_GtDA - quant_GtDA.reserved_quantity, precision_digits=precision_digits))
class TestRoutes(TestStockCommon):
def setUp(self):