From f11fcf6bb403e3b830c37e5c9b4c2fcda9452a57 Mon Sep 17 00:00:00 2001 From: JF Aubert Date: Thu, 2 Dec 2021 11:00:17 +0000 Subject: [PATCH] [REF] mrp: split productions There is 2 major issues with the production of multiple serials number - Performance issue - Duplicated code with classic backorder mechanism The performance issues exist since backorders were create one by one and `stock.move` and `stock.move.line` are always recomputed. They are not created in batch neither. _generate_backorders is removed and replace by _split_productions. The functionality are the same. Technicaly it does the maximum in batch, first it creates all the `mrp.production` then all the `stock.move` and finaly, it splits the existing `stock.move.line` among the new `stock.move` It means that the reservation is not recompute anymore during a backorder process and will remain the same than the splitted production order. Performance metrics (10 components): | 2 | 10 100 1000 before | 0.47s | 2.84s | 32.25s | 580.53s ----------------------------------------------- after | 0.13s | 0.36s | 2.60s | 35.17s task 2633369 Part-of: odoo/odoo#77254 --- addons/mrp/models/mrp_production.py | 360 +++++++++--------- addons/mrp/models/stock_move.py | 11 +- addons/mrp/tests/__init__.py | 1 + addons/mrp/tests/test_order.py | 104 ----- addons/mrp/tests/test_performance.py | 112 ++++++ addons/mrp/tests/test_smp.py | 62 ++- .../mrp/wizard/stock_assign_serial_numbers.py | 24 +- .../wizard/stock_assign_serial_numbers.xml | 2 +- .../models/mrp_production.py | 2 +- .../models/stock_picking.py | 3 +- .../tests/test_subcontracting.py | 2 +- addons/stock/models/stock_move_line.py | 2 +- 12 files changed, 391 insertions(+), 294 deletions(-) create mode 100644 addons/mrp/tests/test_performance.py 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: