diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py
index 08c4b681905..9bcd229c95b 100644
--- a/addons/mrp/models/mrp_production.py
+++ b/addons/mrp/models/mrp_production.py
@@ -12,7 +12,7 @@ from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.tools import float_compare, float_round, float_is_zero, format_datetime
-from odoo.tools.misc import format_date
+from odoo.tools.misc import OrderedSet, format_date
from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES
@@ -265,7 +265,7 @@ class MrpProduction(models.Model):
('late', 'Late')], compute='_compute_components_availability')
show_lot_ids = fields.Boolean('Display the serial number shortcut on the moves', compute='_compute_show_lot_ids')
forecasted_issue = fields.Boolean(compute='_compute_forecasted_issue')
- show_serial_mass_produce = fields.Boolean('Display the serial mass product wizard action moves', compute='_compute_show_serial_mass_produce')
+ show_serial_mass_produce = fields.Boolean('Display the serial mass produce wizard action', compute='_compute_show_serial_mass_produce')
@api.depends('procurement_group_id.stock_move_ids.created_production_id.procurement_group_id.mrp_production_ids')
def _compute_mrp_production_child_count(self):
@@ -540,9 +540,7 @@ class MrpProduction(models.Model):
if order.state == 'confirmed' and order.product_id.tracking == 'serial' and \
float_compare(order.product_qty, 1, precision_rounding=order.product_uom_id.rounding) > 0 and \
float_compare(order.qty_producing, order.product_qty, precision_rounding=order.product_uom_id.rounding) < 0:
- have_serial_components, dummy, dummy, dummy = order._check_serial_mass_produce_components()
- if not have_serial_components:
- order.show_serial_mass_produce = True
+ order.show_serial_mass_produce = True
_sql_constraints = [
('name_uniq', 'unique(name, company_id)', 'Reference must be unique per Company!'),
@@ -1456,77 +1454,202 @@ class MrpProduction(models.Model):
def _get_backorder_mo_vals(self):
self.ensure_one()
- next_seq = max(self.procurement_group_id.mrp_production_ids.mapped("backorder_sequence"), default=1)
return {
- 'name': self._get_name_backorder(self.name, next_seq + 1),
- 'backorder_sequence': next_seq + 1,
'procurement_group_id': self.procurement_group_id.id,
'move_raw_ids': None,
'move_finished_ids': None,
- 'product_qty': self._get_quantity_to_backorder(),
'lot_producing_id': False,
- 'origin': self.origin
+ 'origin': self.origin,
+ 'state': 'confirmed',
}
- def _generate_backorder_productions(self, close_mo=True):
- backorders = self.env['mrp.production']
+ def _split_productions(self, amounts=False, cancel_remaning_qty=False):
+ """ Splits productions into productions smaller quantities to produce, i.e. creates
+ its backorders.
+
+ :param dict amounts: a dict with a production as key and a list value containing
+ the amounts each production split should produce including the original production,
+ e.g. {mrp.production(1,): [3, 2]} will result in mrp.production(1,) having a product_qty=3
+ and a new backorder with product_qty=2.
+ :return: mrp.production records in order of [orig_prod_1, backorder_prod_1,
+ backorder_prod_2, orig_prod_2, backorder_prod_2, etc.]
+ """
+ def _default_amounts(production):
+ return [production.qty_producing, production._get_quantity_to_backorder()]
+
+ if not amounts:
+ amounts = {}
for production in self:
+ mo_amounts = amounts.get(production)
+ if not mo_amounts:
+ amounts[production] = _default_amounts(production)
+ continue
+ total_amount = sum(mo_amounts)
+ if total_amount < production.product_qty and not cancel_remaning_qty:
+ amounts[production].append(production.product_qty - total_amount)
+ elif total_amount > production.product_qty or production.state in ['done', 'cancel']:
+ raise UserError(_("Unable to split with more than the quantity to produce."))
+
+ backorder_vals_list = []
+ initial_qty_by_production = {}
+
+ # Create the backorders.
+ for production in self:
+ initial_qty_by_production[production] = production.product_qty
if production.backorder_sequence == 0: # Activate backorder naming
production.backorder_sequence = 1
production.name = self._get_name_backorder(production.name, production.backorder_sequence)
- backorder_mo = production.copy(default=production._get_backorder_mo_vals())
- if close_mo:
- production.move_raw_ids.filtered(lambda m: m.state not in ('done', 'cancel')).write({
- 'raw_material_production_id': backorder_mo.id,
- })
- production.move_finished_ids.filtered(lambda m: m.state not in ('done', 'cancel')).write({
- 'production_id': backorder_mo.id,
- })
- else:
- new_moves_vals = []
- for move in production.move_raw_ids | production.move_finished_ids:
- if not move.additional:
- qty_to_split = move.product_uom_qty - move.unit_factor * production.qty_producing
- qty_to_split = move.product_uom._compute_quantity(qty_to_split, move.product_id.uom_id, rounding_method='HALF-UP')
- move_vals = move._split(qty_to_split)
- if not move_vals:
- continue
- if move.raw_material_production_id:
- move_vals[0]['raw_material_production_id'] = backorder_mo.id
- else:
- move_vals[0]['production_id'] = backorder_mo.id
- new_moves_vals.append(move_vals[0])
- self.env['stock.move'].create(new_moves_vals)
- backorders |= backorder_mo
+ production.product_qty = amounts[production][0]
+ backorder_vals = production.copy_data(default=production._get_backorder_mo_vals())[0]
+ backorder_qtys = amounts[production][1:]
- # We need to adapt `duration_expected` on both the original workorders and their
- # backordered workorders. To do that, we use the original `duration_expected` and the
- # ratio of the quantity really produced and the quantity to produce.
- ratio = production.qty_producing / production.product_qty
+ next_seq = max(production.procurement_group_id.mrp_production_ids.mapped("backorder_sequence"), default=1)
+
+ for qty_to_backorder in backorder_qtys:
+ next_seq += 1
+ backorder_vals_list.append(dict(
+ backorder_vals,
+ product_qty=qty_to_backorder,
+ name=production._get_name_backorder(production.name, next_seq),
+ backorder_sequence=next_seq
+ ))
+
+ backorders = self.env['mrp.production'].create(backorder_vals_list)
+
+ index = 0
+ production_to_backorders = {}
+ production_ids = OrderedSet()
+ for production in self:
+ number_of_backorder_created = len(amounts.get(production, _default_amounts(production))) - 1
+ production_backorders = backorders[index:index + number_of_backorder_created]
+ production_to_backorders[production] = production_backorders
+ production_ids.update(production.ids)
+ production_ids.update(production_backorders.ids)
+ index += number_of_backorder_created
+
+ # Split the `stock.move` among new backorders.
+ new_moves_vals = []
+ moves = []
+ for production in self:
+ for move in production.move_raw_ids | production.move_finished_ids:
+ if move.additional:
+ continue
+ unit_factor = move.product_uom_qty / initial_qty_by_production[production]
+ initial_move_vals = move.copy_data(move._get_backorder_move_vals())[0]
+ move.with_context(do_not_unreserve=True).product_uom_qty = production.product_qty * unit_factor
+
+ for backorder in production_to_backorders[production]:
+ move_vals = dict(
+ initial_move_vals,
+ product_uom_qty=backorder.product_qty * unit_factor
+ )
+ if move.raw_material_production_id:
+ move_vals['raw_material_production_id'] = backorder.id
+ else:
+ move_vals['production_id'] = backorder.id
+ new_moves_vals.append(move_vals)
+ moves.append(move)
+
+ backorder_moves = self.env['stock.move'].create(new_moves_vals)
+ # Split `stock.move.line`s. 2 options for this:
+ # - do_unreserve -> action_assign
+ # - Split the reserved amounts manually
+ # The first option would be easier to maintain since it's less code
+ # However it could be slower (due to `stock.quant` update) and could
+ # create inconsistencies in mass production if a new lot higher in a
+ # FIFO strategy arrives between the reservation and the backorder creation
+ move_to_backorder_moves = defaultdict(lambda: self.env['stock.move'])
+ for move, backorder_move in zip(moves, backorder_moves):
+ move_to_backorder_moves[move] |= backorder_move
+
+ move_lines_vals = []
+ assigned_moves = set()
+ partially_assigned_moves = set()
+ move_lines_to_unlink = set()
+
+ for initial_move, backorder_moves in move_to_backorder_moves.items():
+ ml_by_move = []
+ product_uom = initial_move.product_id.uom_id
+ for move_line in initial_move.move_line_ids:
+ available_qty = move_line.product_uom_id._compute_quantity(move_line.reserved_uom_qty, product_uom)
+ if float_compare(available_qty, 0, precision_rounding=move_line.product_uom_id.rounding) <= 0:
+ continue
+ ml_by_move.append((available_qty, move_line, move_line.copy_data()[0]))
+
+ initial_move.move_line_ids.with_context(bypass_reservation_update=True).write({'reserved_uom_qty': 0})
+ moves = list(initial_move | backorder_moves)
+
+ move = moves and moves.pop(0)
+ move_qty_to_reserve = move.product_qty
+ for quantity, move_line, ml_vals in ml_by_move:
+ while float_compare(quantity, 0, precision_rounding=product_uom.rounding) > 0 and move:
+ # Do not create `stock.move.line` if there is no initial demand on `stock.move`
+ taken_qty = min(move_qty_to_reserve, quantity)
+ taken_qty_uom = product_uom._compute_quantity(taken_qty, move_line.product_uom_id)
+ if move == initial_move:
+ move_line.with_context(bypass_reservation_update=True).reserved_uom_qty = taken_qty_uom
+ elif not float_is_zero(taken_qty_uom, precision_rounding=move_line.product_uom_id.rounding):
+ move_lines_vals.append(dict(
+ ml_vals,
+ reserved_uom_qty=taken_qty_uom,
+ move_id=move.id
+ ))
+ quantity -= taken_qty
+ move_qty_to_reserve -= taken_qty
+
+ if float_compare(move_qty_to_reserve, 0, precision_rounding=move.product_uom.rounding) <= 0:
+ assigned_moves.add(move.id)
+ move = moves and moves.pop(0)
+ move_qty_to_reserve = move and move.product_qty or 0
+
+ # Unreserve the quantity removed from initial `stock.move.line` and
+ # not assigned to a move anymore. In case of a split smaller than initial
+ # quantity and fully reserved
+ if quantity:
+ self.env['stock.quant']._update_reserved_quantity(
+ move_line.product_id, move_line.location_id, -quantity,
+ lot_id=move_line.lot_id, package_id=move_line.package_id,
+ owner_id=move_line.owner_id, strict=True)
+
+ if move and move_qty_to_reserve != move.product_qty:
+ partially_assigned_moves.add(move.id)
+
+ move_lines_to_unlink.update(initial_move.move_line_ids.filtered(
+ lambda ml: not ml.reserved_uom_qty and not ml.qty_done).ids)
+
+ self.env['stock.move'].browse(assigned_moves).write({'state': 'assigned'})
+ self.env['stock.move'].browse(partially_assigned_moves).write({'state': 'partially_available'})
+ # Avoid triggering a useless _recompute_state
+ self.env['stock.move.line'].browse(move_lines_to_unlink).write({'move_id': False})
+ self.env['stock.move.line'].browse(move_lines_to_unlink).unlink()
+ self.env['stock.move.line'].create(move_lines_vals)
+
+ # We need to adapt `duration_expected` on both the original workorders and their
+ # backordered workorders. To do that, we use the original `duration_expected` and the
+ # ratio of the quantity produced and the quantity to produce.
+ for production in self:
+ initial_qty = initial_qty_by_production[production]
+ initial_workorder_remaining_qty = []
+ bo = production_to_backorders[production]
+
+ # Adapt duration
+ for workorder in (production | bo).workorder_ids:
+ workorder.duration_expected = workorder.duration_expected * workorder.production_id.product_qty / initial_qty
+
+ # Adapt quantities produced
for workorder in production.workorder_ids:
- workorder.duration_expected = workorder.duration_expected * ratio
- for workorder in backorder_mo.workorder_ids:
- workorder.duration_expected = workorder.duration_expected * (1 - ratio)
+ initial_workorder_remaining_qty.append(max(workorder.qty_produced - workorder.qty_production, 0))
+ workorder.qty_produced = min(workorder.qty_produced, workorder.qty_production)
+ workorders_len = len(bo.workorder_ids)
+ for index, workorder in enumerate(bo.workorder_ids):
+ remaining_qty = initial_workorder_remaining_qty[index // workorders_len]
+ if remaining_qty:
+ workorder.qty_produced = max(workorder.qty_production, remaining_qty)
+ initial_workorder_remaining_qty[index % workorders_len] = max(remaining_qty - workorder.qty_produced, 0)
- # As we have split the moves before validating them, we need to 'remove' the excess reservation
- if not close_mo:
- self.move_raw_ids.filtered(lambda m: not m.additional)._do_unreserve()
- self.move_raw_ids.filtered(lambda m: not m.additional)._action_assign()
- backorders.action_confirm()
- backorders.action_assign()
+ backorders.workorder_ids._action_confirm()
- # Remove the serial move line without reserved quantity. Post inventory will assigned all the non done moves
- # So those move lines are duplicated.
- backorders.move_raw_ids.move_line_ids.filtered(lambda ml: ml.product_id.tracking == 'serial' and ml.reserved_qty == 0).unlink()
-
- for old_wo, wo in zip(self.workorder_ids, backorders.workorder_ids):
- wo.qty_produced = max(old_wo.qty_produced - old_wo.qty_producing, 0)
- if wo.product_tracking == 'serial':
- wo.qty_producing = 1
- else:
- wo.qty_producing = wo.qty_remaining
-
- return backorders
+ return self.env['mrp.production'].browse(production_ids)
def button_mark_done(self):
self._button_mark_done_sanity_checks()
@@ -1540,15 +1663,15 @@ class MrpProduction(models.Model):
if self.env.context.get('mo_ids_to_backorder'):
productions_to_backorder = self.browse(self.env.context['mo_ids_to_backorder'])
productions_not_to_backorder = self - productions_to_backorder
- close_mo = False
else:
productions_not_to_backorder = self
productions_to_backorder = self.env['mrp.production']
- close_mo = True
self.workorder_ids.button_finish()
- backorders = productions_to_backorder._generate_backorder_productions(close_mo=close_mo)
+ backorders = productions_to_backorder and productions_to_backorder._split_productions()
+ backorders = backorders - productions_to_backorder
+
productions_not_to_backorder._post_inventory(cancel_backorder=True)
productions_to_backorder._post_inventory(cancel_backorder=True)
@@ -1735,17 +1858,10 @@ class MrpProduction(models.Model):
return
if self.product_id.tracking != 'serial':
return
- dummy, dummy, missing_components, multiple_lot_components = self._check_serial_mass_produce_components()
- message = ""
- if missing_components:
- message += _("Make sure enough quantities of these components are reserved to carry on production:\n")
+ if self.reservation_state != 'assigned':
+ missing_components = {move.product_id for move in self.move_raw_ids if float_compare(move.reserved_availability, move.product_uom_qty, precision_rounding=move.product_uom.rounding) < 0}
+ message = _("Make sure enough quantities of these components are reserved to do the production:\n")
message += "\n".join(component.name for component in missing_components)
- if multiple_lot_components:
- if message:
- message += "\n"
- message += _("Component Lots must be unique for mass production. Please review consumption for:\n")
- message += "\n".join(component.name for component in multiple_lot_components)
- if message:
raise UserError(message)
next_serial = self.env['stock.lot']._get_next_serial(self.company_id, self.product_id)
action = self.env["ir.actions.actions"]._for_xml_id("mrp.act_assign_serial_numbers_production")
@@ -1870,99 +1986,3 @@ class MrpProduction(models.Model):
) and float_is_zero(production.qty_producing, precision_digits=pd):
immediate_productions |= production
return immediate_productions
-
- def _check_serial_mass_produce_components(self):
- have_serial_components = False
- have_lot_components = False
- missing_components = set()
- multiple_lot_components = set()
- for production in self:
- have_serial_components |= any(move.product_id.tracking == 'serial' for move in production.move_raw_ids)
- have_lot_components |= any(move.product_id.tracking == 'lot' for move in production.move_raw_ids)
- missing_components.update([move.product_id for move in production.move_raw_ids if float_compare(move.reserved_availability, move.product_uom_qty, precision_rounding=move.product_uom.rounding) < 0])
- components = {}
- for move in production.move_raw_ids:
- if move.product_id.tracking != 'lot':
- continue
- lot_ids = move.mapped('move_line_ids.lot_id.id')
- if not lot_ids:
- continue
- components.setdefault(move.product_id, set()).update(lot_ids)
- multiple_lot_components.update([p for p, l in components.items() if len(l) != 1])
- return (have_serial_components, have_lot_components, missing_components, multiple_lot_components)
-
- def _generate_backorder_productions_multi(self, serial_numbers, cancel_remaining_quantities=False):
- self.ensure_one()
- if self.product_id.tracking != 'serial':
- raise UserError(_('Expected product tracked by Serial Number.'))
- if self.backorder_sequence == 0:
- self.backorder_sequence = 1
- result = []
- production = self
- for serial_number in serial_numbers:
- production.qty_producing = 1
- if production.product_qty > 1 and (serial_number != serial_numbers[-1] or not cancel_remaining_quantities):
- backorder = production.copy(default=production._get_backorder_mo_vals())
- else:
- backorder = None
- new_moves_origin = []
- new_moves_quantity_to_split = []
- new_moves_vals = []
- for move in production.move_raw_ids | production.move_finished_ids:
- if not move.additional:
- uom_qty_to_split = move.product_uom_qty - move.unit_factor * production.qty_producing
- qty_to_split = move.product_uom._compute_quantity(uom_qty_to_split, move.product_id.uom_id, rounding_method='HALF-UP')
- uom_qty_to_split = float_round(uom_qty_to_split, precision_rounding=move.product_uom.rounding, rounding_method='HALF-UP')
- move_vals = move._split(qty_to_split)
- if backorder:
- if not move_vals:
- continue
- if move.raw_material_production_id:
- move_vals[0]['raw_material_production_id'] = backorder.id
- else:
- move_vals[0]['production_id'] = backorder.id
- new_moves_origin.append(move)
- new_moves_vals.append(move_vals[0])
- new_moves_quantity_to_split.append(uom_qty_to_split)
- elif move.raw_material_production_id:
- move.move_line_ids.reserved_uom_qty = 0
- move.move_line_ids[0].reserved_uom_qty = move.product_uom_qty
- if backorder:
- new_moves = self.env['stock.move'].create(new_moves_vals)
- for move, quantity_to_split, new_move in zip(new_moves_origin, new_moves_quantity_to_split, new_moves):
- if move.raw_material_production_id:
- move.move_line_ids[0].copy({
- 'move_id': new_move.id,
- 'reserved_uom_qty': quantity_to_split,
- 'lot_id': move.move_line_ids[0].lot_id.id if move.move_line_ids[0].lot_id else 0,
- })
- move.with_context(bypass_reservation_update=True).move_line_ids.reserved_uom_qty = 0
- move.with_context(bypass_reservation_update=True).move_line_ids[0].reserved_uom_qty = move.product_uom_qty
- for old_wo, wo in zip(production.workorder_ids, backorder.workorder_ids):
- wo.qty_produced = max(old_wo.qty_produced - old_wo.qty_producing, 0)
- wo.qty_producing = 1
- ratio = production.qty_producing / production.product_qty
- for workorder in production.workorder_ids:
- workorder.duration_expected = workorder.duration_expected * ratio
- for workorder in backorder.workorder_ids:
- workorder.duration_expected = workorder.duration_expected * (1 - ratio)
- backorder.action_confirm()
- production.name = self._get_name_backorder(production.name, production.backorder_sequence)
- production.product_qty = 1
- production.lot_producing_id = self.env['stock.lot'].create({
- 'product_id': production.product_id.id,
- 'company_id': production.company_id.id,
- 'name': serial_number,
- })
- qty_producing_uom = production.product_uom_id._compute_quantity(production.qty_producing, production.product_id.uom_id, rounding_method='HALF-UP')
- if qty_producing_uom != 1:
- production.qty_producing = production.product_id.uom_id._compute_quantity(1, production.product_uom_id, rounding_method='HALF-UP')
- for move in (production.move_raw_ids | production.move_finished_ids.filtered(lambda m: m.product_id != production.product_id)):
- if not move.product_uom:
- continue
- new_qty = float_round((production.qty_producing - production.qty_produced) * move.unit_factor, precision_rounding=move.product_uom.rounding)
- 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)
- result.append((production.lot_producing_id.id, production.id))
- production = backorder
- return result
diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py
index 6132f5fccec..5c8886211ec 100644
--- a/addons/mrp/models/stock_move.py
+++ b/addons/mrp/models/stock_move.py
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import api, fields, models
+from odoo import api, Command, fields, models
from odoo.osv import expression
from odoo.tools import float_compare, float_round, float_is_zero, OrderedSet
@@ -354,6 +354,15 @@ class StockMove(models.Model):
res.append('mrp_operation')
return res
+ def _get_backorder_move_vals(self):
+ self.ensure_one()
+ return {
+ 'state': 'confirmed',
+ 'reservation_date': self.reservation_date,
+ 'move_orig_ids': [Command.link(m.id) for m in self.mapped('move_orig_ids')],
+ 'move_dest_ids': [Command.link(m.id) for m in self.mapped('move_dest_ids')]
+ }
+
def _get_source_document(self):
res = super()._get_source_document()
return res or self.production_id or self.raw_material_production_id
diff --git a/addons/mrp/tests/__init__.py b/addons/mrp/tests/__init__.py
index 94136892072..36c31199f06 100644
--- a/addons/mrp/tests/__init__.py
+++ b/addons/mrp/tests/__init__.py
@@ -14,3 +14,4 @@ from . import test_traceability
from . import test_multicompany
from . import test_backorder
from . import test_smp
+from . import test_performance
diff --git a/addons/mrp/tests/test_order.py b/addons/mrp/tests/test_order.py
index 9a95051426b..ff6f90d38b0 100644
--- a/addons/mrp/tests/test_order.py
+++ b/addons/mrp/tests/test_order.py
@@ -2125,110 +2125,6 @@ class TestMrpOrder(TestMrpCommon):
self.assertEqual(len(move_finished_ids_3), 1, "There should only be 1 produced product for this MO")
self.assertEqual(move_finished_ids_3.product_id, variant_1, "Incorrect variant produced")
- def test_manufacturing_order_with_work_orders(self):
- """Test the behavior of a manufacturing order when opening the workorder related to it,
- as well as the behavior when a backorder is created
- """
- # create a few work centers
- work_center_1 = self.env['mrp.workcenter'].create({"name": "WC1"})
- work_center_2 = self.env['mrp.workcenter'].create({"name": "WC2"})
- work_center_3 = self.env['mrp.workcenter'].create({"name": "WC3"})
-
- # create a product, a bom related to it with 3 components and 3 operations
- product = self.env['product.template'].create({"name": "Product"})
- component_1 = self.env['product.template'].create({"name": "Component 1", "type": "product"})
- component_2 = self.env['product.template'].create({"name": "Component 2", "type": "product"})
- component_3 = self.env['product.template'].create({"name": "Component 3", "type": "product"})
-
- self.env['stock.quant'].create({
- "product_id": component_1.product_variant_id.id,
- "location_id": 8,
- "quantity": 100
- })
- self.env['stock.quant'].create({
- "product_id": component_2.product_variant_id.id,
- "location_id": 8,
- "quantity": 100
- })
- self.env['stock.quant'].create({
- "product_id": component_3.product_variant_id.id,
- "location_id": 8,
- "quantity": 100
- })
-
- self.env['mrp.bom'].create({
- "product_tmpl_id": product.id,
- "product_id": False,
- "product_qty": 1,
- "bom_line_ids": [
- [0, 0, {"product_id": component_1.product_variant_id.id, "product_qty": 1}],
- [0, 0, {"product_id": component_2.product_variant_id.id, "product_qty": 1}],
- [0, 0, {"product_id": component_3.product_variant_id.id, "product_qty": 1}]
- ],
- "operation_ids": [
- [0, 0, {"name": "Operation 1", "workcenter_id": work_center_1.id}],
- [0, 0, {"name": "Operation 2", "workcenter_id": work_center_2.id}],
- [0, 0, {"name": "Operation 3", "workcenter_id": work_center_3.id}]
- ]
- })
-
- # create a manufacturing order with 10 product to produce
- mo_form = Form(self.env['mrp.production'])
- mo_form.product_id = product.product_variant_id
- mo_form.product_qty = 10
- mo = mo_form.save()
-
- self.assertEqual(mo.state, 'draft')
- mo.action_confirm()
-
- wo_1 = mo.workorder_ids[0]
- wo_2 = mo.workorder_ids[1]
- wo_3 = mo.workorder_ids[2]
- self.assertEqual(mo.state, 'confirmed')
- self.assertEqual(wo_1.state, 'ready')
-
- wo_1.button_start()
- self.assertEqual(mo.state, 'progress')
- wo_1.button_finish()
-
- wo_2.button_start()
- wo_2.qty_producing = 8
- wo_2.button_finish()
-
- wo_3.button_start()
- wo_3.qty_producing = 8
- wo_3.button_finish()
-
- self.assertEqual(mo.state, 'to_close')
- mo.button_mark_done()
-
- bo = self.env['mrp.production.backorder'].create({
- "mrp_production_backorder_line_ids": [
- [0, 0, {"mrp_production_id": mo.id, "to_backorder": True}]
- ]
- })
- bo.action_backorder()
-
- self.assertEqual(mo.state, 'done')
-
- mo_2 = self.env['mrp.production'].browse(mo.id + 1)
- self.assertEqual(mo_2.state, 'progress')
- wo_4, wo_5, wo_6 = mo_2.workorder_ids
-
- self.assertEqual(wo_4.state, 'ready')
- wo_4.button_start()
- wo_4.button_finish()
-
- wo_5.button_start()
- self.assertEqual(mo_2.state, 'progress')
- wo_5.button_finish()
-
- wo_6.button_start()
- wo_6.button_finish()
- self.assertEqual(mo_2.state, 'to_close')
- mo_2.button_mark_done()
- self.assertEqual(mo_2.state, 'done')
-
def test_move_finished_onchanges(self):
""" Test that move_finished_ids (i.e. produced products) are still correct even after
multiple onchanges have changed the the moves
diff --git a/addons/mrp/tests/test_performance.py b/addons/mrp/tests/test_performance.py
new file mode 100644
index 00000000000..bfae4b485ee
--- /dev/null
+++ b/addons/mrp/tests/test_performance.py
@@ -0,0 +1,112 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import unittest
+import time
+import logging
+
+from odoo.tests import common, Form
+
+_logger = logging.getLogger(__name__)
+
+
+class TestMrpSerialMassProducePerformance(common.TransactionCase):
+
+ @unittest.skip
+ def test_smp_performance(self):
+
+ total_quantity = 1000
+ quantity = 1
+
+ raw_materials_count = 10
+ trackings = [
+ 'none',
+ # 'lot',
+ # 'serial'
+ ]
+
+ _logger.info('setting up environment')
+
+ raw_materials = []
+ for i in range(raw_materials_count):
+ raw_materials.append(self.env['product.product'].create({
+ 'name': '@raw_material#' + str(i + 1),
+ 'type': 'product',
+ 'tracking': trackings[i % len(trackings)]
+ }))
+ finished = self.env['product.product'].create({
+ 'name': '@finished',
+ 'type': 'product',
+ 'tracking': 'serial',
+ })
+ bom = self.env['mrp.bom'].create({
+ 'product_id': finished.id,
+ 'product_tmpl_id': finished.product_tmpl_id.id,
+ 'product_uom_id': finished.uom_id.id,
+ 'product_qty': 1.0,
+ 'type': 'normal',
+ 'consumption': 'flexible',
+ 'bom_line_ids': [(0, 0, {'product_id': p[0]['id'], 'product_qty': 1}) for p in raw_materials]
+ })
+
+ form = Form(self.env['mrp.production'])
+ form.product_id = finished
+ form.bom_id = bom
+ form.product_qty = total_quantity
+
+ mo = form.save()
+
+ mo.action_confirm()
+
+ for i in range(raw_materials_count):
+ if raw_materials[i].tracking == 'none':
+ self.env['stock.quant'].with_context(inventory_mode=True).create({
+ 'product_id': raw_materials[i].id,
+ 'inventory_quantity': total_quantity,
+ 'location_id': mo.location_src_id.id,
+ })._apply_inventory()
+ elif raw_materials[i].tracking == 'lot':
+ qty = total_quantity
+ while qty > 0:
+ lot = self.env['stock.lot'].create({
+ 'product_id': raw_materials[i].id,
+ 'company_id': self.env.company.id,
+ })
+ self.env['stock.quant'].with_context(inventory_mode=True).create({
+ 'product_id': raw_materials[i].id,
+ 'inventory_quantity': 10,
+ 'location_id': mo.location_src_id.id,
+ 'lot_id': lot.id,
+ })._apply_inventory()
+ qty -= 10
+ else:
+ for _ in range(total_quantity):
+ lot = self.env['stock.lot'].create({
+ 'product_id': raw_materials[i].id,
+ 'company_id': self.env.company.id,
+ })
+ self.env['stock.quant'].with_context(inventory_mode=True).create({
+ 'product_id': raw_materials[i].id,
+ 'inventory_quantity': 1,
+ 'location_id': mo.location_src_id.id,
+ 'lot_id': lot.id,
+ })._apply_inventory()
+
+ mo.action_assign()
+
+ action = mo.action_serial_mass_produce_wizard()
+ wizard = Form(self.env['stock.assign.serial'].with_context(**action['context']))
+ wizard.next_serial_number = "sn#1"
+ wizard.next_serial_count = quantity
+ action = wizard.save().generate_serial_numbers_production()
+ wizard = Form(self.env['stock.assign.serial'].browse(action['res_id']))
+ wizard = wizard.save()
+
+ _logger.info('generating serial numbers')
+ start = time.perf_counter()
+ if quantity == total_quantity:
+ wizard.apply()
+ else:
+ wizard.create_backorder()
+ end = time.perf_counter()
+ _logger.info('time to produce %s/%s: %s', quantity, total_quantity, end - start)
diff --git a/addons/mrp/tests/test_smp.py b/addons/mrp/tests/test_smp.py
index e027d4e79fc..4a1e2608600 100644
--- a/addons/mrp/tests/test_smp.py
+++ b/addons/mrp/tests/test_smp.py
@@ -3,7 +3,7 @@
from odoo.addons.mrp.tests.common import TestMrpCommon
from odoo.tests import Form
-from odoo.exceptions import UserError
+
class TestMrpSerialMassProduce(TestMrpCommon):
@@ -16,17 +16,9 @@ class TestMrpSerialMassProduce(TestMrpCommon):
res = mo.action_serial_mass_produce_wizard()
self.assertFalse(res)
- def test_smp_no_serial_component(self):
- """Create a MO for a product tracked by serial number with a component also tracked by serial number.
- An error should be throwed.
- """
- mo = self.generate_mo(tracking_final='serial', tracking_base_2='serial')[0]
- with self.assertRaises(UserError):
- mo.action_serial_mass_produce_wizard()
-
def test_smp_produce_all(self):
"""Create a MO for a product tracked by serial number.
- Open the smp wizard, generate all serial numbers to produce all quantitites.
+ Open the smp wizard, generate all serial numbers to produce all quantities.
"""
mo = self.generate_mo(tracking_final='serial')[0]
count = mo.product_qty
@@ -53,6 +45,8 @@ class TestMrpSerialMassProduce(TestMrpCommon):
self.assertEqual(mo.state, "to_close")
# Each generated serial number should have its own mo
self.assertEqual(len(mo.procurement_group_id.mrp_production_ids), count)
+ # Check generated serial numbers
+ self.assertEqual(mo.procurement_group_id.mrp_production_ids.lot_producing_id.mapped('name'), ["sn#1", "sn#2", "sn#3", "sn#4", "sn#5"])
def test_smp_produce_all_but_one(self):
"""Create a MO for a product tracked by serial number.
@@ -80,3 +74,51 @@ class TestMrpSerialMassProduce(TestMrpCommon):
bo = mo.procurement_group_id.mrp_production_ids[-1]
self.assertEqual(bo.backorder_sequence, count)
self.assertEqual(bo.state, "confirmed")
+
+ def test_smp_produce_complex(self):
+ """Create a MO for a product tracked by serial number
+ and with complex components (serial and multiple lots).
+ Open the smp wizard, generate all serial numbers to produce all quantities.
+ Check lot splitting.
+ """
+ mo, dummy, dummy, product_to_use_1, product_to_use_2 = self.generate_mo(tracking_final='serial', tracking_base_1='lot', tracking_base_2='serial', qty_final=3, qty_base_1=2, qty_base_2=1)
+ count = mo.product_qty
+ # Make some stock and reserve
+ for _ in range(2): # 2 lots of 3 to satisfy the need and check lot splitting
+ lot = self.env['stock.lot'].create({
+ 'product_id': product_to_use_1.id,
+ 'company_id': self.env.company.id,
+ })
+ self.env['stock.quant'].with_context(inventory_mode=True).create({
+ 'product_id': product_to_use_1.id,
+ 'inventory_quantity': 3,
+ 'location_id': mo.location_src_id.id,
+ 'lot_id': lot.id,
+ })._apply_inventory()
+ for _ in range(3): # 3 serial numbers
+ lot = self.env['stock.lot'].create({
+ 'product_id': product_to_use_2.id,
+ 'company_id': self.env.company.id,
+ })
+ self.env['stock.quant'].with_context(inventory_mode=True).create({
+ 'product_id': product_to_use_2.id,
+ 'inventory_quantity': 1,
+ 'location_id': mo.location_src_id.id,
+ 'lot_id': lot.id,
+ })._apply_inventory()
+ mo.action_assign()
+ # Open the wizard
+ action = mo.action_serial_mass_produce_wizard()
+ wizard = Form(self.env['stock.assign.serial'].with_context(**action['context']))
+ # Let the wizard generate all serial numbers
+ wizard.next_serial_number = "sn#1"
+ wizard.next_serial_count = count
+ action = wizard.save().generate_serial_numbers_production()
+ # Reload the wizard to apply generated serial numbers
+ wizard = Form(self.env['stock.assign.serial'].browse(action['res_id']))
+ wizard.save().apply()
+ # 1st & 3rd MO in sequence should have only 1 move lines (1 lot) for product_to_use_1 (2nd in bom)
+ self.assertEqual(mo.procurement_group_id.mrp_production_ids[0].move_raw_ids[1].move_lines_count, 1)
+ self.assertEqual(mo.procurement_group_id.mrp_production_ids[2].move_raw_ids[1].move_lines_count, 1)
+ # 2nd MO should have 2 move lines (2 different lots) for product_to_use_1
+ self.assertEqual(mo.procurement_group_id.mrp_production_ids[1].move_raw_ids[1].move_lines_count, 2)
diff --git a/addons/mrp/wizard/stock_assign_serial_numbers.py b/addons/mrp/wizard/stock_assign_serial_numbers.py
index 25e5268fa80..01219f5785a 100644
--- a/addons/mrp/wizard/stock_assign_serial_numbers.py
+++ b/addons/mrp/wizard/stock_assign_serial_numbers.py
@@ -58,11 +58,29 @@ class StockAssignSerialNumbers(models.TransientModel):
self.show_apply = self.produced_qty == self.expected_qty
self.show_backorders = self.produced_qty > 0 and self.produced_qty < self.expected_qty
+ def _assign_serial_numbers(self, cancel_remaining_quantity=False):
+ serial_numbers = self._get_serial_numbers()
+ productions = self.production_id._split_productions(
+ {self.production_id: [1] * len(serial_numbers)}, cancel_remaining_quantity)
+ production_lots_vals = []
+ for serial_name in serial_numbers:
+ production_lots_vals.append({
+ 'product_id': self.production_id.product_id.id,
+ 'company_id': self.production_id.company_id.id,
+ 'name': serial_name,
+ })
+ production_lots = self.env['stock.lot'].create(production_lots_vals)
+ for production, production_lot in zip(productions, production_lots):
+ production.lot_producing_id = production_lot.id
+ production.qty_producing = production.product_qty
+ for workorder in production.workorder_ids:
+ workorder.qty_produced = workorder.qty_producing
+
def apply(self):
- self.production_id._generate_backorder_productions_multi(self._get_serial_numbers())
+ self._assign_serial_numbers()
def create_backorder(self):
- self.production_id._generate_backorder_productions_multi(self._get_serial_numbers(), False)
+ self._assign_serial_numbers(False)
def no_backorder(self):
- self.production_id._generate_backorder_productions_multi(self._get_serial_numbers(), True)
+ self._assign_serial_numbers(True)
diff --git a/addons/mrp/wizard/stock_assign_serial_numbers.xml b/addons/mrp/wizard/stock_assign_serial_numbers.xml
index 9f013b5b55a..3b41658e2c9 100644
--- a/addons/mrp/wizard/stock_assign_serial_numbers.xml
+++ b/addons/mrp/wizard/stock_assign_serial_numbers.xml
@@ -34,7 +34,7 @@
+
You have entered less serial numbers than the quantity to produce.
Create a backorder if you expect to process the remaining quantities later.
Do not create a backorder if you will not process the remaining products.
diff --git a/addons/mrp_subcontracting/models/mrp_production.py b/addons/mrp_subcontracting/models/mrp_production.py
index 788a73e31d2..bb89352e90f 100644
--- a/addons/mrp_subcontracting/models/mrp_production.py
+++ b/addons/mrp_subcontracting/models/mrp_production.py
@@ -57,7 +57,7 @@ class MrpProduction(models.Model):
quantity_issues = self._get_quantity_produced_issues()
if quantity_issues:
- backorder = self._generate_backorder_productions(close_mo=False)
+ backorder = self._split_productions()[1:]
# No qty to consume to avoid propagate additional move
# TODO avoid : stock move created in backorder with 0 as qty
backorder.move_raw_ids.filtered(lambda m: m.additional).product_uom_qty = 0.0
diff --git a/addons/mrp_subcontracting/models/stock_picking.py b/addons/mrp_subcontracting/models/stock_picking.py
index ec63d949b17..f49a4b19ad1 100644
--- a/addons/mrp_subcontracting/models/stock_picking.py
+++ b/addons/mrp_subcontracting/models/stock_picking.py
@@ -35,7 +35,6 @@ class StockPicking(models.Model):
# -------------------------------------------------------------------------
def _action_done(self):
res = super(StockPicking, self)._action_done()
-
for move in self.move_ids.filtered(lambda move: move.is_subcontract):
# Auto set qty_producing/lot_producing_id of MO wasn't recorded
# manually (if the flexible + record_component or has tracked component)
@@ -62,7 +61,7 @@ class StockPicking(models.Model):
production._set_qty_producing()
production.subcontracting_has_been_recorded = True
if move_line != move.move_line_ids[-1]:
- backorder = production._generate_backorder_productions(close_mo=False)
+ backorder = production._split_productions()[1:]
# The move_dest_ids won't be set because the _split filter out done move
backorder.move_finished_ids.filtered(lambda mo: mo.product_id == move.product_id).move_dest_ids = production.move_finished_ids.filtered(lambda mo: mo.product_id == move.product_id).move_dest_ids
production.product_qty = production.qty_producing
diff --git a/addons/mrp_subcontracting/tests/test_subcontracting.py b/addons/mrp_subcontracting/tests/test_subcontracting.py
index 3ebbf60405c..27cf0e53a8c 100644
--- a/addons/mrp_subcontracting/tests/test_subcontracting.py
+++ b/addons/mrp_subcontracting/tests/test_subcontracting.py
@@ -435,7 +435,7 @@ class TestSubcontractingFlows(TestMrpSubcontractingCommon):
self.assertEqual(backorder_mo.qty_produced, 0)
backorder.move_ids.quantity_done = 2
backorder._action_done()
- self.assertTrue(picking_receipt.move_ids.move_orig_ids.production_id.state == 'done')
+ self.assertTrue(picking_receipt.move_ids.move_orig_ids[0].production_id.state == 'done')
def test_flow_9(self):
"""Ensure that cancel the subcontract moves will also delete the
diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py
index 380f1957eaf..f22276d68fd 100644
--- a/addons/stock/models/stock_move_line.py
+++ b/addons/stock/models/stock_move_line.py
@@ -464,7 +464,7 @@ class StockMoveLine(models.Model):
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
for ml in self:
# Unlinking a move line should unreserve.
- if ml.product_id.type == 'product' and not ml.move_id._should_bypass_reservation(ml.location_id) and not float_is_zero(ml.reserved_qty, precision_digits=precision):
+ if not float_is_zero(ml.reserved_qty, precision_digits=precision) and not ml.move_id._should_bypass_reservation(ml.location_id):
try:
self.env['stock.quant']._update_reserved_quantity(ml.product_id, ml.location_id, -ml.reserved_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
except UserError: