[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
This commit is contained in:
+190
-170
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,3 +14,4 @@ from . import test_traceability
|
||||
from . import test_multicompany
|
||||
from . import test_backorder
|
||||
from . import test_smp
|
||||
from . import test_performance
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<group>
|
||||
<field name="expected_qty" readonly="True"/>
|
||||
</group>
|
||||
<p class="o_form_label oe_inline" attrs="{'invisible': [('show_backorders', '=', False)]}">
|
||||
<p col="1" class="o_form_label oe_inline" attrs="{'invisible': [('show_backorders', '=', False)]}">
|
||||
You have entered less serial numbers than the quantity to produce.<br/>
|
||||
Create a backorder if you expect to process the remaining quantities later.<br/>
|
||||
Do not create a backorder if you will not process the remaining products.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user