From c7f53f770c1d22fd9aff1823a04c86464a91bf71 Mon Sep 17 00:00:00 2001 From: "Tiffany Chang (tic)" Date: Mon, 19 Oct 2020 14:23:55 +0000 Subject: [PATCH 1/3] [IMP] mrp, (purchase_)stock: improve move reservation Currently stock moves are auto-assigned (i.e. reserve free stock) both when the scheduler is run and when a corresponding stock move that can assign a move is completed. This strictness was causing issues for prioritization of moves to assign, for example a picking with a scheduled_date far in the future could automatically reserve all stock if it was created before another picking that an immediate scheduled_date. To ease this, an extra setting has been added to stock.picking.type to let users choose how reservations should occur for moves assigned to that picking_type (or picking with that picking_type): 1. 'at_confirm' = automatically when: - stock is available when the move's associated picking/MO is confirmed, - when new stock becomes available, - when the scheduler is triggered (+ stock available). 2. 'manual' = user must always manually click "Check Availability" button (scheduler will no longer reserve). 3. 'by_date' = automatically when within the move's reservation_date and: - stock is available when the move's associated picking/MO is confirmed, - new stock becomes available when move is already confirmed, or - the scheduler is run (+ stock is available). 'by_date' has an extra option of # days before the move's scheduled date that affects the move.reservation_date. Task: 2359317 Upgrade PR: odoo/upgrade#1868 ENT PR: odoo/enterprise#15050 --- addons/mrp/tests/test_procurement.py | 17 ++- .../test_warehouse_multistep_manufacturing.py | 4 +- .../tests/test_create_picking.py | 2 + addons/stock/models/stock_move.py | 42 +++++-- addons/stock/models/stock_picking.py | 8 +- addons/stock/models/stock_rule.py | 4 + addons/stock/tests/common.py | 1 + addons/stock/tests/test_move.py | 2 +- addons/stock/tests/test_move2.py | 109 ++++++++++++++++++ addons/stock/tests/test_multicompany.py | 2 +- addons/stock/tests/test_packing.py | 1 + addons/stock/tests/test_product.py | 1 + addons/stock/views/stock_picking_views.xml | 5 + .../tests/test_batch_picking.py | 1 + 14 files changed, 185 insertions(+), 14 deletions(-) diff --git a/addons/mrp/tests/test_procurement.py b/addons/mrp/tests/test_procurement.py index 6901920c91d..297adfcf455 100644 --- a/addons/mrp/tests/test_procurement.py +++ b/addons/mrp/tests/test_procurement.py @@ -353,7 +353,8 @@ class TestProcurement(TestMrpCommon): 2. There is not enough of a manufactured component to assign the created MO => auto-create 2nd MO 3. Add an extra manufactured component (not in stock) to 1st MO => auto-create 3rd MO 4. When 2nd MO is completed => auto-assign to 1st MO - 5. When 1st MO is completed => auto-assign to picking """ + 5. When 1st MO is completed => auto-assign to picking + 6. Additionally check that a MO that has component in stock auto-reserves when MO is confirmed (since default setting = 'at_confirm')""" self.warehouse = self.env.ref('stock.warehouse0') route_manufacture = self.warehouse.manufacture_pull_id.route_id @@ -373,7 +374,7 @@ class TestProcurement(TestMrpCommon): 'type': 'consu', }) - self.env['mrp.bom'].create({ + bom1 = self.env['mrp.bom'].create({ 'product_id': product_1.id, 'product_tmpl_id': product_1.product_tmpl_id.id, 'product_uom_id': self.uom_unit.id, @@ -504,3 +505,15 @@ class TestProcurement(TestMrpCommon): mo.button_mark_done() self.assertEqual(pick_output.move_ids_without_package.reserved_availability, 10, "Completed products should have been auto-reserved in picking") + + # make sure next MO auto-reserves components now that they are in stock since + # default reservation_method = 'at_confirm' + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = product_1 + mo_form.bom_id = bom1 + mo_form.product_qty = 5 + mo_form.product_uom_id = product_1.uom_id + mo_assign_at_confirm = mo_form.save() + mo_assign_at_confirm.action_confirm() + + self.assertEqual(mo_assign_at_confirm.move_raw_ids.reserved_availability, 5, "Components should have been auto-reserved") diff --git a/addons/mrp/tests/test_warehouse_multistep_manufacturing.py b/addons/mrp/tests/test_warehouse_multistep_manufacturing.py index d9e6f840950..ba48660e707 100644 --- a/addons/mrp/tests/test_warehouse_multistep_manufacturing.py +++ b/addons/mrp/tests/test_warehouse_multistep_manufacturing.py @@ -174,7 +174,7 @@ class TestMultistepManufacturingWarehouse(TestMrpCommon): self.assertTrue(picking_stock_preprod) self.assertTrue(picking_stock_postprod) - self.assertEqual(picking_stock_preprod.state, 'confirmed') + self.assertEqual(picking_stock_preprod.state, 'assigned') self.assertEqual(picking_stock_postprod.state, 'waiting') self.assertEqual(picking_stock_preprod.origin, production_order.name, 'The pre-prod origin should be the MO name') self.assertEqual(picking_stock_postprod.origin, 'SOURCEDOCUMENT', 'The post-prod origin should be the SO name') @@ -250,7 +250,7 @@ class TestMultistepManufacturingWarehouse(TestMrpCommon): self.assertTrue(move_stock_preprod) self.assertTrue(move_stock_postprod) - self.assertEqual(move_stock_preprod.state, 'confirmed') + self.assertEqual(move_stock_preprod.state, 'assigned') self.assertEqual(move_stock_postprod.state, 'waiting') move_stock_preprod._action_cancel() diff --git a/addons/purchase_stock/tests/test_create_picking.py b/addons/purchase_stock/tests/test_create_picking.py index a63443e15b7..210ae144463 100644 --- a/addons/purchase_stock/tests/test_create_picking.py +++ b/addons/purchase_stock/tests/test_create_picking.py @@ -101,6 +101,7 @@ class TestCreatePicking(common.TestProductCommon): """ stock_location = self.env['ir.model.data'].xmlid_to_object('stock.stock_location_stock') customer_location = self.env['ir.model.data'].xmlid_to_object('stock.stock_location_customers') + picking_type_out = self.env['ir.model.data'].xmlid_to_object('stock.picking_type_out') # route buy should be there by default partner = self.env['res.partner'].create({ 'name': 'Jhon' @@ -132,6 +133,7 @@ class TestCreatePicking(common.TestProductCommon): 'product_uom': product.uom_id.id, 'product_uom_qty': 100.0, 'procure_method': 'make_to_order', + 'picking_type_id': picking_type_out.id, }) customer_move._action_confirm() diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 9901998c6f2..6e046eb5baa 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -3,7 +3,7 @@ import json from collections import defaultdict -from datetime import datetime +from datetime import datetime, timedelta from itertools import groupby from operator import itemgetter from re import findall as regex_findall @@ -180,6 +180,8 @@ class StockMove(models.Model): forecast_availability = fields.Float('Forecast Availability', compute='_compute_forecast_information', digits='Product Unit of Measure') forecast_expected_date = fields.Datetime('Forecasted Expected date', compute='_compute_forecast_information') lot_ids = fields.Many2many('stock.production.lot', compute='_compute_lot_ids', inverse='_set_lot_ids', string='Serial Numbers', readonly=False) + reservation_date = fields.Date('Date to Reserve', compute='_compute_reservation_date', store=True, + help="This is a technical field for calculating when a move should be reserved") @api.onchange('product_id', 'picking_type_id') def onchange_product(self): @@ -493,6 +495,18 @@ class StockMove(models.Model): move_lines_commands.append((0, 0, move_line_vals)) move.write({'move_line_ids': move_lines_commands}) + @api.depends('picking_id', 'picking_type_id', 'date') + def _compute_reservation_date(self): + for move in self: + if move.state in ['draft', 'confirmed', 'waiting', 'partially_available']: + picking_type_id = False + if move.picking_type_id: + picking_type_id = move.picking_type_id + elif move.picking_id: + picking_type_id = move.picking_id.picking_type_id + if picking_type_id and picking_type_id.reservation_method == 'by_date': + move.reservation_date = fields.Date.to_date(move.date) - timedelta(days=move.picking_type_id.reservation_days_before) + @api.constrains('product_uom') def _check_uom(self): moves_error = self.filtered(lambda move: move.product_id.uom_id.category_id != move.product_uom.category_id) @@ -1120,9 +1134,14 @@ class StockMove(models.Model): if merge: moves = self._merge_moves(merge_into=merge_into) - # call `_action_assign` on every confirmed move which location_id bypasses the reservation - moves.filtered(lambda move: not move.picking_id.immediate_transfer and move._should_bypass_reservation() and move.state == 'confirmed')._action_assign() - + # call `_action_assign` on every confirmed move which location_id bypasses the reservation + those expected to be auto-assigned + moves.filtered(lambda move: not move.picking_id.immediate_transfer + and move.state == 'confirmed' + and (move._should_bypass_reservation() + or move.picking_type_id.reservation_method == 'at_confirm' + or move.picking_id.picking_type_id.reservation_method == 'at_confirm' + or (move.reservation_date and move.reservation_date <= fields.Date.today())))\ + ._action_assign() if new_push_moves: new_push_moves._action_confirm() @@ -1385,6 +1404,8 @@ class StockMove(models.Model): self.env['stock.move.line'].create(move_line_vals_list) partially_available_moves.write({'state': 'partially_available'}) assigned_moves.write({'state': 'assigned'}) + if self.env.context.get('bypass_entire_pack'): + return self.mapped('picking_id')._check_entire_pack() def _action_cancel(self): @@ -1502,7 +1523,9 @@ class StockMove(models.Model): new_move_vals = move._split(qty_split) backorder_moves_vals += new_move_vals backorder_moves = self.env['stock.move'].create(backorder_moves_vals) - backorder_moves._action_confirm(merge=False) + # The backorder moves are not yet in their own picking. We do not want to check entire packs for those + # ones as it could messed up the result_package_id of the moves being currently validated + backorder_moves.with_context(bypass_entire_pack=True)._action_confirm(merge=False) if cancel_backorder: backorder_moves.with_context(moves_todo=moves_todo)._action_cancel() moves_todo.mapped('move_line_ids').sorted()._action_done() @@ -1528,7 +1551,9 @@ class StockMove(models.Model): return moves_todo if picking and not cancel_backorder: - picking._create_backorder() + backorder = picking._create_backorder() + if any([m.state == 'assigned' for m in backorder.move_lines]): + backorder._check_entire_pack() return moves_todo def unlink(self): @@ -1770,5 +1795,8 @@ class StockMove(models.Model): for move in self: domains.append([('product_id', '=', move.product_id.id), ('location_id', '=', move.location_dest_id.id)]) static_domain = [('state', 'in', ['confirmed', 'partially_available']), ('procure_method', '=', 'make_to_stock')] - moves_to_reserve = self.env['stock.move'].search(expression.AND([static_domain, expression.OR(domains)])) + reservation_domain = ['|', '|', ('picking_type_id.reservation_method', '=', 'at_confirm'), + ('picking_id.picking_type_id.reservation_method', '=', 'at_confirm'), + ('reservation_date', '<=', fields.Date.today())] + moves_to_reserve = self.env['stock.move'].search(expression.AND([static_domain, expression.OR(domains), reservation_domain])) moves_to_reserve._action_assign() diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index 7f136fb5e14..c53775db866 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -5,7 +5,7 @@ import json import time from ast import literal_eval from collections import defaultdict -from datetime import date +from datetime import date, timedelta from itertools import groupby from operator import itemgetter @@ -66,6 +66,12 @@ class PickingType(models.Model): 'Pre-fill Detailed Operations', default=True, help="If this checkbox is ticked, Odoo will automatically pre-fill the detailed " "operations with the corresponding products, locations and lot/serial numbers.") + reservation_method = fields.Selection( + [('by_date', 'before scheduled date'), ('at_confirm', 'At Confirmation'), ('manual', 'Manually')], + 'Reservation Method', required=True, default='at_confirm', + help="How products in transfers of this operation type should be reserved.") + reservation_days_before = fields.Integer('Days', help="Maximum number of days before scheduled date that products should be reserved.") + count_picking_draft = fields.Integer(compute='_compute_picking_count') count_picking_ready = fields.Integer(compute='_compute_picking_count') diff --git a/addons/stock/models/stock_rule.py b/addons/stock/models/stock_rule.py index def3cd8cf44..3cf631e898d 100644 --- a/addons/stock/models/stock_rule.py +++ b/addons/stock/models/stock_rule.py @@ -489,6 +489,10 @@ class ProcurementGroup(models.Model): ('state', 'in', ['confirmed', 'partially_available']), ('product_uom_qty', '!=', 0.0) ] + # TODO: make picking_type_id/reservation method one value on stock.move + moves_domain = expression.AND([moves_domain, ['|', '|', ('picking_type_id.reservation_method', '=', 'at_confirm'), + ('picking_id.picking_type_id.reservation_method', '=', 'at_confirm'), + ('reservation_date', '<=', fields.Date.today())]]) if company_id: moves_domain = expression.AND([[('company_id', '=', company_id)], moves_domain]) return moves_domain diff --git a/addons/stock/tests/common.py b/addons/stock/tests/common.py index e794a2f20dc..9401283ea97 100644 --- a/addons/stock/tests/common.py +++ b/addons/stock/tests/common.py @@ -23,6 +23,7 @@ class TestStockCommon(common.TransactionCase): # Model Data cls.picking_type_in = cls.ModelDataObj.xmlid_to_res_id('stock.picking_type_in') cls.picking_type_out = cls.ModelDataObj.xmlid_to_res_id('stock.picking_type_out') + cls.env['stock.picking.type'].browse(cls.picking_type_out).reservation_method = 'manual' cls.supplier_location = cls.ModelDataObj.xmlid_to_res_id('stock.stock_location_suppliers') cls.stock_location = cls.ModelDataObj.xmlid_to_res_id('stock.stock_location_stock') pack_location = cls.env.ref('stock.location_pack_zone') diff --git a/addons/stock/tests/test_move.py b/addons/stock/tests/test_move.py index cea6e61f660..5b812eb3e28 100644 --- a/addons/stock/tests/test_move.py +++ b/addons/stock/tests/test_move.py @@ -3841,7 +3841,7 @@ class StockMove(TransactionCase): }) move1._action_confirm() - self.assertEqual(move1.state, 'confirmed') + self.assertEqual(move1.state, 'assigned') scrap = self.env['stock.scrap'].create({ 'product_id': self.product.id, 'product_uom_id': self.product.uom_id.id, diff --git a/addons/stock/tests/test_move2.py b/addons/stock/tests/test_move2.py index f20ffc44e30..a907f9b51ab 100644 --- a/addons/stock/tests/test_move2.py +++ b/addons/stock/tests/test_move2.py @@ -2991,6 +2991,7 @@ class TestAutoAssign(TestStockCommon): """ pack_location = self.env['stock.location'].browse(self.pack_location) stock_location = self.env['stock.location'].browse(self.stock_location) + self.env['stock.picking.type'].browse(self.picking_type_out).reservation_method = 'at_confirm' # create customer picking and move customer_picking = self.env['stock.picking'].create({ @@ -3045,6 +3046,7 @@ class TestAutoAssign(TestStockCommon): picking_pick, picking_client = self.create_pick_ship() pack_location = self.env['stock.location'].browse(self.pack_location) stock_location = self.env['stock.location'].browse(self.stock_location) + self.env['stock.picking.type'].browse(self.picking_type_out).reservation_method = 'at_confirm' # make some stock self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 10.0) @@ -3072,6 +3074,113 @@ class TestAutoAssign(TestStockCommon): self.assertEqual(picking_client.state, 'waiting', "MTO moves can't be automatically assigned.") self.assertEqual(self.env['stock.quant']._get_available_quantity(self.productA, pack_location), 10.0) + def test_auto_assign_reservation_method(self): + """Test different stock.picking.type reservation methods by: + 1. Create multiple delivery picking types with different reservation methods + 2. Create/confirm outgoing pickings for each of these picking types for a product not in stock + 3. Create/do an incoming picking that fulfills all of the outgoing pickings + 4. Check that only the correct outgoing pickings are auto_assigned + 5. Additionally check that auto-assignment at confirmation correctly works when products are in stock + Note, default reservation method is expected to be covered by other tests. + """ + + stock_location = self.env['stock.location'].browse(self.stock_location) + self.assertEqual(self.env['stock.quant']._get_available_quantity(self.productA, stock_location), 0) + picking_type_out1 = self.env['stock.picking.type'].browse(self.picking_type_out).copy() + picking_type_out2 = picking_type_out1.copy() + picking_type_out3 = picking_type_out1.copy() + picking_type_out4 = picking_type_out1.copy() + picking_type_out1.reservation_method = 'manual' + picking_type_out2.reservation_method = 'by_date' + picking_type_out2.reservation_days_before = '1' + picking_type_out3.reservation_method = 'by_date' + picking_type_out3.reservation_days_before = '10' + picking_type_out4.reservation_method = 'at_confirm' + + # 'manual' assign picking => should never auto-assign + customer_picking1 = self.env['stock.picking'].create({ + 'name': "Delivery 1", + 'location_id': self.stock_location, + 'location_dest_id': self.customer_location, + 'picking_type_id': picking_type_out1.id, + }) + + # 'by_date' picking w/ 1 day before scheduled date auto-assign setting, set to 5 days in advance => shouldn't auto-assign + customer_picking2 = customer_picking1.copy({'name': "Delivery 2", + 'picking_type_id': picking_type_out2.id, + 'scheduled_date': customer_picking1.scheduled_date + timedelta(days=5)}) + # 'by_date' picking w/ 10 days before scheduled date auto-assign setting, set to 5 days in advance => should auto-assign + customer_picking3 = customer_picking2.copy({'name': "Delivery 3", 'picking_type_id': picking_type_out3.id}) + customer_picking4 = customer_picking3.copy({'name': "Delivery 4", 'picking_type_id': picking_type_out3.id}) + + customer_picking5 = customer_picking1.copy({'name': "Delivery 5", 'picking_type_id': picking_type_out4.id}) + + # create their associated moves (needs to be in form view so compute functions properly trigger) + customer_picking1 = Form(customer_picking1) + with customer_picking1.move_ids_without_package.new() as move: + move.product_id = self.productA + move.product_uom_qty = 10 + customer_picking1 = customer_picking1.save() + + customer_picking2 = Form(customer_picking2) + with customer_picking2.move_ids_without_package.new() as move: + move.product_id = self.productA + move.product_uom_qty = 10 + customer_picking2 = customer_picking2.save() + + customer_picking3 = Form(customer_picking3) + with customer_picking3.move_ids_without_package.new() as move: + move.product_id = self.productA + move.product_uom_qty = 10 + customer_picking3 = customer_picking3.save() + + customer_picking4 = Form(customer_picking4) + with customer_picking4.move_ids_without_package.new() as move: + move.product_id = self.productA + move.product_uom_qty = 10 + customer_picking4 = customer_picking4.save() + + customer_picking5 = Form(customer_picking5) + with customer_picking5.move_ids_without_package.new() as move: + move.product_id = self.productA + move.product_uom_qty = 10 + customer_picking5 = customer_picking5.save() + + customer_picking1.action_assign() + customer_picking2.action_assign() + customer_picking3.action_assign() + self.assertEqual(customer_picking1.move_lines.reserved_availability, 0, "There should be no products available to reserve yet.") + self.assertEqual(customer_picking2.move_lines.reserved_availability, 0, "There should be no products available to reserve yet.") + self.assertEqual(customer_picking3.move_lines.reserved_availability, 0, "There should be no products available to reserve yet.") + + # create supplier picking and move + supplier_picking = self.env['stock.picking'].create({ + 'location_id': self.customer_location, + 'location_dest_id': self.stock_location, + 'picking_type_id': self.picking_type_in, + }) + supplier_move = self.env['stock.move'].create({ + 'name': 'test_transit_1', + 'location_id': self.customer_location, + 'location_dest_id': self.stock_location, + 'product_id': self.productA.id, + 'product_uom': self.productA.uom_id.id, + 'product_uom_qty': 50.0, + 'picking_id': supplier_picking.id, + }) + supplier_move.quantity_done = 50 + supplier_picking._action_done() + + self.assertEqual(customer_picking1.move_lines.reserved_availability, 0, "Reservation Method: 'manual' shouldn't ever auto-assign") + self.assertEqual(customer_picking2.move_lines.reserved_availability, 0, "Reservation Method: 'by_date' shouldn't auto-assign when not within reservation date range") + self.assertEqual(customer_picking3.move_lines.reserved_availability, 10, "Reservation Method: 'by_date' should auto-assign when within reservation date range") + self.assertEqual(self.env['stock.quant']._get_available_quantity(self.productA, stock_location), 40) + + customer_picking4.action_assign() + customer_picking5.action_assign() + self.assertEqual(customer_picking4.move_lines.reserved_availability, 10, "Reservation Method: 'at_confirm' should auto-assign at confirmation") + self.assertEqual(customer_picking5.move_lines.reserved_availability, 10, "Reservation Method: 'by_date' should auto-assign when within reservation date range at confirmation") + def test_serial_lot_ids(self): self.stock_location = self.env.ref('stock.stock_location_stock') self.customer_location = self.env.ref('stock.stock_location_customers') diff --git a/addons/stock/tests/test_multicompany.py b/addons/stock/tests/test_multicompany.py index 131a0f9dd3c..8609a5007f6 100644 --- a/addons/stock/tests/test_multicompany.py +++ b/addons/stock/tests/test_multicompany.py @@ -563,7 +563,7 @@ class TestMultiCompany(TransactionCase): "Chained move created in transit location") self.assertEqual(move_wha_to_cus.state, "waiting") self.assertEqual(move_transit_to_wha.state, "waiting") - self.assertEqual(move_whb_to_transit.state, "confirmed") + self.assertEqual(move_whb_to_transit.state, "assigned") (move_wha_to_cus + move_whb_to_transit + move_transit_to_wha).picking_id.action_assign() self.assertEqual(move_wha_to_cus.state, "waiting") diff --git a/addons/stock/tests/test_packing.py b/addons/stock/tests/test_packing.py index 5baa0ae5222..3e1096dc7f0 100644 --- a/addons/stock/tests/test_packing.py +++ b/addons/stock/tests/test_packing.py @@ -14,6 +14,7 @@ class TestPackingCommon(TransactionCase): cls.stock_location = cls.env.ref('stock.stock_location_stock') cls.warehouse = cls.env['stock.warehouse'].search([('lot_stock_id', '=', cls.stock_location.id)], limit=1) cls.warehouse.write({'delivery_steps': 'pick_pack_ship'}) + cls.warehouse.int_type_id.reservation_method = 'manual' cls.pack_location = cls.warehouse.wh_pack_stock_loc_id cls.ship_location = cls.warehouse.wh_output_stock_loc_id cls.customer_location = cls.env.ref('stock.stock_location_customers') diff --git a/addons/stock/tests/test_product.py b/addons/stock/tests/test_product.py index 35c5bc1136e..b8ea55a81a9 100644 --- a/addons/stock/tests/test_product.py +++ b/addons/stock/tests/test_product.py @@ -15,6 +15,7 @@ class TestVirtualAvailable(TestStockCommon): # Make `product3` a storable product for this test. Indeed, creating quants # and playing with owners is not possible for consumables. self.product_3.type = 'product' + self.env['stock.picking.type'].browse(self.ref('stock.picking_type_out')).reservation_method = 'manual' self.env['stock.quant'].create({ 'product_id': self.product_3.id, diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index dab56ab247e..267f25a2c40 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -43,6 +43,11 @@ +