diff --git a/addons/purchase_stock/models/purchase.py b/addons/purchase_stock/models/purchase.py index 077ab49487d..314c5655d2a 100644 --- a/addons/purchase_stock/models/purchase.py +++ b/addons/purchase_stock/models/purchase.py @@ -90,17 +90,19 @@ class PurchaseOrder(models.Model): @api.multi def button_cancel(self): for order in self: - for pick in order.picking_ids: - if pick.state == 'done': + for move in order.order_line.mapped('move_ids'): + if move.state == 'done': raise UserError(_('Unable to cancel purchase order %s as some receptions have already been done.') % (order.name)) # If the product is MTO, change the procure_method of the the closest move to purchase to MTS. # The purpose is to link the po that the user will manually generate to the existing moves's chain. - if order.state in ('draft', 'sent', 'to approve'): + if order.state in ('draft', 'sent', 'to approve', 'purchase'): for order_line in order.order_line: + order_line.move_ids._action_cancel() if order_line.move_dest_ids: - move_dest_ids = order_line.move_dest_ids.filtered(lambda m: m.state not in ('done', 'cancel')) - siblings_states = (move_dest_ids.mapped('move_orig_ids')).mapped('state') - if all(state in ('done', 'cancel') for state in siblings_states): + move_dest_ids = order_line.move_dest_ids + if order_line.propagate_cancel: + move_dest_ids._action_cancel() + else: move_dest_ids.write({'procure_method': 'make_to_stock'}) move_dest_ids._recompute_state() @@ -229,6 +231,7 @@ class PurchaseOrderLine(models.Model): move_dest_ids = fields.One2many('stock.move', 'created_purchase_line_id', 'Downstream Moves') propagate_date = fields.Boolean(string="Propagate Rescheduling", help='The rescheduling is propagated to the next move.') propagate_date_minimum_delta = fields.Integer(string='Reschedule if Higher Than', help='The change must be higher than this value to be propagated') + propagate_cancel = fields.Boolean('Propagate cancellation', default=True) @api.multi def _compute_qty_received_method(self): @@ -374,6 +377,7 @@ class PurchaseOrderLine(models.Model): 'propagate_date': self.propagate_date, 'propagate_date_minimum_delta': self.propagate_date_minimum_delta, 'description_picking': self.product_id._get_description(self.order_id.picking_type_id), + 'propagate_cancel': self.propagate_cancel, 'route_ids': self.order_id.picking_type_id.warehouse_id and [(6, 0, [x.id for x in self.order_id.picking_type_id.warehouse_id.route_ids])] or [], 'warehouse_id': self.order_id.picking_type_id.warehouse_id.id, } @@ -400,5 +404,5 @@ class PurchaseOrderLine(models.Model): args can be merged. If it returns an empty record then a new line will be created. """ - lines = self.filtered(lambda l: l.propagate_date == values['propagate_date'] and l.propagate_date_minimum_delta == values['propagate_date_minimum_delta']) + lines = self.filtered(lambda l: l.propagate_date == values['propagate_date'] and l.propagate_date_minimum_delta == values['propagate_date_minimum_delta'] and l.propagate_cancel == values['propagate_cancel']) return lines and lines[0] or self.env['purchase.order.line'] diff --git a/addons/purchase_stock/models/stock_rule.py b/addons/purchase_stock/models/stock_rule.py index 6a0f9bf518a..885e8990ad7 100644 --- a/addons/purchase_stock/models/stock_rule.py +++ b/addons/purchase_stock/models/stock_rule.py @@ -53,6 +53,7 @@ class StockRule(models.Model): procurement.values['supplier'] = supplier procurement.values['propagate_date'] = rule.propagate_date procurement.values['propagate_date_minimum_delta'] = rule.propagate_date_minimum_delta + procurement.values['propagate_cancel'] = rule.propagate_cancel domain = rule._make_po_get_domain(procurement.company_id, procurement.values, partner) procurements_by_po_domain[domain].append((procurement, rule)) @@ -117,11 +118,11 @@ class StockRule(models.Model): @api.model def _get_procurements_to_merge_groupby(self, procurement): - return procurement.product_id, procurement.product_uom, procurement.values['propagate_date'], procurement.values['propagate_date_minimum_delta'] + return procurement.product_id, procurement.product_uom, procurement.values['propagate_date'], procurement.values['propagate_date_minimum_delta'], procurement.values['propagate_cancel'] @api.model def _get_procurements_to_merge_sorted(self, procurement): - return procurement.product_id.id, procurement.product_uom.id, procurement.values['propagate_date'], procurement.values['propagate_date_minimum_delta'] + return procurement.product_id.id, procurement.product_uom.id, procurement.values['propagate_date'], procurement.values['propagate_date_minimum_delta'], procurement.values['propagate_cancel'] @api.model def _get_procurements_to_merge(self, procurements): @@ -232,6 +233,7 @@ class StockRule(models.Model): 'product_id': product_id.id, 'product_uom': product_id.uom_po_id.id, 'price_unit': price_unit, + 'propagate_cancel': values.get('propagate_cancel'), 'date_planned': date_planned, 'propagate_date': values['propagate_date'], 'propagate_date_minimum_delta': values['propagate_date_minimum_delta'], diff --git a/addons/purchase_stock/tests/__init__.py b/addons/purchase_stock/tests/__init__.py index 46efcec099d..3deb9f79a75 100644 --- a/addons/purchase_stock/tests/__init__.py +++ b/addons/purchase_stock/tests/__init__.py @@ -14,3 +14,4 @@ from . import test_purchase_order_process from . import test_stockvaluation from . import test_replenish_wizard from . import test_reordering_rule +from . import test_move_cancel_propagation diff --git a/addons/purchase_stock/tests/test_move_cancel_propagation.py b/addons/purchase_stock/tests/test_move_cancel_propagation.py new file mode 100644 index 00000000000..2c7aeda0719 --- /dev/null +++ b/addons/purchase_stock/tests/test_move_cancel_propagation.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.tests import tagged +from .common import TestPurchase + + +class TestMoveCancelPropagation(TestPurchase): + + def setUp(self): + super(TestMoveCancelPropagation, self).setUp() + self.customer = self.env['res.partner'].create({'name': 'abc'}) + self.group = self.env['procurement.group'].create({'partner_id': self.customer.id, 'name': 'New Group'}) + self.warehouse = self.env.ref('stock.warehouse0') + cust_location = self.env.ref('stock.stock_location_customers') + seller = self.env['product.supplierinfo'].create({ + 'name': self.customer.id, + 'price': 100.0, + }) + product = self.env['product.product'].create({ + 'name': 'Geyser', + 'type': 'product', + 'route_ids': [(4, self.route_mto), (4, self.route_buy)], + 'seller_ids': [(6, 0, [seller.id])], + }) + self.picking_out = self.env['stock.picking'].create({ + 'location_id': self.warehouse.out_type_id.default_location_src_id.id, + 'location_dest_id': cust_location.id, + 'partner_id': self.customer.id, + 'group_id': self.group.id, + 'picking_type_id': self.ref('stock.picking_type_out'), + }) + self.move = self.env['stock.move'].create({ + 'name': product.name, + 'product_id': product.id, + 'product_uom_qty': 10, + 'product_uom': product.uom_id.id, + 'picking_id': self.picking_out.id, + 'group_id': self.group.id, + 'location_id': self.warehouse.out_type_id.default_location_src_id.id, + 'location_dest_id': cust_location.id, + 'procure_method': 'make_to_order', + }) + + def test_01_cancel_draft_purchase_order_one_steps(self): + """ Check the picking and moves status related PO, When canceling purchase order + Ex. + 1) Set one steps of receiption and delivery on the warehouse. + 2) Create Delivery order with mto move and confirm the order, related RFQ should be generated. + 3) Cancel 'draft' purchase order should not cancel < Delivery > + """ + self.warehouse.write({'delivery_steps': 'ship_only', 'reception_steps': 'one_step'}) + self.picking_out.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.move.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.picking_out.action_confirm() + + # Find PO related to picking. + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.customer.id)]) + + # Po should be create related picking. + self.assertTrue(purchase_order, 'No purchase order created.') + + # Check status of Purchase Order + self.assertEqual(purchase_order.state, 'draft', "Purchase order should be in 'draft' state.") + + # Cancel Purchase order. + purchase_order.button_cancel() + + # Check the status of picking after canceling po. + self.assertNotEqual(self.picking_out.state, 'cancel') + + def test_02_cancel_confirm_purchase_order_one_steps(self): + """ Check the picking and moves status related purchase order, When canceling purchase order + after confirming. + Ex. + 1) Set one steps of receiption and delivery on the warehouse. + 2) Create Delivery order with mto move and confirm the order, related RFQ should be generated. + 3) Cancel 'confirmed' purchase order, should cancel releted < Receiption > + but it should not cancel < Delivery > order. + """ + self.warehouse.write({'delivery_steps': 'ship_only', 'reception_steps': 'one_step'}) + self.picking_out.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.move.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.picking_out.action_confirm() + + # Find PO related to picking. + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.customer.id)]) + # Po should be create related picking. + self.assertTrue(purchase_order, 'No purchase order created.') + + # Check status of Purchase Order + self.assertEqual(purchase_order.state, 'draft', "Purchase order should be in 'draft' state.") + purchase_order .button_confirm() + picking_in = purchase_order.picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.in_type_id) + # Cancel Purchase order. + purchase_order .button_cancel() + + # Check the status of picking after canceling po. + self.assertEquals(picking_in.state, 'cancel') + self.assertNotEqual(self.picking_out.state, 'cancel') + + def test_03_cancel_draft_purchase_order_two_steps(self): + """ Check the picking and moves status related PO, When canceling purchase order + in 'draft' state. + Ex. + 1) Set two steps of receiption and delivery on the warehouse. + 2) Create Delivery order with mto move and confirm the order, related RFQ should be generated. + 3) Cancel 'draft' purchase order should cancel < Input to Stock> + but it should not cancel < PICK, Delivery > + """ + self.warehouse.write({'delivery_steps': 'pick_ship', 'reception_steps': 'two_steps'}) + self.picking_out.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.move.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.picking_out.action_confirm() + + # Find purchase order related to picking. + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.customer.id)]) + # Purchase order should be created for picking. + self.assertTrue(purchase_order, 'No purchase order created.') + + picking_ids = self.env['stock.picking'].search([('group_id', '=', self.group.id)]) + + internal = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.int_type_id and r.group_id.id == self.group.id) + pick = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.pick_type_id and r.group_id.id == self.group.id) + + # Check status of Purchase Order + self.assertEqual(purchase_order.state, 'draft', "Purchase order should be in 'draft' state.") + # Cancel Purchase order. + purchase_order.button_cancel() + + # Check the status of picking after canceling po. + for res in internal: + self.assertEqual(res.state, 'cancel') + self.assertNotEqual(pick.state, 'cancel') + self.assertNotEqual(self.picking_out.state, 'cancel') + + def test_04_cancel_confirm_purchase_order_two_steps(self): + """ Check the picking and moves status related PO, When canceling purchase order + Ex. + 1) Set 2 steps of receiption and delivery on the warehouse. + 2) Create Delivery order with mto move and confirm the order, related RFQ should be generated. + 3) Cancel 'comfirm' purchase order should cancel releted < Receiption Picking IN, INT> + not < PICK, SHIP > + """ + self.warehouse.write({'delivery_steps': 'pick_ship', 'reception_steps': 'two_steps'}) + self.picking_out.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.move.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.picking_out.action_confirm() + + # Find PO related to picking. + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.customer.id)]) + # Po should be create related picking. + self.assertTrue(purchase_order, 'purchase order is created.') + + picking_ids = self.env['stock.picking'].search([('group_id', '=', self.group.id)]) + + internal = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.int_type_id) + pick = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.pick_type_id) + + # Check status of Purchase Order + self.assertEqual(purchase_order.state, 'draft', "Purchase order should be in 'draft' state.") + + purchase_order.button_confirm() + picking_in = purchase_order.picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.in_type_id) + # Cancel Purchase order. + purchase_order.button_cancel() + + # Check the status of picking after canceling po. + self.assertEqual(picking_in.state, 'cancel') + for res in internal: + self.assertEqual(res.state, 'cancel') + self.assertNotEqual(pick.state, 'cancel') + self.assertNotEqual(self.picking_out.state, 'cancel') + + def test_05_cancel_draft_purchase_order_three_steps(self): + """ Check the picking and moves status related PO, When canceling purchase order + Ex. + 1) Set 3 steps of receiption and delivery on the warehouse. + 2) Create Delivery order with mto move and confirm the order, related RFQ should be generated. + 3) Cancel 'draft' purchase order should cancel releted < Receiption Picking IN> + not < PICK, PACK, SHIP > + """ + self.warehouse.write({'delivery_steps': 'pick_pack_ship', 'reception_steps': 'three_steps'}) + self.picking_out.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.move.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.picking_out.action_confirm() + + # Find PO related to picking. + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.customer.id)]) + # Po should be create related picking. + self.assertTrue(purchase_order, 'No purchase order created.') + + picking_ids = self.env['stock.picking'].search([('group_id', '=', self.group.id)]) + + internal = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.int_type_id) + pick = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.pick_type_id) + pack = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.pack_type_id) + + # Check status of Purchase Order + self.assertEqual(purchase_order.state, 'draft', "Purchase order should be in 'draft' state.") + # Cancel Purchase order. + purchase_order.button_cancel() + + # Check the status of picking after canceling po. + for res in internal: + self.assertEqual(res.state, 'cancel') + self.assertNotEqual(pick.state, 'cancel') + self.assertNotEqual(pack.state, 'cancel') + self.assertNotEqual(self.picking_out.state, 'cancel') + + def test_06_cancel_confirm_purchase_order_three_steps(self): + """ Check the picking and moves status related PO, When canceling purchase order + Ex. + 1) Set 3 steps of receiption and delivery on the warehouse. + 2) Create Delivery order with mto move and confirm the order, related RFQ should be generated. + 3) Cancel 'comfirm' purchase order should cancel releted < Receiption Picking IN, INT> + not < PICK, PACK, SHIP > + """ + self.warehouse.write({'delivery_steps': 'pick_pack_ship', 'reception_steps': 'three_steps'}) + self.picking_out.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.move.write({'location_id': self.warehouse.out_type_id.default_location_src_id.id}) + self.picking_out.action_confirm() + + # Find PO related to picking. + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.customer.id)]) + # Po should be create related picking. + self.assertTrue(purchase_order, 'No purchase order created.') + + picking_ids = self.env['stock.picking'].search([('group_id', '=', self.group.id)]) + + internal = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.int_type_id) + pick = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.pick_type_id) + pack = picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.pack_type_id) + + # Check status of Purchase Order + self.assertEqual(purchase_order.state, 'draft', "Purchase order should be in 'draft' state.") + + purchase_order.button_confirm() + picking_in = purchase_order.picking_ids.filtered(lambda r: r.picking_type_id == self.warehouse.in_type_id) + # Cancel Purchase order. + purchase_order.button_cancel() + + # Check the status of picking after canceling po. + self.assertEqual(picking_in.state, 'cancel') + for res in internal: + self.assertEqual(res.state, 'cancel') + self.assertNotEqual(pick.state, 'cancel') + self.assertNotEqual(pack.state, 'cancel') + self.assertNotEqual(self.picking_out.state, 'cancel') diff --git a/addons/purchase_stock/views/purchase_views.xml b/addons/purchase_stock/views/purchase_views.xml index 92330f01d22..947ba72b686 100644 --- a/addons/purchase_stock/views/purchase_views.xml +++ b/addons/purchase_stock/views/purchase_views.xml @@ -27,6 +27,9 @@ + + + {'column_invisible': [('parent.state', 'not in', ('purchase', 'done'))], 'readonly': [('product_type', 'in', ('consu', 'product'))]}