[IMP] mrp : split / merge manufacturing orders

Split a manufacturing order in several parts
using the back order mechanism

Merge several manufacturing orders related to the same product/bom
by cancelling all of them and creating a new one

closes odoo/odoo#82427

Task: 2662566
Signed-off-by: Arnold Moyaux <arm@odoo.com>
This commit is contained in:
JF Aubert
2022-02-01 18:06:50 +00:00
committed by Arnold Moyaux
parent a08a0b6454
commit 6acfb2e630
9 changed files with 338 additions and 10 deletions
+1
View File
@@ -24,6 +24,7 @@
'wizard/mrp_consumption_warning_views.xml',
'wizard/mrp_immediate_production_views.xml',
'wizard/stock_assign_serial_numbers.xml',
'wizard/mrp_production_split.xml',
'views/mrp_views_menus.xml',
'views/stock_move_views.xml',
'views/mrp_workorder_views.xml',
+98 -5
View File
@@ -10,7 +10,7 @@ from ast import literal_eval
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo import api, fields, models, _, Command
from odoo.exceptions import UserError, ValidationError
from odoo.tools import float_compare, float_round, float_is_zero, format_datetime
from odoo.tools.misc import OrderedSet, format_date
@@ -381,7 +381,7 @@ class MrpProduction(models.Model):
for production in self:
production.confirm_cancel = productions_with_done_move.get(production.id, False)
@api.depends('procurement_group_id')
@api.depends('procurement_group_id', 'procurement_group_id.stock_move_ids.group_id')
def _compute_picking_ids(self):
for order in self:
order.picking_ids = self.env['stock.picking'].search([
@@ -539,7 +539,7 @@ class MrpProduction(models.Model):
and order.state not in {'cancel', 'draft'}
)
@api.depends('state','move_raw_ids')
@api.depends('state', 'move_raw_ids')
def _compute_show_lot_ids(self):
for order in self:
order.show_lot_ids = order.state != 'draft' and any(m.product_id.tracking == 'serial' for m in order.move_raw_ids)
@@ -1406,7 +1406,6 @@ class MrpProduction(models.Model):
self.workorder_ids.filtered(lambda x: x.state not in ['done', 'cancel']).action_cancel()
finish_moves = self.move_finished_ids.filtered(lambda x: x.state not in ('done', 'cancel'))
raw_moves = self.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel'))
(finish_moves | raw_moves)._action_cancel()
picking_ids = self.picking_ids.filtered(lambda x: x.state not in ('done', 'cancel'))
picking_ids.action_cancel()
@@ -1482,7 +1481,7 @@ class MrpProduction(models.Model):
'move_finished_ids': None,
'lot_producing_id': False,
'origin': self.origin,
'state': 'confirmed',
'state': 'draft' if self.state == 'draft' else 'confirmed',
'date_deadline': self.date_deadline,
'orderpoint_id': self.orderpoint_id.id,
}
@@ -1928,6 +1927,73 @@ class MrpProduction(models.Model):
}
return action
def action_split(self):
self._pre_action_split_merge_hook(split=True)
if len(self) > 1:
productions = [Command.create({'production_id': production.id}) for production in self]
# Wizard need a real id to have buttons enable in the view
wizard = self.env['mrp.production.split.multi'].create({'production_ids': productions})
action = self.env['ir.actions.actions']._for_xml_id('mrp.action_mrp_production_split_multi')
action['res_id'] = wizard.id
return action
else:
action = self.env['ir.actions.actions']._for_xml_id('mrp.action_mrp_production_split')
action['context'] = {
'default_production_id': self.id,
}
return action
def action_merge(self):
self._pre_action_split_merge_hook(merge=True)
products = set([(production.product_id, production.bom_id) for production in self])
product_id, bom_id = products.pop()
users = set([production.user_id for production in self])
if len(users) == 1:
user_id = users.pop()
else:
user_id = self.env.user
origs = {}
for move in self.move_raw_ids:
origs.setdefault(move.bom_line_id.id, []).extend(move.move_orig_ids.ids)
dests = {}
for move in self.move_finished_ids:
dests.setdefault(move.byproduct_id.id, []).extend(move.move_dest_ids.ids)
production = self.env['mrp.production'].create({
'product_id': product_id.id,
'bom_id': bom_id.id,
'picking_type_id': bom_id.picking_type_id or self._get_default_picking_type(),
'product_qty': sum(production.product_uom_qty for production in self),
'product_uom_id': product_id.uom_id.id,
'user_id': user_id.id,
'origin': ",".join(sorted([production.name for production in self])),
})
self.env['stock.move'].create(production._get_moves_raw_values())
self.env['stock.move'].create(production._get_moves_finished_values())
production._create_workorder()
for move in production.move_raw_ids:
move.move_orig_ids = [Command.set(origs[move.bom_line_id.id])]
for move in production.move_finished_ids:
move.move_dest_ids = [Command.set(dests[move.byproduct_id.id])]
production.move_dest_ids = [Command.set(sum(list(dests.values()), []))]
self.procurement_group_id.stock_move_ids.group_id = production.procurement_group_id
if 'confirmed' in self.mapped('state'):
production.action_confirm()
self.with_context(skip_activity=True)._action_cancel()
return {
'type': 'ir.actions.act_window',
'res_model': 'mrp.production',
'view_mode': 'form',
'res_id': production.id,
'target': 'main',
}
@api.model
def _prepare_procurement_group_vals(self, values):
return {'name': values['name']}
@@ -2033,3 +2099,30 @@ class MrpProduction(models.Model):
) and float_is_zero(production.qty_producing, precision_digits=pd):
immediate_productions |= production
return immediate_productions
def _pre_action_split_merge_hook(self, merge=False, split=False):
if not merge and not split:
return True
ope_str = merge and 'merge' or 'split'
if any(production.state not in ('draft', 'confirmed') for production in self):
raise UserError(_("Only manufacturing orders in either a draft or confirmed state can be %s.", ope_str))
if any(not production.bom_id for production in self):
raise UserError(_("Only manufacturing orders with a Bill of Materials can be %s.", ope_str))
if split:
return True
if len(self) < 2:
raise UserError(_("You need at least two production orders to merge them."))
products = set([(production.product_id, production.bom_id) for production in self])
if len(products) > 1:
raise UserError(_('You can only merge manufacturing orders of identical products with same BoM.'))
additional_raw_ids = self.mapped("move_raw_ids").filtered(lambda move: not move.bom_line_id)
additional_byproduct_ids = self.mapped('move_byproduct_ids').filtered(lambda move: not move.byproduct_id)
if additional_raw_ids or additional_byproduct_ids:
raise UserError(_("You can only merge manufacturing orders with no additional components or by-products."))
if len(set(self.mapped('state'))) > 1:
raise UserError(_("You can only merge manufacturing with the same state."))
if len(set(self.mapped('picking_type_id'))) > 1:
raise UserError(_('You can only merge manufacturing with the same operation type'))
# TODO explode and check no quantity has been edited
return True
+3
View File
@@ -58,3 +58,6 @@ access_mrp_immediate_production,access.mrp.immediate.production,model_mrp_immedi
access_mrp_immediate_production_line,access.mrp.immediate.production.line,model_mrp_immediate_production_line,mrp.group_mrp_user,1,1,1,0
access_mrp_workcenter_tag_group_user,access.mrp.workcenter.tag,model_mrp_workcenter_tag,mrp.group_mrp_user,1,0,0,0
access_mrp_workcenter_tag_manager,access.mrp.workcenter.tag,model_mrp_workcenter_tag,mrp.group_mrp_manager,1,1,1,1
access_mrp_production_split_multi,access.mrp.production.split.multi,model_mrp_production_split_multi,mrp.group_mrp_user,1,1,1,0
access_mrp_production_split,access.mrp.production.split,model_mrp_production_split,mrp.group_mrp_user,1,1,1,0
access_mrp_production_split_line,access.mrp.production.split.line,model_mrp_production_split_line,mrp.group_mrp_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
58 access_mrp_immediate_production_line access.mrp.immediate.production.line model_mrp_immediate_production_line mrp.group_mrp_user 1 1 1 0
59 access_mrp_workcenter_tag_group_user access.mrp.workcenter.tag model_mrp_workcenter_tag mrp.group_mrp_user 1 0 0 0
60 access_mrp_workcenter_tag_manager access.mrp.workcenter.tag model_mrp_workcenter_tag mrp.group_mrp_manager 1 1 1 1
61 access_mrp_production_split_multi access.mrp.production.split.multi model_mrp_production_split_multi mrp.group_mrp_user 1 1 1 0
62 access_mrp_production_split access.mrp.production.split model_mrp_production_split mrp.group_mrp_user 1 1 1 0
63 access_mrp_production_split_line access.mrp.production.split.line model_mrp_production_split_line mrp.group_mrp_user 1 1 1 1
+35
View File
@@ -356,6 +356,41 @@ class TestMrpProductionBackorder(TestMrpCommon):
self.assertEqual(production.name.split('-')[0], backorder_ids.name.split('-')[0])
self.assertEqual(int(production.name.split('-')[1]) + 1, int(backorder_ids.name.split('-')[1]))
def test_split_merge(self):
# Change 'Units' rounding to 1 (integer only quantities)
self.uom_unit.rounding = 1
# Create a mo for 10 products
mo, _, _, p1, p2 = self.generate_mo(qty_final=10)
# Split in 3 parts
action = mo.action_split()
wizard = Form(self.env[action['res_model']].with_context(action['context']))
wizard.counter = 3
action = wizard.save().action_split()
# Should have 3 mos
self.assertEqual(len(mo.procurement_group_id.mrp_production_ids), 3)
mo1 = mo.procurement_group_id.mrp_production_ids[0]
mo2 = mo.procurement_group_id.mrp_production_ids[1]
mo3 = mo.procurement_group_id.mrp_production_ids[2]
# Check quantities
self.assertEqual(mo1.product_qty, 3)
self.assertEqual(mo2.product_qty, 3)
self.assertEqual(mo3.product_qty, 4)
# Check raw movew quantities
self.assertEqual(mo1.move_raw_ids.filtered(lambda m: m.product_id == p1).product_qty, 12)
self.assertEqual(mo2.move_raw_ids.filtered(lambda m: m.product_id == p1).product_qty, 12)
self.assertEqual(mo3.move_raw_ids.filtered(lambda m: m.product_id == p1).product_qty, 16)
self.assertEqual(mo1.move_raw_ids.filtered(lambda m: m.product_id == p2).product_qty, 3)
self.assertEqual(mo2.move_raw_ids.filtered(lambda m: m.product_id == p2).product_qty, 3)
self.assertEqual(mo3.move_raw_ids.filtered(lambda m: m.product_id == p2).product_qty, 4)
# Merge them back
expected_origin = ",".join([mo1.name, mo2.name, mo3.name])
action = (mo1 + mo2 + mo3).action_merge()
mo = self.env[action['res_model']].browse(action['res_id'])
# Check origin & initial quantity
self.assertEqual(mo.origin, expected_origin)
self.assertEqual(mo.product_qty, 10)
class TestMrpWorkorderBackorder(TransactionCase):
@classmethod
+12 -4
View File
@@ -47,14 +47,22 @@
</field>
</record>
<record id="production_order_server_action" model="ir.actions.server">
<field name="name">Mrp: Plan Production Orders</field>
<record id="action_production_order_split" model="ir.actions.server">
<field name="name">Split</field>
<field name="model_id" ref="mrp.model_mrp_production"/>
<field name="binding_model_id" ref="mrp.model_mrp_production"/>
<field name="binding_view_types">list,form</field>
<field name="state">code</field>
<field name="code">action = records.action_split()</field>
</record>
<record id="action_production_order_merge" model="ir.actions.server">
<field name="name">Merge</field>
<field name="model_id" ref="mrp.model_mrp_production"/>
<field name="binding_model_id" ref="mrp.model_mrp_production"/>
<field name="binding_view_types">list</field>
<field name="groups_id" eval="[(4, ref('mrp.group_mrp_routings'))]"/>
<field name="state">code</field>
<field name="code">records.button_plan()</field>
<field name="code">action = records.action_merge()</field>
</record>
<record id="action_production_order_mark_done" model="ir.actions.server">
+1
View File
@@ -7,3 +7,4 @@ from . import mrp_production_backorder
from . import mrp_consumption_warning
from . import mrp_immediate_production
from . import stock_assign_serial_numbers
from . import mrp_production_split
+102
View File
@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, Command
from odoo.tools import float_round, float_compare
class MrpProductionSplitMulti(models.TransientModel):
_name = 'mrp.production.split.multi'
_description = "Wizard to Split Multiple Productions"
production_ids = fields.One2many('mrp.production.split', 'production_split_multi_id', 'Productions To Split')
class MrpProductionSplit(models.TransientModel):
_name = 'mrp.production.split'
_description = "Wizard to Split a Production"
production_split_multi_id = fields.Many2one('mrp.production.split.multi', 'Split Productions')
production_id = fields.Many2one('mrp.production', 'Manufacturing Order', readonly=True)
product_id = fields.Many2one(related='production_id.product_id')
product_qty = fields.Float(related='production_id.product_qty')
product_uom_id = fields.Many2one(related='production_id.product_uom_id')
counter = fields.Integer(
"Split #", default=0, compute="_compute_counter",
store=True, readonly=False)
production_detailed_vals_ids = fields.One2many(
'mrp.production.split.line', 'mrp_production_split_id',
'Split Details', compute="_compute_details", store=True, readonly=False)
valid_details = fields.Boolean("Valid", compute="_compute_valid_details")
@api.depends('production_detailed_vals_ids')
def _compute_counter(self):
for wizard in self:
wizard.counter = len(wizard.production_detailed_vals_ids)
@api.depends('counter')
def _compute_details(self):
for wizard in self:
commands = [Command.clear()]
if wizard.counter < 1 or not wizard.production_id:
wizard.production_detailed_vals_ids = commands
continue
quantity = float_round(wizard.product_qty / wizard.counter, precision_rounding=wizard.product_uom_id.rounding)
remaining_quantity = wizard.product_qty
for _ in range(wizard.counter - 1):
commands.append(Command.create({
'quantity': quantity,
'user_id': wizard.production_id.user_id,
'date': wizard.production_id.date_planned_start,
}))
remaining_quantity = float_round(remaining_quantity - quantity, precision_rounding=wizard.product_uom_id.rounding)
commands.append(Command.create({
'quantity': remaining_quantity,
'user_id': wizard.production_id.user_id,
'date': wizard.production_id.date_planned_start,
}))
wizard.production_detailed_vals_ids = commands
@api.depends('production_detailed_vals_ids')
def _compute_valid_details(self):
self.valid_details = False
for wizard in self:
if wizard.production_detailed_vals_ids:
wizard.valid_details = float_compare(wizard.product_qty, sum(wizard.production_detailed_vals_ids.mapped('quantity')), precision_rounding=wizard.product_uom_id.rounding) == 0
def action_split(self):
productions = self.production_id._split_productions({self.production_id: [detail.quantity for detail in self.production_detailed_vals_ids]})
for production, detail in zip(productions, self.production_detailed_vals_ids):
production.user_id = detail.user_id
production.date_planned_start = detail.date
if self.production_split_multi_id:
saved_production_split_multi_id = self.production_split_multi_id.id
self.production_split_multi_id.production_ids = [Command.unlink(self.id)]
action = self.env['ir.actions.actions']._for_xml_id('mrp.action_mrp_production_split_multi')
action['res_id'] = saved_production_split_multi_id
return action
def action_prepare_split(self):
action = self.env['ir.actions.actions']._for_xml_id('mrp.action_mrp_production_split')
action['res_id'] = self.id
return action
def action_return_to_list(self):
self.production_detailed_vals_ids = [Command.clear()]
self.counter = 0
action = self.env['ir.actions.actions']._for_xml_id('mrp.action_mrp_production_split_multi')
action['res_id'] = self.production_split_multi_id.id
return action
class MrpProductionSplitLine(models.TransientModel):
_name = 'mrp.production.split.line'
_description = "Split Production Detail"
mrp_production_split_id = fields.Many2one(
'mrp.production.split', 'Split Production', required=True, ondelete="cascade")
quantity = fields.Float('Quantity To Produce', digits='Product Unit of Measure', required=True)
user_id = fields.Many2one(
'res.users', 'Responsible', required=True,
domain=lambda self: [('groups_id', 'in', self.env.ref('mrp.group_mrp_user').id)])
date = fields.Datetime('Schedule Date')
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_mrp_production_split_multi_form" model="ir.ui.view">
<field name="name">mrp.production.split.multi.form</field>
<field name="model">mrp.production.split.multi</field>
<field name="type">form</field>
<field name="arch" type="xml">
<form string="Split Productions">
<field name="production_ids">
<tree create="0" editable="top">
<field name="production_id"/>
<field name="product_id"/>
<field name="product_qty"/>
<field name="product_uom_id"/>
<button name="action_prepare_split" type="object" icon="fa-scissors" width="0.1" title="Split Production"/>
</tree>
</field>
<footer>
<button string="Discard" class="btn-secondary" special="cancel" data-hotkey="z"/>
</footer>
</form>
</field>
</record>
<record id="view_mrp_production_split_form" model="ir.ui.view">
<field name="name">Split Production</field>
<field name="model">mrp.production.split</field>
<field name="arch" type="xml">
<form string="Split Production">
<group>
<group>
<field name="production_id" readonly="1"/>
</group>
<group>
<field name="product_id"/>
<field name="product_qty"/>
<field name="product_uom_id"/>
</group>
<group>
<field name="counter"/>
</group>
</group>
<field name="production_detailed_vals_ids" attrs="{'invisible': [('counter', '=', 0)]}">
<tree editable="top">
<field name="quantity"/>
<field name="user_id"/>
<field name="date"/>
</tree>
</field>
<field name="production_split_multi_id" invisible="1"/>
<field name="valid_details" invisible="1"/>
<footer>
<button string="Split" class="btn-primary" type="object" name="action_split" data-hotkey="q" attrs="{'invisible': [('valid_details', '=', False)]}"/>
<button string="Discard" class="btn-secondary" special="cancel" data-hotkey="z" attrs="{'invisible': [('production_split_multi_id', '!=', False)]}"/>
<button string="Discard" class="btn-secondary" type="object" name="action_return_to_list" data-hotkey="z" attrs="{'invisible': [('production_split_multi_id', '=', False)]}"/>
</footer>
</form>
</field>
</record>
<record id="action_mrp_production_split_multi" model="ir.actions.act_window">
<field name="name">Split productions</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">mrp.production.split.multi</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<record id="action_mrp_production_split" model="ir.actions.act_window">
<field name="name">Split production</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">mrp.production.split</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</data>
</odoo>
@@ -3,7 +3,7 @@
from collections import defaultdict
from odoo import fields, models, _, api
from odoo.exceptions import UserError
from odoo.exceptions import UserError, ValidationError
from odoo.tools.float_utils import float_compare, float_is_zero
@@ -35,6 +35,11 @@ class MrpProduction(models.Model):
production.move_raw_ids = [(0, 0, move)]
production.move_raw_ids.filtered(lambda m: m.product_id == product_id)[:1].move_line_ids = lines
def action_merge(self):
if any(production._get_subcontract_move() for production in self):
raise ValidationError(_("Subcontracted manufacturing orders cannot be merged."))
super().action_merge()
def subcontracting_record_component(self):
self.ensure_one()
if not self._get_subcontract_move():