diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py
index 861fe31cd49..b491218d69b 100644
--- a/addons/mrp/models/mrp_production.py
+++ b/addons/mrp/models/mrp_production.py
@@ -83,6 +83,7 @@ class MrpProduction(models.Model):
domain="[('code', '=', 'mrp_operation'), ('company_id', '=', company_id)]",
required=True, check_company=True, index=True)
use_create_components_lots = fields.Boolean(related='picking_type_id.use_create_components_lots')
+ use_auto_consume_components_lots = fields.Boolean(related='picking_type_id.use_auto_consume_components_lots')
location_src_id = fields.Many2one(
'stock.location', 'Components Location',
compute='_compute_locations', store=True, check_company=True,
@@ -1036,7 +1037,14 @@ class MrpProduction(models.Model):
for move in (self.move_raw_ids | self.move_finished_ids.filtered(lambda m: m.product_id != self.product_id)):
if move._should_bypass_set_qty_producing() or not move.product_uom:
continue
+
new_qty = float_round((self.qty_producing - self.qty_produced) * move.unit_factor, precision_rounding=move.product_uom.rounding)
+ if self.use_auto_consume_components_lots and move.has_tracking in ('lot', 'serial'):
+ if float_compare(move.reserved_availability, 0, precision_rounding=move.product_uom.rounding) <= 0:
+ continue
+ else:
+ new_qty = min(new_qty, move.reserved_availability)
+
move.move_line_ids.filtered(lambda ml: ml.state not in ('done', 'cancel')).qty_done = 0
move.move_line_ids = move._set_quantity_done_prepare_vals(new_qty)
diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py
index 4c478f85627..01707e4ee11 100644
--- a/addons/mrp/models/stock_move.py
+++ b/addons/mrp/models/stock_move.py
@@ -149,7 +149,7 @@ class StockMove(models.Model):
for move in self:
if move.state != 'draft':
continue
- move.manual_consumption = move.bom_line_id.manual_consumption or move.product_id.tracking != 'none'
+ move.manual_consumption = not move.raw_material_production_id.use_auto_consume_components_lots and (move.bom_line_id.manual_consumption or move.has_tracking != 'none')
@api.depends('bom_line_id')
def _compute_description_bom_line(self):
@@ -443,8 +443,7 @@ class StockMove(models.Model):
# Do not update extra product quantities
if float_is_zero(self.product_uom_qty, precision_rounding=self.product_uom.rounding):
return True
- if self.has_tracking != 'none' or self.state == 'done' or \
- self.manual_consumption or self._origin.manual_consumption:
+ if (not self.raw_material_production_id.use_auto_consume_components_lots and self.has_tracking != 'none') or self.manual_consumption or self._origin.manual_consumption:
return True
return False
diff --git a/addons/mrp/models/stock_picking.py b/addons/mrp/models/stock_picking.py
index a1d4fdf2b70..f1a63be51fe 100644
--- a/addons/mrp/models/stock_picking.py
+++ b/addons/mrp/models/stock_picking.py
@@ -21,6 +21,10 @@ class StockPickingType(models.Model):
help="Allow to create new lot/serial numbers for the components",
default=False,
)
+ use_auto_consume_components_lots = fields.Boolean(
+ string="Consume Reserved Lots/Serial Numbers automatically",
+ help="Allow automatic consumption of tracked components that are reserved",
+ )
def _get_mo_count(self):
mrp_picking_types = self.filtered(lambda picking: picking.code == 'mrp_operation')
diff --git a/addons/mrp/tests/__init__.py b/addons/mrp/tests/__init__.py
index 36c31199f06..7238c72b624 100644
--- a/addons/mrp/tests/__init__.py
+++ b/addons/mrp/tests/__init__.py
@@ -15,3 +15,4 @@ from . import test_multicompany
from . import test_backorder
from . import test_smp
from . import test_performance
+from . import test_consume_tracked_component
diff --git a/addons/mrp/tests/common_consume_tracked_component.py b/addons/mrp/tests/common_consume_tracked_component.py
new file mode 100644
index 00000000000..82f78152a5d
--- /dev/null
+++ b/addons/mrp/tests/common_consume_tracked_component.py
@@ -0,0 +1,233 @@
+import copy
+
+from odoo.exceptions import UserError
+from odoo.tests import common, Form
+
+class TestConsumeTrackedComponentCommon(common.TransactionCase):
+
+ @classmethod
+ def setUpClass(cls):
+ """
+ The following variables are used in each test to define the number of MO to generate.
+ They're also used as a verification in the executeConsumptionTriggers() to see if enough MO were passed to it
+ in order to test all the triggers.
+
+ SERIAL : MO's product_tracking is 'serial'
+ DEFAULT : MO's product_tracking is 'none' or 'lot'
+ AVAILABLE : MO'S raw components are fully available
+ """
+ super().setUpClass()
+
+ cls.SERIAL_AVAILABLE_TRIGGERS_COUNT = 3
+ cls.DEFAULT_AVAILABLE_TRIGGERS_COUNT = 2
+ cls.SERIAL_TRIGGERS_COUNT = 2
+ cls.DEFAULT_TRIGGERS_COUNT = 1
+
+ cls.manufacture_route = cls.env.ref('mrp.route_warehouse0_manufacture')
+ cls.stock_id = cls.env.ref('stock.stock_location_stock').id
+
+ cls.picking_type = cls.env['stock.picking.type'].search([('code', '=', 'mrp_operation')])[0]
+ cls.picking_type.use_create_components_lots = True
+ cls.picking_type.use_auto_consume_components_lots = True
+
+ #Create Products & Components
+ cls.produced_lot = cls.env['product.product'].create({
+ 'name': 'Produced Lot',
+ 'type': 'product',
+ 'categ_id': cls.env.ref('product.product_category_all').id,
+ 'tracking' : 'lot',
+ 'route_ids': [(4, cls.manufacture_route.id, 0)],
+ })
+ cls.produced_serial = cls.env['product.product'].create({
+ 'name': 'Produced Serial',
+ 'type': 'product',
+ 'categ_id': cls.env.ref('product.product_category_all').id,
+ 'tracking' : 'serial',
+ 'route_ids': [(4, cls.manufacture_route.id, 0)],
+ })
+ cls.produced_none = cls.env['product.product'].create({
+ 'name': 'Produced None',
+ 'type': 'product',
+ 'categ_id': cls.env.ref('product.product_category_all').id,
+ 'tracking' : 'none',
+ 'route_ids': [(4, cls.manufacture_route.id, 0)],
+ })
+
+ cls.raw_lot = cls.env['product.product'].create({
+ 'name': 'Raw Lot',
+ 'type': 'product',
+ 'categ_id': cls.env.ref('product.product_category_all').id,
+ 'tracking' : 'lot',
+ })
+ cls.raw_serial = cls.env['product.product'].create({
+ 'name': 'Raw Serial',
+ 'type': 'product',
+ 'categ_id': cls.env.ref('product.product_category_all').id,
+ 'tracking' : 'serial',
+ })
+ cls.raw_none = cls.env['product.product'].create({
+ 'name': 'Raw None',
+ 'type': 'product',
+ 'categ_id': cls.env.ref('product.product_category_all').id,
+ 'tracking' : 'none',
+ })
+
+ cls.raws = [cls.raw_none, cls.raw_lot, cls.raw_serial]
+
+ #Workcenter
+ cls.workcenter = cls.env['mrp.workcenter'].create({
+ 'name' : 'Assembly Line',
+ })
+
+ #BoMs
+ cls.bom_none = cls.env['mrp.bom'].create({
+ 'product_tmpl_id' : cls.produced_none.product_tmpl_id.id,
+ 'product_uom_id' : cls.produced_none.uom_id.id,
+ 'consumption' : 'flexible',
+ 'sequence' : 1
+ })
+
+ cls.bom_none_lines = cls.create_bom_lines(cls.bom_none, cls.raws, [3, 2, 1])
+
+ cls.bom_lot = cls.env['mrp.bom'].create({
+ 'product_tmpl_id' : cls.produced_lot.product_tmpl_id.id,
+ 'product_uom_id' : cls.produced_lot.uom_id.id,
+ 'consumption' : 'flexible',
+ 'sequence' : 2
+ })
+
+ cls.bom_lot_lines = cls.create_bom_lines(cls.bom_lot, cls.raws, [3, 2, 1])
+
+ cls.bom_serial = cls.env['mrp.bom'].create({
+ 'product_tmpl_id' : cls.produced_serial.product_tmpl_id.id,
+ 'product_uom_id' : cls.produced_serial.uom_id.id,
+ 'consumption' : 'flexible',
+ 'sequence' : 1
+ })
+
+ cls.bom_serial_lines = cls.create_bom_lines(cls.bom_serial, cls.raws, [3, 2, 1])
+
+ #Manufacturing Orders
+ cls.mo_none_tmpl = {
+ 'product_id' : cls.produced_none.id,
+ 'product_uom_id' : cls.produced_none.uom_id.id,
+ 'product_qty' : 1,
+ 'bom_id' : cls.bom_none.id
+ }
+
+ cls.mo_lot_tmpl = {
+ 'product_id' : cls.produced_lot.id,
+ 'product_uom_id' : cls.produced_lot.uom_id.id,
+ 'product_qty' : 1,
+ 'bom_id' : cls.bom_lot.id
+ }
+
+ cls.mo_serial_tmpl = {
+ 'product_id' : cls.produced_serial.id,
+ 'product_uom_id' : cls.produced_serial.uom_id.id,
+ 'product_qty' : 1,
+ 'bom_id' : cls.bom_serial.id
+ }
+
+ @classmethod
+ def create_quant(cls, product, qty, offset=0, name="L"):
+ i = 1
+ if product.tracking == 'serial':
+ i, qty = qty, 1
+ if name == "L":
+ name = "S"
+
+ vals = []
+ for x in range(1, i+1):
+ qDict = {
+ 'location_id': cls.stock_id,
+ 'product_id': product.id,
+ 'inventory_quantity': qty,
+ }
+
+ if product.tracking != 'none':
+ qDict['lot_id'] = cls.env['stock.lot'].create({
+ 'name': name + str(offset + x),
+ 'product_id': product.id,
+ 'company_id': cls.env.company.id
+ }).id
+ vals.append(qDict)
+
+ return cls.env['stock.quant'].create(vals)
+
+ @classmethod
+ def create_bom_lines(cls, bom, products, quantities=None):
+ if quantities is None:
+ quantities = [1 for i in range(len(products))]
+
+ vals = []
+ for product, seq in zip(products, range(len(products))):
+ vals.append({
+ 'product_id' : product.id,
+ 'product_qty' : quantities[seq],
+ 'product_uom_id' : product.uom_id.id,
+ 'sequence' : seq,
+ 'bom_id' : bom.id,
+ })
+
+ return cls.env['mrp.bom.line'].create(vals)
+
+ @classmethod
+ def create_mo(cls, template, count):
+ vals = []
+ for _ in range(count):
+ vals.append(copy.deepcopy(template))
+ return cls.env['mrp.production'].create(vals)
+
+ def executeConsumptionTriggers(self, mrp_productions):
+ """
+ There's 3 different triggers to test : _onchange_producing(), action_generate_serial(), button_mark_done().
+
+ Depending on the tracking of the final product and the availability of the components,
+ only a part of these 3 triggers is available or intended to work.
+
+ This function automatically call and process the appropriate triggers.
+ """
+ tracking = mrp_productions[0].product_tracking
+ sameTracking = True
+ for mo in mrp_productions:
+ sameTracking = sameTracking and mo.product_tracking == tracking
+ self.assertTrue(sameTracking, "MOs passed to the executeConsumptionTriggers method shall have the same product_tracking")
+
+ isSerial = tracking == 'serial'
+ isAvailable = all(move.state == 'assigned' for move in mrp_productions.move_raw_ids)
+
+ countOk = True
+ length = len(mrp_productions)
+ if isSerial:
+ if isAvailable:
+ countOk = length == self.SERIAL_AVAILABLE_TRIGGERS_COUNT
+ else:
+ countOk = length == self.SERIAL_TRIGGERS_COUNT
+ else:
+ if isAvailable:
+ countOk = length == self.DEFAULT_AVAILABLE_TRIGGERS_COUNT
+ else:
+ countOk = length == self.DEFAULT_TRIGGERS_COUNT
+ self.assertTrue(countOk, "The number of MOs passed to the executeConsumptionTriggers method does not match the associated TRIGGERS_COUNT")
+
+ mrp_productions[0].qty_producing = mrp_productions[0].product_qty
+ mrp_productions[0]._onchange_producing()
+
+ i = 1
+ if isSerial:
+ mrp_productions[i].action_generate_serial()
+ i += 1
+
+ if isAvailable:
+ mark_done_action = mrp_productions[i].button_mark_done()
+ immediate_production_wizard = Form(
+ self.env['mrp.immediate.production']
+ .with_context(**mark_done_action['context'])
+ ).save()
+ error = False
+ try:
+ immediate_production_wizard.process()
+ except UserError:
+ error = True
+ self.assertFalse(error, "Immediate Production Wizard shall not raise an error.")
diff --git a/addons/mrp/tests/test_consume_tracked_component.py b/addons/mrp/tests/test_consume_tracked_component.py
new file mode 100644
index 00000000000..cded3090949
--- /dev/null
+++ b/addons/mrp/tests/test_consume_tracked_component.py
@@ -0,0 +1,157 @@
+from odoo.tests import tagged
+from odoo.addons.mrp.tests.common_consume_tracked_component import TestConsumeTrackedComponentCommon
+
+@tagged('post_install', '-at_install')
+class TestConsumeTrackedComponent(TestConsumeTrackedComponentCommon):
+
+ def test_option_disabled_and_qty_available(self):
+ """
+ Option disabled, qty available
+ -> Not Tracked components are fully consumed
+ -> Tracked components are only consumed on button_mark_done trigger
+ """
+
+ self.picking_type.use_auto_consume_components_lots = False
+
+ mo_none = self.create_mo(self.mo_none_tmpl, self.DEFAULT_AVAILABLE_TRIGGERS_COUNT)
+ mo_serial = self.create_mo(self.mo_serial_tmpl, self.SERIAL_AVAILABLE_TRIGGERS_COUNT)
+ mo_lot = self.create_mo(self.mo_lot_tmpl, self.DEFAULT_AVAILABLE_TRIGGERS_COUNT)
+
+ mo_all = mo_none + mo_serial + mo_lot
+ mo_all.action_confirm()
+
+ all_qty = 2 * self.DEFAULT_AVAILABLE_TRIGGERS_COUNT + self.SERIAL_AVAILABLE_TRIGGERS_COUNT
+
+ quant = self.create_quant(self.raw_none, 3*all_qty)
+ quant |= self.create_quant(self.raw_lot, 2*all_qty)
+ quant |= self.create_quant(self.raw_serial, 1*all_qty)
+ quant.action_apply_inventory()
+
+ #Quantities are fully reserved (stock.move state is available)
+ mo_all.action_assign()
+ for mov in mo_all.move_raw_ids:
+ self.assertEqual(mov.product_qty, mov.reserved_availability, "Reserved quantity shall be equal to To Consume quantity.")
+
+ #Test for Serial Product
+ self.executeConsumptionTriggers(mo_serial)
+ self.executeConsumptionTriggers(mo_none)
+ self.executeConsumptionTriggers(mo_lot)
+ for mov in mo_all.move_raw_ids:
+ if mov.has_tracking == 'none' or mov.raw_material_production_id.state == 'done':
+ self.assertEqual(mov.product_qty, mov.quantity_done, "Done quantity shall be equal to To Consume quantity.")
+ else:
+ self.assertEqual(0, mov.quantity_done, "Done quantity shall be equal to 0.")
+
+ def test_option_enabled_and_qty_available(self):
+ """
+ Option enabled, qty available
+ -> Not Tracked components are fully consumed
+ -> Tracked components are fully consumed
+ """
+
+ mo_none = self.create_mo(self.mo_none_tmpl, self.DEFAULT_AVAILABLE_TRIGGERS_COUNT)
+ mo_serial = self.create_mo(self.mo_serial_tmpl, self.SERIAL_AVAILABLE_TRIGGERS_COUNT)
+ mo_lot = self.create_mo(self.mo_lot_tmpl, self.DEFAULT_AVAILABLE_TRIGGERS_COUNT)
+
+ mo_all = mo_none + mo_serial + mo_lot
+ mo_all.action_confirm()
+
+ all_qty = 2 * self.DEFAULT_AVAILABLE_TRIGGERS_COUNT + self.SERIAL_AVAILABLE_TRIGGERS_COUNT
+
+ quant = self.create_quant(self.raw_none, 3*all_qty)
+ quant |= self.create_quant(self.raw_lot, 2*all_qty)
+ quant |= self.create_quant(self.raw_serial, 1*all_qty)
+ quant.action_apply_inventory()
+
+ #Quantities are fully reserved (stock.move state is available)
+ mo_all.action_assign()
+ for mov in mo_all.move_raw_ids:
+ self.assertEqual(mov.product_qty, mov.reserved_availability, "Reserved quantity shall be equal to To Consume quantity.")
+
+ self.executeConsumptionTriggers(mo_serial)
+ self.executeConsumptionTriggers(mo_none)
+ self.executeConsumptionTriggers(mo_lot)
+ for mov in mo_all.move_raw_ids:
+ self.assertEqual(mov.product_qty, mov.quantity_done, "Done quantity shall be equal to To Consume quantity.")
+
+ def test_option_enabled_and_qty_not_available(self):
+ """
+ Option enabled, qty not available
+ -> Not Tracked components are fully consumed
+ -> Tracked components are not consumed
+ """
+
+ mo_none = self.create_mo(self.mo_none_tmpl, self.DEFAULT_TRIGGERS_COUNT)
+ mo_serial = self.create_mo(self.mo_serial_tmpl, self.SERIAL_TRIGGERS_COUNT)
+ mo_lot = self.create_mo(self.mo_lot_tmpl, self.DEFAULT_TRIGGERS_COUNT)
+
+ mo_all = mo_none + mo_serial + mo_lot
+ mo_all.action_confirm()
+
+ #Quantities are not reserved at all (stock.move state is confirmed)
+ mo_all.action_assign()
+ for mov in mo_all.move_raw_ids:
+ self.assertEqual(0, mov.reserved_availability, "Reserved quantity shall be equal to 0.")
+
+ self.executeConsumptionTriggers(mo_serial)
+ self.executeConsumptionTriggers(mo_none)
+ self.executeConsumptionTriggers(mo_lot)
+
+ for mov in mo_all.move_raw_ids:
+ if mov.has_tracking == 'none':
+ self.assertEqual(mov.product_qty, mov.quantity_done, "Done quantity shall be equal to To Consume quantity.")
+ else:
+ self.assertEqual(0, mov.quantity_done, "Done quantity shall be equal to To Consume quantity.")
+
+ def test_option_enabled_and_qty_partially_available(self):
+ """
+ Option enabled, qty partially available
+ -> Not Tracked components are fully consumed
+ -> Tracked components are partially consumed
+ """
+
+ #update BoM serial component qty
+ self.bom_none_lines[2].product_qty = 2
+ self.bom_serial_lines[2].product_qty = 2
+ self.bom_lot_lines[2].product_qty = 2
+
+ raw_none_qty = 2
+ raw_tracked_qty = 1
+
+ quant = self.create_quant(self.raw_none, raw_none_qty)
+ quant |= self.create_quant(self.raw_lot, raw_tracked_qty)
+ quant |= self.create_quant(self.raw_serial, raw_tracked_qty)
+ quant.action_apply_inventory()
+
+ #We must create & process each MO at once as we must assign quants for each individually
+ def testUnit(mo_tmpl, serialTrigger=None):
+ mo = self.create_mo(mo_tmpl, 1)
+ mo.action_confirm()
+
+ #Quantities are partially reserved (stock.move state is partially_available)
+ mo.action_assign()
+ for mov in mo.move_raw_ids:
+ if mov.has_tracking == "none":
+ self.assertEqual(raw_none_qty, mov.reserved_availability, "Reserved quantity shall be equal to " + str(raw_none_qty)+ ".")
+ else:
+ self.assertEqual(raw_tracked_qty, mov.reserved_availability, "Reserved quantity shall be equal to " + str(raw_tracked_qty)+ ".")
+
+ if serialTrigger is None:
+ self.executeConsumptionTriggers(mo)
+ elif serialTrigger == 1:
+ mo.qty_producing = mo.product_qty
+ mo._onchange_producing()
+ elif serialTrigger == 2:
+ mo.action_generate_serial()
+
+ for mov in mo.move_raw_ids:
+ if mov.has_tracking == "none":
+ self.assertEqual(mov.product_qty, mov.quantity_done, "Done quantity shall be equal to To Consume quantity.")
+ else:
+ self.assertEqual(raw_tracked_qty, mov.quantity_done, "Done quantity shall be equal to " + str(raw_tracked_qty)+ ".")
+ mo.action_cancel()
+
+ testUnit(self.mo_none_tmpl)
+ testUnit(self.mo_lot_tmpl)
+ testUnit(self.mo_serial_tmpl, 1)
+ testUnit(self.mo_serial_tmpl, 2)
diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml
index 48477b2bf9b..d932f881f63 100644
--- a/addons/mrp/views/mrp_production_views.xml
+++ b/addons/mrp/views/mrp_production_views.xml
@@ -342,7 +342,8 @@
decoration-bf="not manual_consumption"
decoration-success="not is_done and (quantity_done - should_consume_qty == 0)"
decoration-warning="not is_done and (quantity_done - should_consume_qty > 0.0001)"
- attrs="{'column_invisible': [('parent.state', '=', 'draft')], 'readonly': [('has_tracking', '!=','none')]}"/>
+ attrs="{'column_invisible': [('parent.state', '=', 'draft')], 'readonly': [('has_tracking', '!=','none')]}"
+ force_save="1"/>
+