From e3d66913502ce3bf011cdc2a129d1513bb6e6c9c Mon Sep 17 00:00:00 2001 From: diagnoza Date: Tue, 4 Jan 2022 12:51:32 +0000 Subject: [PATCH] [FIX] mrp: backport performance fix closes odoo/odoo#82215 Signed-off-by: Arnold Moyaux --- addons/mrp/i18n/mrp.pot | 6 + addons/mrp/models/mrp_production.py | 268 +++++++++++++----- addons/mrp/models/stock_move.py | 11 +- .../mrp/wizard/stock_assign_serial_numbers.py | 24 +- 4 files changed, 228 insertions(+), 81 deletions(-) diff --git a/addons/mrp/i18n/mrp.pot b/addons/mrp/i18n/mrp.pot index 32a0f81016500..9cb815e098a0c 100644 --- a/addons/mrp/i18n/mrp.pot +++ b/addons/mrp/i18n/mrp.pot @@ -5388,6 +5388,12 @@ msgstr "" msgid "You cannot move a manufacturing order once it is cancelled or done." msgstr "" +#. module: mrp +#: code:addons/mrp/models/mrp_production.py:0 +#, python-format +msgid "Unable to split with more than the quantity to produce." +msgstr "" + #. module: mrp #: code:addons/mrp/models/mrp_workorder.py:0 #, python-format diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index bcf41eb3740bc..3a75c685325f4 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -5,6 +5,7 @@ import datetime import math import re +import warnings from collections import defaultdict from dateutil.relativedelta import relativedelta @@ -12,13 +13,12 @@ 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 SIZE_BACK_ORDER_NUMERING = 3 - class MrpProduction(models.Model): """ Manufacturing Orders """ _name = 'mrp.production' @@ -1523,9 +1523,195 @@ def _generate_backorder_productions(self, close_mo=True): wo.qty_producing = 1 else: wo.qty_producing = wo.qty_remaining - return backorders + 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) + production.product_qty = amounts[production][0] + backorder_vals = production.copy_data(default=production._get_backorder_mo_vals())[0] + backorder_qtys = amounts[production][1:] + + 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, + state='confirmed' + )) + + 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.product_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({'product_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).product_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, + product_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.product_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: + 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) + backorders.workorder_ids._action_confirm() + + return self.env['mrp.production'].browse(production_ids) + def button_mark_done(self): self._button_mark_done_sanity_checks() @@ -1896,77 +2082,5 @@ def _check_serial_mass_produce_components(self): 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.product_uom_qty = 0 - move.move_line_ids[0].product_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, - 'product_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.product_uom_qty = 0 - move.with_context(bypass_reservation_update=True).move_line_ids[0].product_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.production.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 + warnings.warn("Method '_generate_backorder_productions_multi()' is deprecated, use _split_productions() instead.", DeprecationWarning) + self._split_productions({self: [1] * len(serial_numbers)}, cancel_remaining_quantities) diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index c4371e0f25e18..3429da54c4d19 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 @@ def _consuming_picking_types(self): 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/wizard/stock_assign_serial_numbers.py b/addons/mrp/wizard/stock_assign_serial_numbers.py index 5ac8277fe2c39..08bbe86933511 100644 --- a/addons/mrp/wizard/stock_assign_serial_numbers.py +++ b/addons/mrp/wizard/stock_assign_serial_numbers.py @@ -57,12 +57,30 @@ def _onchange_serial_numbers(self): self.produced_qty = len(serial_numbers) 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.production.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)