[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:
JF Aubert
2021-12-22 17:55:49 +00:00
committed by Arnold Moyaux
parent ea0e9ec0aa
commit f11fcf6bb4
12 changed files with 391 additions and 294 deletions
+190 -170
View File
@@ -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
+10 -1
View File
@@ -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
+1
View File
@@ -14,3 +14,4 @@ from . import test_traceability
from . import test_multicompany
from . import test_backorder
from . import test_smp
from . import test_performance
-104
View File
@@ -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
+112
View File
@@ -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)
+52 -10
View File
@@ -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
+1 -1
View File
@@ -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: