diff --git a/addons/mrp/tests/test_order.py b/addons/mrp/tests/test_order.py index 90f2945d6e6..65a4da48486 100644 --- a/addons/mrp/tests/test_order.py +++ b/addons/mrp/tests/test_order.py @@ -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.') diff --git a/addons/product/i18n/product.pot b/addons/product/i18n/product.pot index 574530cf552..16fcc6f186a 100644 --- a/addons/product/i18n/product.pot +++ b/addons/product/i18n/product.pot @@ -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 "" diff --git a/addons/product/models/__init__.py b/addons/product/models/__init__.py index 57917450de3..6e6333f642d 100644 --- a/addons/product/models/__init__.py +++ b/addons/product/models/__init__.py @@ -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` diff --git a/addons/product/models/decimal_precision.py b/addons/product/models/decimal_precision.py index e8a46a90e2f..0539fbb32dc 100644 --- a/addons/product/models/decimal_precision.py +++ b/addons/product/models/decimal_precision.py @@ -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)), + }} diff --git a/addons/product/models/uom_uom.py b/addons/product/models/uom_uom.py new file mode 100644 index 00000000000..cae84ba1ba0 --- /dev/null +++ b/addons/product/models/uom_uom.py @@ -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), + }} diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 8207dcbeb8d..9901998c6f2 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -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)): diff --git a/addons/stock/tests/test_move2.py b/addons/stock/tests/test_move2.py index d2e251cb1b7..f20ffc44e30 100644 --- a/addons/stock/tests/test_move2.py +++ b/addons/stock/tests/test_move2.py @@ -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):