[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:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user