diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 930ce953948..f336f10293c 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import datetime +from collections import defaultdict from itertools import groupby from odoo import api, fields, models, _ @@ -202,6 +203,27 @@ class MrpProduction(models.Model): production_location_id = fields.Many2one('stock.location', "Production Location", related='product_id.property_stock_production', readonly=False) picking_ids = fields.Many2many('stock.picking', compute='_compute_picking_ids', string='Picking associated to this manufacturing order') delivery_count = fields.Integer(string='Delivery Orders', compute='_compute_picking_ids') + confirm_cancel = fields.Boolean(compute='_compute_confirm_cancel') + + @api.depends('move_raw_ids.state', 'move_finished_ids.state') + def _compute_confirm_cancel(self): + """ If the manufacturing order contains some done move (via an intermediate + post inventory), the user has to confirm the cancellation. + """ + domain = [ + ('state', '=', 'done'), + '|', + ('production_id', 'in', self.ids), + ('raw_material_production_id', 'in', self.ids) + ] + res = self.env['stock.move'].read_group(domain, ['state', 'production_id', 'raw_material_production_id'], ['production_id', 'raw_material_production_id'], lazy=False) + productions_with_done_move = {} + for rec in res: + production_record = rec['production_id'] or rec['raw_material_production_id'] + if production_record: + productions_with_done_move[production_record[0]] = True + for production in self: + production.confirm_cancel = productions_with_done_move.get(production.id, False) @api.depends('procurement_group_id') def _compute_picking_ids(self): @@ -770,33 +792,36 @@ class MrpProduction(models.Model): if not self.move_raw_ids: self.state = 'cancel' return True - if any(workorder.state == 'progress' for workorder in self.mapped('workorder_ids')): - raise UserError(_('You can not cancel production order, a work order is still in progress.')) - documents = {} + self._action_cancel() + return True + + def _action_cancel(self): + documents_by_production = {} for production in self: - for move_raw_id in production.move_raw_ids.filtered(lambda m: m.state not in ('done', 'cancel')): + documents = defaultdict(list) + for move_raw_id in self.move_raw_ids.filtered(lambda m: m.state not in ('done', 'cancel')): iterate_key = self._get_document_iterate_key(move_raw_id) if iterate_key: document = self.env['stock.picking']._log_activity_get_documents({move_raw_id: (move_raw_id.product_uom_qty, 0)}, iterate_key, 'UP') for key, value in document.items(): - if documents.get(key): - documents[key] += [value] - else: - documents[key] = [value] - production.workorder_ids.filtered(lambda x: x.state != 'cancel').action_cancel() - finish_moves = production.move_finished_ids.filtered(lambda x: x.state not in ('done', 'cancel')) - raw_moves = production.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel')) - (finish_moves | raw_moves)._action_cancel() - picking_ids = production.picking_ids.filtered(lambda x: x.state not in ('done', 'cancel')) - picking_ids.action_cancel() + documents[key] += [value] + if documents: + documents_by_production[production] = documents - if documents: + 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() + + for production, documents in documents_by_production.items(): filtered_documents = {} for (parent, responsible), rendering_context in documents.items(): - if not parent or parent._name == 'stock.picking' and parent.state == 'cancel' or parent == self: + if not parent or parent._name == 'stock.picking' and parent.state == 'cancel' or parent == production: continue filtered_documents[(parent, responsible)] = rendering_context - self._log_manufacture_exception(filtered_documents, cancel=True) + production._log_manufacture_exception(filtered_documents, cancel=True) return True def _get_document_iterate_key(self, move_raw_id): diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index 3289c9aa525..5c0de7462d4 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -134,12 +134,6 @@ class StockMove(models.Model): 'workorder_id': move.workorder_id.id,}) return res - def _action_cancel(self): - if any(move.quantity_done and (move.raw_material_production_id or move.production_id) for move in self): - raise exceptions.UserError(_('You cannot cancel a manufacturing order if you have already consumed material.\ - If you want to cancel this MO, please change the consumed quantities to 0.')) - return super(StockMove, self)._action_cancel() - def _action_confirm(self, merge=True, merge_into=False): moves = self.env['stock.move'] for move in self: diff --git a/addons/mrp/tests/__init__.py b/addons/mrp/tests/__init__.py index e1186006cee..9cc1a1ca874 100644 --- a/addons/mrp/tests/__init__.py +++ b/addons/mrp/tests/__init__.py @@ -2,6 +2,7 @@ from . import test_bom from . import test_byproduct +from . import test_cancel_mo from . import test_order from . import test_stock from . import test_warehouse_multistep_manufacturing diff --git a/addons/mrp/tests/test_cancel_mo.py b/addons/mrp/tests/test_cancel_mo.py new file mode 100644 index 00000000000..629f1c2d824 --- /dev/null +++ b/addons/mrp/tests/test_cancel_mo.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import Form +from datetime import datetime, timedelta + +from odoo.fields import Datetime as Dt +from odoo.exceptions import UserError +from odoo.addons.mrp.tests.common import TestMrpCommon + + +class TestMrpCancelMO(TestMrpCommon): + + def test_cancel_mo_without_routing_1(self): + """ Cancel a Manufacturing Order with no routing, no production. + """ + # Create MO + manufacturing_order = self.generate_mo()[0] + # Do nothing, cancel it + manufacturing_order.action_cancel() + # Check the MO and its moves are cancelled + self.assertEqual(manufacturing_order.state, 'cancel', "MO should be in cancel state.") + self.assertEqual(manufacturing_order.move_raw_ids[0].state, 'cancel', + "Cancelled MO raw moves must be cancelled as well.") + self.assertEqual(manufacturing_order.move_raw_ids[1].state, 'cancel', + "Cancelled MO raw moves must be cancelled as well.") + self.assertEqual(manufacturing_order.move_finished_ids.state, 'cancel', + "Cancelled MO finished move must be cancelled as well.") + + def test_cancel_mo_without_routing_2(self): + """ Cancel a Manufacturing Order with no routing but some productions. + """ + # Create MO + manufacturing_order = self.generate_mo()[0] + # Produce some quantity + produce_form = Form(self.env['mrp.product.produce'].with_context(active_id=manufacturing_order.id)) + produce_form.qty_producing = 2 + produce = produce_form.save() + produce.do_produce() + # Cancel it + manufacturing_order.action_cancel() + # Check it's cancelled + self.assertEqual(manufacturing_order.state, 'cancel', "MO should be in cancel state.") + self.assertEqual(manufacturing_order.move_raw_ids[0].state, 'cancel', + "Cancelled MO raw moves must be cancelled as well.") + self.assertEqual(manufacturing_order.move_raw_ids[1].state, 'cancel', + "Cancelled MO raw moves must be cancelled as well.") + self.assertEqual(manufacturing_order.move_finished_ids.state, 'cancel', + "Cancelled MO finished move must be cancelled as well.") + + def test_cancel_mo_without_routing_3(self): + """ Cancel a Manufacturing Order with no routing but some productions + after post inventory. + """ + # Create MO + manufacturing_order = self.generate_mo()[0] + # Produce some quantity (not all to avoid to done the MO when post inventory) + produce_form = Form(self.env['mrp.product.produce'].with_context(active_id=manufacturing_order.id)) + produce_form.qty_producing = 2 + produce = produce_form.save() + produce.do_produce() + # Post Inventory + manufacturing_order.post_inventory() + # Cancel the MO + manufacturing_order.action_cancel() + # Check MO is marked as done and its SML are done or cancelled + self.assertEqual(manufacturing_order.state, 'done', "MO should be in done state.") + self.assertEqual(manufacturing_order.move_raw_ids[0].state, 'done', + "Due to 'post_inventory', some move raw must stay in done state") + self.assertEqual(manufacturing_order.move_raw_ids[1].state, 'done', + "Due to 'post_inventory', some move raw must stay in done state") + self.assertEqual(manufacturing_order.move_raw_ids[2].state, 'cancel', + "The other move raw are cancelled like their MO.") + self.assertEqual(manufacturing_order.move_raw_ids[3].state, 'cancel', + "The other move raw are cancelled like their MO.") + self.assertEqual(manufacturing_order.move_finished_ids[0].state, 'done', + "Due to 'post_inventory', a move finished must stay in done state") + self.assertEqual(manufacturing_order.move_finished_ids[1].state, 'cancel', + "The other move finished is cancelled like its MO.") + + def test_cancel_mo_with_routing(self): + """ Cancel a Manufacturing Order with routing (so generate a Work Order) + and produce some quantities. When cancelled, the MO must be marked as + done and the WO must be cancelled. + """ + # Create MO + mo_data = self.generate_mo() + manufacturing_order = mo_data[0] + bom = mo_data[1] + bom.routing_id = self.routing_1 + + manufacturing_order.button_plan() + workorder = manufacturing_order.workorder_ids + # Produce some quantity + workorder.button_start() + workorder.qty_producing = 2 + workorder._apply_update_workorder_lines() + workorder.record_production() + # Post Inventory + manufacturing_order.post_inventory() + # Cancel it + manufacturing_order.action_cancel() + # Check MO is done, WO is cancelled and its SML are done or cancelled + self.assertEqual(manufacturing_order.state, 'done', "MO should be in done state.") + self.assertEqual(workorder.state, 'cancel', "WO should be cancelled.") + self.assertEqual(manufacturing_order.move_raw_ids[0].state, 'done', + "Due to 'post_inventory', some move raw must stay in done state") + self.assertEqual(manufacturing_order.move_raw_ids[1].state, 'done', + "Due to 'post_inventory', some move raw must stay in done state") + self.assertEqual(manufacturing_order.move_raw_ids[2].state, 'cancel', + "The other move raw are cancelled like their MO.") + self.assertEqual(manufacturing_order.move_raw_ids[3].state, 'cancel', + "The other move raw are cancelled like their MO.") + self.assertEqual(manufacturing_order.move_finished_ids[0].state, 'done', + "Due to 'post_inventory', a move finished must stay in done state") + self.assertEqual(manufacturing_order.move_finished_ids[1].state, 'cancel', + "The other move finished is cancelled like its MO.") diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml index 2324f76389d..c7858c2f2ba 100644 --- a/addons/mrp/views/mrp_production_views.xml +++ b/addons/mrp/views/mrp_production_views.xml @@ -41,6 +41,7 @@
+