From 18e6be5d08e6f196a64d492cc01f4cfe4b3ddb91 Mon Sep 17 00:00:00 2001 From: Adrien Widart Date: Fri, 11 Mar 2022 08:20:37 +0000 Subject: [PATCH] [FIX] stock: apply putaway strategy on packages The putaway strategies are not correctly working with packages To reproduce the issue: 1. In Settings, enable: - Multi-Step Routes - Storage Categories - Packages 2. Create a Storage Category SC: - Allow New Product: mixed - Max Weight: 100 kg - Capacity by Package: - 1 x Pallet 3. Create two locations L1, L2: - Parent: WH/Stock - Type: Internal - Storage Category: SC 4. Create a putaway rule: - When in: WH/Stock - Package type: Pallet - Store to: WH/Stock - Having Category: SC 5. Edit the warehouse: - Incoming Shipments: 2 steps 6. Create a product P: - Type: Storable - Weight: 1 kg 7. Create a planned receipt R: - To: WH/Input - Operations: - 100 x P 8. Mark R as Todo 9. Create two packages: - 50 x P in PK01 (! PK01 must be a Pallet) - 50 x P in PK02 (! PK02 must be a Pallet) 10. Validate R 11. Open the related internal transfer T - Error [1]: Both packages are redirected to L01, but the capacity by package is 1 x Pallet. PK01 should be redirected to L01 and PK02 to L02 12. Set the done quantity of PK01 - Note: PK01 is now redirected to L02 13. Set the done quantity of PK02 - Error [2]: PK02 is also redirected to L02. Again, it violates the package capacity constraint **Context** When validating the receipt (step 10), it creates a picking Input->Stock. The module then tries to assign some quantities, which leads to `_prepare_move_line_vals` for each package. In this method, `_get_putaway_strategy` is called to define the best destination of each stock move line: https://github.com/odoo/odoo/blob/d55a99ff2d2f7b8d44cde1db3b6d48cdaac1cd3a/addons/stock/models/stock_move.py#L1313 The computation of the best location is in 3 phases: - Phase 01: `_get_putaway_strategy` finds the relevant putaway rules and computes the current and forecasted stock related to the current package/product - Phase 02: `_get_putaway_location` checks if, considering the putaway rule and the current package/product, a location can be used - Phase 03: `_check_can_be_used` checks if a location can receive a package/product (considering the weight and the capacity constraints) **Error 01** During the first phase, in case of a package, the forecasted quantities are computed by searching all SML that have the same package type: https://github.com/odoo/odoo/blob/4bae10e0d960e5b80055e0e44056493238e552d3/addons/stock/models/stock_location.py#L258-L263 This can not work. The SML used to move the product in PK01 (from Input to Stock) is created but its field `result_package_id` is not yet defined. This operation will be done at the of the assign process, thanks to: https://github.com/odoo/odoo/blob/d55a99ff2d2f7b8d44cde1db3b6d48cdaac1cd3a/addons/stock/models/stock_move.py#L1547 So, during the first phase, when searching a location for PK02, the computations are not aware that PK01 is already sent to L01. This explains the error [1]. **Error 01 (Other examples)** Considering how it is currently working, we could imagine another problematic use case: 2 different products on the same package. Since there is one SML per product, `_prepare_move_line_vals` will be called twice (as well as the putaway strategy process). It may lead to two different locations while the product are on the same package. **Error 02** When setting the done quantity, an onchange method is triggered: https://github.com/odoo/odoo/blob/4202e46a8313fa9f1487d372ef0cb771f769be8d/addons/stock/models/stock_move_line.py#L200-L208 and tries to find the best location, considering the done quantity. As said before, during the first phase, it looks for the current and forecasted stock of a location. Since it will find the SML we are writing on, the parameter `additional_qty` is used to subtract the quantity of this SML (we need to ignore the current SML's quantity). But here is a new issue: https://github.com/odoo/odoo/blob/4202e46a8313fa9f1487d372ef0cb771f769be8d/addons/stock/models/stock_move_line.py#L217-L222 `_get_putaway_additional_qty` returns the quantity of products while the first phase is looking for the quantity of packages. As a result, we do some operations between products and packages: https://github.com/odoo/odoo/blob/4bae10e0d960e5b80055e0e44056493238e552d3/addons/stock/models/stock_location.py#L290-L292 `qty_by_location[location_id]` is 2 (i.e. PK01 and PK02) for L01 while `qty` is `-50` (i.e. the 50 products of the current SML), so the value becomes `-48`. This can not work properly because the value doesn't have sense anymore. Later on, during the third phase: https://github.com/odoo/odoo/blob/4bae10e0d960e5b80055e0e44056493238e552d3/addons/stock/models/stock_location.py#L340-L346 The forecasted weight is not subtracted by the quantity of the current SML. So the weight is exceeded, this is the reason why the destination of PK01 becomes L02 on step 12 (it will be the same on step 13) **Additional error** Suppose the user is receiving a tracked product (suppose qty > 1). When confirming the picking, several SMLs are generated to "reserve" the quantity from the customer: https://github.com/odoo/odoo/blob/d55a99ff2d2f7b8d44cde1db3b6d48cdaac1cd3a/addons/stock/models/stock_move.py#L1469-L1471 Later on, thanks to the wizard `stock.assign.serial`, the user generates all USN. However, because the option `show_reserved` is disabled, the wizard creates some new SMLs. But here is the issue, the SMLs created during the assign process are already existing. Therefore, when looking for the best location (for the new SMLs), the existing SMLs will disturb the result (`_get_putaway_strategy` will find these SMLs and think that some products are already incoming in some locations) **Solutions** - We should compute the best locations once all SMLs are created (and linked with their package) - In case of a package with a type, we should try to find the best location for the package itself, not for each product of the package - When calling the putaway strategy process, we should provide the SML(s) to exclude instead of providing some quantities. This will prevent subtracting products with packages and allow the correct calculation of the forecasted weight for each location - When creating a putaway rule, we ensure that the option `show_reserved` of the destination location is enabled OPW-2746169 closes odoo/odoo#89350 X-original-commit: 98169b391f4610545b7f4745b729ad95eac3696a Signed-off-by: William Henrotin (whe) Signed-off-by: Adrien Widart --- addons/stock/models/product_strategy.py | 14 ++ addons/stock/models/stock_location.py | 19 +- addons/stock/models/stock_move.py | 28 ++- addons/stock/models/stock_move_line.py | 33 ++- .../tests/test_generate_serial_numbers.py | 73 ++++++ addons/stock/tests/test_packing.py | 207 ++++++++++++++++++ 6 files changed, 356 insertions(+), 18 deletions(-) diff --git a/addons/stock/models/product_strategy.py b/addons/stock/models/product_strategy.py index 1a504959687..78bc95f4d56 100644 --- a/addons/stock/models/product_strategy.py +++ b/addons/stock/models/product_strategy.py @@ -89,13 +89,27 @@ class StockPutawayRule(models.Model): if not child_location_count or not self.location_out_id: self.location_out_id = self.location_in_id + @api.model_create_multi + def create(self, vals_list): + rules = super().create(vals_list) + rules._enable_show_reserved() + return rules + def write(self, vals): if 'company_id' in vals: for rule in self: if rule.company_id.id != vals['company_id']: raise UserError(_("Changing the company of this record is forbidden at this point, you should rather archive it and create a new one.")) + self._enable_show_reserved() return super(StockPutawayRule, self).write(vals) + def _enable_show_reserved(self): + out_locations = self.location_out_id + if out_locations: + self.env['stock.picking.type'].with_context(active_test=False)\ + .search([('default_location_dest_id', 'in', out_locations.ids)])\ + .write({'show_reserved': True}) + def _get_putaway_location(self, product, quantity=0, package=None, packaging=None, qty_by_location=None): # find package type on package or packaging package_type = self.env['stock.package.type'] diff --git a/addons/stock/models/stock_location.py b/addons/stock/models/stock_location.py index 289f3b44381..c6f813a7d6a 100644 --- a/addons/stock/models/stock_location.py +++ b/addons/stock/models/stock_location.py @@ -102,12 +102,14 @@ class Location(models.Model): 'outgoing_move_line_ids.state', 'incoming_move_line_ids.state', 'outgoing_move_line_ids.product_id.weight', 'outgoing_move_line_ids.product_id.weight', 'quant_ids.quantity', 'quant_ids.product_id.weight') + @api.depends_context('exclude_sml_ids') def _compute_weight(self): for location in self: location.net_weight = 0 quants = location.quant_ids.filtered(lambda q: q.product_id.type != 'service') - incoming_move_lines = location.incoming_move_line_ids.filtered(lambda ml: ml.product_id.type != 'service' and ml.state not in ['draft', 'done', 'cancel']) - outgoing_move_lines = location.outgoing_move_line_ids.filtered(lambda ml: ml.product_id.type != 'service' and ml.state not in ['draft', 'done', 'cancel']) + excluded_sml_ids = self._context.get('exclude_sml_ids', []) + incoming_move_lines = location.incoming_move_line_ids.filtered(lambda ml: ml.product_id.type != 'service' and ml.state not in ['draft', 'done', 'cancel'] and ml.id not in excluded_sml_ids) + outgoing_move_lines = location.outgoing_move_line_ids.filtered(lambda ml: ml.product_id.type != 'service' and ml.state not in ['draft', 'done', 'cancel'] and ml.id not in excluded_sml_ids) for quant in quants: location.net_weight += quant.product_id.weight * quant.quantity location.forecast_weight = location.net_weight @@ -248,6 +250,7 @@ class Location(models.Model): if locations.storage_category_id: if package and package.package_type_id: move_line_data = self.env['stock.move.line']._read_group([ + ('id', 'not in', self._context.get('exclude_sml_ids', [])), ('result_package_id.package_type_id', '=', package_type.id), ('state', 'not in', ['draft', 'cancel', 'done']), ], ['result_package_id:count_distinct'], ['location_dest_id']) @@ -261,6 +264,7 @@ class Location(models.Model): qty_by_location[values['location_id'][0]] += values['package_id'] else: move_line_data = self.env['stock.move.line']._read_group([ + ('id', 'not in', self._context.get('exclude_sml_ids', [])), ('product_id', '=', product.id), ('location_dest_id', 'in', locations.ids), ('state', 'not in', ['draft', 'done', 'cancel']) @@ -329,15 +333,20 @@ class Location(models.Model): specified.""" self.ensure_one() if self.storage_category_id: - # check weight - if self.storage_category_id.max_weight < self.forecast_weight + product.weight * quantity: - return False # check if enough space if package and package.package_type_id: + # check weight + package_smls = self.env['stock.move.line'].search([('result_package_id', '=', package.id)]) + if self.storage_category_id.max_weight < self.forecast_weight + sum(package_smls.mapped(lambda sml: sml.reserved_qty * sml.product_id.weight)): + return False + # check if enough space package_capacity = self.storage_category_id.package_capacity_ids.filtered(lambda pc: pc.package_type_id == package.package_type_id) if package_capacity and location_qty >= package_capacity.quantity: return False else: + # check weight + if self.storage_category_id.max_weight < self.forecast_weight + product.weight * quantity: + return False product_capacity = self.storage_category_id.product_capacity_ids.filtered(lambda pc: pc.product_id == product) # To handle new line without quantity in order to avoid suggesting a location already full if product_capacity and location_qty >= product_capacity.quantity: diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index f1ca50c762b..3aeb8ee8e07 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -355,6 +355,7 @@ class StockMove(models.Model): # do not impact reservation here move_line = self.env['stock.move.line'].create(dict(move._prepare_move_line_vals(), qty_done=quantity_done)) move.write({'move_line_ids': [(4, move_line.id)]}) + move_line._apply_putaway_strategy() elif len(move_lines) == 1: move_lines[0].qty_done = quantity_done else: @@ -1119,13 +1120,9 @@ class StockMove(models.Model): else: move_lines = self.move_line_nosuggest_ids.filtered(lambda ml: not ml.lot_id and not ml.lot_name) - if origin_move_line: - location_dest = origin_move_line.location_dest_id - else: - location_dest = self.location_dest_id._get_putaway_strategy(self.product_id, quantity=1, packaging=self.product_packaging_id) + loc_dest = origin_move_line and origin_move_line.location_dest_id move_line_vals = { 'picking_id': self.picking_id.id, - 'location_dest_id': location_dest.id, 'location_id': self.location_id.id, 'product_id': self.product_id.id, 'product_uom_id': self.product_id.uom_id.id, @@ -1142,6 +1139,7 @@ class StockMove(models.Model): }) move_lines_commands = [] + qty_by_location = defaultdict(float) for lot_name in lot_names: # We write the lot name on an existing move line (if we have still one)... if move_lines: @@ -1149,11 +1147,14 @@ class StockMove(models.Model): 'lot_name': lot_name, 'qty_done': 1, })) + qty_by_location[move_lines[0].location_dest_id.id] += 1 move_lines = move_lines[1:] # ... or create a new move line with the serial name. else: - move_line_cmd = dict(move_line_vals, lot_name=lot_name) + loc = loc_dest or self.location_dest_id._get_putaway_strategy(self.product_id, quantity=1, packaging=self.product_packaging_id, additional_qty=qty_by_location) + move_line_cmd = dict(move_line_vals, lot_name=lot_name, location_dest_id=loc.id) move_lines_commands.append((0, 0, move_line_cmd)) + qty_by_location[loc.id] += 1 return move_lines_commands def _get_new_picking_values(self): @@ -1299,6 +1300,7 @@ class StockMove(models.Model): 'product_id': self.product_id.id, 'product_uom_id': self.product_uom.id, 'location_id': self.location_id.id, + 'location_dest_id': self.location_dest_id.id, 'picking_id': self.picking_id.id, 'company_id': self.company_id.id, } @@ -1321,9 +1323,6 @@ class StockMove(models.Model): package_id=package.id or False, owner_id =reserved_quant.owner_id.id or False, ) - # apply putaway - location_dest_id = self.location_dest_id._get_putaway_strategy(self.product_id, quantity=quantity or 0, package=package, packaging=self.product_packaging_id).id - vals['location_dest_id'] = location_dest_id return vals def _update_reserved_quantity(self, need, available_quantity, location_id, lot_id=None, package_id=None, owner_id=None, strict=True): @@ -1454,6 +1453,9 @@ class StockMove(models.Model): reserved_availability = {move: move.reserved_availability for move in self} roundings = {move: move.product_id.uom_id.rounding for move in self} move_line_vals_list = [] + # Once the quantities are assigned, we want to find a better destination location thanks + # to the putaway rules. This redirection will be applied on moves of `moves_to_redirect`. + moves_to_redirect = OrderedSet() for move in self.filtered(lambda m: m.state in ['confirmed', 'waiting', 'partially_available']): rounding = roundings[move] missing_reserved_uom_quantity = move.product_uom_qty - reserved_availability[move] @@ -1493,6 +1495,7 @@ class StockMove(models.Model): else: move_line_vals_list.append(move._prepare_move_line_vals(quantity=missing_reserved_quantity)) assigned_moves_ids.add(move.id) + moves_to_redirect.add(move.id) else: if float_is_zero(move.product_uom_qty, precision_rounding=move.product_uom.rounding): assigned_moves_ids.add(move.id) @@ -1512,6 +1515,7 @@ class StockMove(models.Model): taken_quantity = move._update_reserved_quantity(need, available_quantity, move.location_id, package_id=forced_package_id, strict=False) if float_is_zero(taken_quantity, precision_rounding=rounding): continue + moves_to_redirect.add(move.id) if float_compare(need, taken_quantity, precision_rounding=rounding) == 0: assigned_moves_ids.add(move.id) else: @@ -1542,6 +1546,7 @@ class StockMove(models.Model): taken_quantity = move._update_reserved_quantity(need, min(quantity, available_quantity), location_id, lot_id, package_id, owner_id) if float_is_zero(taken_quantity, precision_rounding=rounding): continue + moves_to_redirect.add(move.id) if float_is_zero(need - taken_quantity, precision_rounding=rounding): assigned_moves_ids.add(move.id) break @@ -1555,6 +1560,7 @@ class StockMove(models.Model): if self.env.context.get('bypass_entire_pack'): return self.mapped('picking_id')._check_entire_pack() + StockMove.browse(moves_to_redirect).move_line_ids._apply_putaway_strategy() def _action_cancel(self): if any(move.state == 'done' and not move.scrapped for move in self): @@ -1855,7 +1861,11 @@ class StockMove(models.Model): looking for trouble...). @param qty: quantity in the UoM of move.product_uom """ + existing_smls = self.move_line_ids self.move_line_ids = self._set_quantity_done_prepare_vals(qty) + # `_set_quantity_done_prepare_vals` may return some commands to create new SMLs + # These new SMLs need to be redirected thanks to putaway rules + (self.move_line_ids - existing_smls)._apply_putaway_strategy() def _set_quantities_to_reservation(self): for move in self: diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index 0977fd29c04..c523dd95e7f 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -5,7 +5,7 @@ from collections import Counter, defaultdict from odoo import _, api, fields, tools, models from odoo.exceptions import UserError, ValidationError -from odoo.tools import OrderedSet +from odoo.tools import OrderedSet, groupby from odoo.tools.float_utils import float_compare, float_is_zero, float_round from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG @@ -203,10 +203,35 @@ class StockMoveLine(models.Model): if not self.id and self.user_has_groups('stock.group_stock_multi_locations') and self.product_id and self.qty_done: qty_done = self.product_uom_id._compute_quantity(self.qty_done, self.product_id.uom_id) default_dest_location = self._get_default_dest_location() - additional_qty = self._get_putaway_additional_qty() - self.location_dest_id = default_dest_location._get_putaway_strategy( + self.location_dest_id = default_dest_location.with_context(exclude_sml_ids=self.ids)._get_putaway_strategy( self.product_id, quantity=qty_done, package=self.result_package_id, - packaging=self.move_id.product_packaging_id, additional_qty=additional_qty) + packaging=self.move_id.product_packaging_id) + + def _apply_putaway_strategy(self): + for package, smls in groupby(self, lambda sml: sml.result_package_id): + smls = self.env['stock.move.line'].concat(*smls) + excluded_smls = smls + if package.package_type_id: + best_loc = smls.move_id.location_dest_id.with_context(exclude_sml_ids=excluded_smls.ids)._get_putaway_strategy(self.env['product.product'], package=package) + smls.location_dest_id = smls.package_level_id.location_dest_id = best_loc + elif package: + used_locations = set() + for sml in smls: + if len(used_locations) > 1: + break + sml.location_dest_id = sml.move_id.location_dest_id.with_context(exclude_sml_ids=excluded_smls.ids)._get_putaway_strategy(sml.product_id, quantity=sml.reserved_uom_qty) + excluded_smls -= sml + used_locations.add(sml.location_dest_id) + if len(used_locations) > 1: + smls.location_dest_id = smls.move_id.location_dest_id + else: + smls.package_level_id.location_dest_id = smls.location_dest_id + else: + for sml in smls: + sml.location_dest_id = sml.move_id.location_dest_id.with_context(exclude_sml_ids=excluded_smls.ids)._get_putaway_strategy( + sml.product_id, quantity=sml.reserved_uom_qty, packaging=sml.move_id.product_packaging_id, + ) + excluded_smls -= sml def _get_default_dest_location(self): if not self.user_has_groups('stock.group_stock_storage_categories'): diff --git a/addons/stock/tests/test_generate_serial_numbers.py b/addons/stock/tests/test_generate_serial_numbers.py index 5f8ffe5c877..377c6a9a837 100644 --- a/addons/stock/tests/test_generate_serial_numbers.py +++ b/addons/stock/tests/test_generate_serial_numbers.py @@ -378,3 +378,76 @@ class StockGenerate(TransactionCase): self.assertEqual(move_line.lot_name, filtered_value_list.pop(0)) for move_line in (move.move_line_ids - move.move_line_nosuggest_ids): self.assertEqual(move_line.lot_name, False) + + def test_generate_with_putaway_02(self): + """ + Suppose a tracked-by-USN product P + Sub locations in WH/Stock + Storage Category + The Storage Category adds a capacity constraint (max 1 x P / Location) + - Plan a receipt with 2 x P + - Receive 4 x P + -> The test ensures that the destination locations are correct + """ + stock_location = self.warehouse.lot_stock_id + self.env.user.write({'groups_id': [(4, self.env.ref('stock.group_stock_storage_categories').id)]}) + self.env.user.write({'groups_id': [(4, self.env.ref('stock.group_stock_multi_locations').id)]}) + + # max 1 x product_serial + stor_category = self.env['stock.storage.category'].create({ + 'name': 'Super Storage Category', + 'product_capacity_ids': [(0, 0, { + 'product_id': self.product_serial.id, + 'quantity': 1, + })] + }) + + # 5 sub locations with the storage category + # (the last one should never be used) + sub_loc_01, sub_loc_02, sub_loc_03, sub_loc_04, dummy = self.env['stock.location'].create([{ + 'name': 'Sub Location %s' % i, + 'usage': 'internal', + 'location_id': stock_location.id, + 'storage_category_id': stor_category.id, + } for i in [1, 2, 3, 4, 5]]) + + self.env['stock.putaway.rule'].create({ + 'location_in_id': stock_location.id, + 'location_out_id': stock_location.id, + 'product_id': self.product_serial.id, + 'storage_category_id': stor_category.id, + }) + + # Receive 1 x P + receipt_picking = self.env['stock.picking'].create({ + 'picking_type_id': self.warehouse.in_type_id.id, + 'location_id': self.env.ref('stock.stock_location_suppliers').id, + 'location_dest_id': stock_location.id, + }) + move = self.env['stock.move'].create({ + 'name': self.product_serial.name, + 'product_id': self.product_serial.id, + 'product_uom': self.product_serial.uom_id.id, + 'product_uom_qty': 2.0, + 'picking_id': receipt_picking.id, + 'location_id': receipt_picking.location_id.id, + 'location_dest_id': receipt_picking.location_dest_id.id, + }) + receipt_picking.action_confirm() + + self.assertEqual(move.move_line_ids[0].location_dest_id, sub_loc_01) + self.assertEqual(move.move_line_ids[1].location_dest_id, sub_loc_02) + + form_wizard = Form(self.env['stock.assign.serial'].with_context( + default_move_id=move.id, + default_next_serial_number='001', + default_next_serial_count=4, + )) + wiz = form_wizard.save() + wiz.generate_serial_numbers() + + self.assertRecordValues(move.move_line_ids, [ + {'qty_done': 1, 'lot_name': '001', 'location_dest_id': sub_loc_01.id}, + {'qty_done': 1, 'lot_name': '002', 'location_dest_id': sub_loc_02.id}, + {'qty_done': 1, 'lot_name': '003', 'location_dest_id': sub_loc_03.id}, + {'qty_done': 1, 'lot_name': '004', 'location_dest_id': sub_loc_04.id}, + ]) diff --git a/addons/stock/tests/test_packing.py b/addons/stock/tests/test_packing.py index ee6dc10869f..f693d148825 100644 --- a/addons/stock/tests/test_packing.py +++ b/addons/stock/tests/test_packing.py @@ -929,3 +929,210 @@ class TestPacking(TestPackingCommon): picking.button_validate() self.assertEqual(package_level.location_id, shelf1_location) + + def test_pack_in_receipt_two_step_multi_putaway_02(self): + """ + Suppose a product P, its weight is equal to 1kg + We have 100 x P on two pallets. + Receipt in two steps + Sub locations in WH/Stock + Storage Category + The Storage Category adds some constraints on weight/pallets capacity + """ + warehouse = self.stock_location.warehouse_id + warehouse.reception_steps = "two_steps" + self.productA.weight = 1.0 + self.env.user.write({'groups_id': [(4, self.env.ref('stock.group_stock_storage_categories').id)]}) + self.env.user.write({'groups_id': [(4, self.env.ref('stock.group_stock_multi_locations').id)]}) + + package_type = self.env['stock.package.type'].create({ + 'name': "Super Pallet", + }) + package_01, package_02 = self.env['stock.quant.package'].create([{ + 'name': 'Pallet %s' % i, + 'package_type_id': package_type.id, + } for i in [1, 2]]) + + # max 100kg (so 100 x P) and max 1 pallet -> we will work with pallets, + # so the pallet capacity constraint should be the effective one + stor_category = self.env['stock.storage.category'].create({ + 'name': 'Super Storage Category', + 'max_weight': 100, + 'package_capacity_ids': [(0, 0, { + 'package_type_id': package_type.id, + 'quantity': 1, + })] + }) + + # 3 sub locations with the storage category + # (the third location should never be used) + sub_loc_01, sub_loc_02, dummy = self.env['stock.location'].create([{ + 'name': 'Sub Location %s' % i, + 'usage': 'internal', + 'location_id': self.stock_location.id, + 'storage_category_id': stor_category.id, + } for i in [1, 2, 3]]) + + self.env['stock.putaway.rule'].create({ + 'location_in_id': self.stock_location.id, + 'location_out_id': self.stock_location.id, + 'package_type_ids': [(4, package_type.id)], + 'storage_category_id': stor_category.id, + }) + + # Receive 100 x P + receipt_picking = self.env['stock.picking'].create({ + 'picking_type_id': warehouse.in_type_id.id, + 'location_id': self.env.ref('stock.stock_location_suppliers').id, + 'location_dest_id': warehouse.wh_input_stock_loc_id.id, + }) + self.env['stock.move'].create({ + 'name': self.productA.name, + 'product_id': self.productA.id, + 'product_uom': self.productA.uom_id.id, + 'product_uom_qty': 100.0, + 'picking_id': receipt_picking.id, + 'location_id': receipt_picking.location_id.id, + 'location_dest_id': receipt_picking.location_dest_id.id, + }) + receipt_picking.action_confirm() + + # Distribute the products on two pallets, one with 49 x P and a second + # one with 51 x P (to easy the debugging in case of trouble) + move_form = Form(receipt_picking.move_ids, view="stock.view_stock_move_operations") + with move_form.move_line_ids.edit(0) as line: + line.qty_done = 49 + line.result_package_id = package_01 + with move_form.move_line_ids.new() as line: + line.qty_done = 51 + line.result_package_id = package_02 + move_form.save() + receipt_picking.button_validate() + + # We are in two-steps receipt -> check the internal picking + internal_picking = self.env['stock.picking'].search([], order='id desc', limit=1) + self.assertRecordValues(internal_picking.move_line_ids, [ + {'reserved_uom_qty': 51, 'qty_done': 0, 'result_package_id': package_02.id, 'location_dest_id': sub_loc_01.id}, + {'reserved_uom_qty': 49, 'qty_done': 0, 'result_package_id': package_01.id, 'location_dest_id': sub_loc_02.id}, + ]) + + # Change the constraints of the storage category: + # max 75kg (so 75 x P) and max 2 pallet -> this time, the weight + # constraint should be the effective one + stor_category.max_weight = 75 + stor_category.package_capacity_ids.quantity = 2 + internal_picking.do_unreserve() + internal_picking.action_assign() + self.assertRecordValues(internal_picking.move_line_ids, [ + {'reserved_uom_qty': 51, 'qty_done': 0, 'result_package_id': package_02.id, 'location_dest_id': sub_loc_01.id}, + {'reserved_uom_qty': 49, 'qty_done': 0, 'result_package_id': package_01.id, 'location_dest_id': sub_loc_02.id}, + ]) + + move_form = Form(internal_picking.move_ids, view="stock.view_stock_move_operations") + # lines order is reversed: [Pallet 02, Pallet 01] + with move_form.move_line_ids.edit(0) as line: + line.qty_done = 51 + with move_form.move_line_ids.edit(1) as line: + line.qty_done = 49 + move_form.save() + self.assertRecordValues(internal_picking.move_line_ids, [ + {'reserved_uom_qty': 51, 'qty_done': 51, 'result_package_id': package_02.id, 'location_dest_id': sub_loc_01.id}, + {'reserved_uom_qty': 49, 'qty_done': 49, 'result_package_id': package_01.id, 'location_dest_id': sub_loc_02.id}, + ]) + + def test_pack_in_receipt_two_step_multi_putaway_03(self): + """ + Two sublocations (max 100kg, max 2 pallet) + Two products P1, P2, weight = 1kg + There are 10 x P1 on a pallet in the first sub location + Receive a pallet of 50 x P1 + 50 x P2 => because of weight constraint, should be redirected to the + second sub location + Then, same with max 200kg max 1 pallet => same result, this time because of pallet count constraint + """ + warehouse = self.stock_location.warehouse_id + warehouse.reception_steps = "two_steps" + self.productA.weight = 1.0 + self.productB.weight = 1.0 + self.env.user.write({'groups_id': [(4, self.env.ref('stock.group_stock_storage_categories').id)]}) + self.env.user.write({'groups_id': [(4, self.env.ref('stock.group_stock_multi_locations').id)]}) + + package_type = self.env['stock.package.type'].create({ + 'name': "Super Pallet", + }) + package_01, package_02 = self.env['stock.quant.package'].create([{ + 'name': 'Pallet %s' % i, + 'package_type_id': package_type.id, + } for i in [1, 2]]) + + # max 100kg and max 2 pallets + stor_category = self.env['stock.storage.category'].create({ + 'name': 'Super Storage Category', + 'max_weight': 100, + 'package_capacity_ids': [(0, 0, { + 'package_type_id': package_type.id, + 'quantity': 2, + })] + }) + + # 3 sub locations with the storage category + # (the third location should never be used) + sub_loc_01, sub_loc_02, dummy = self.env['stock.location'].create([{ + 'name': 'Sub Location %s' % i, + 'usage': 'internal', + 'location_id': self.stock_location.id, + 'storage_category_id': stor_category.id, + } for i in [1, 2, 3]]) + + self.env['stock.quant']._update_available_quantity(self.productA, sub_loc_01, 10, package_id=package_01) + + self.env['stock.putaway.rule'].create({ + 'location_in_id': self.stock_location.id, + 'location_out_id': self.stock_location.id, + 'package_type_ids': [(4, package_type.id)], + 'storage_category_id': stor_category.id, + }) + + # Receive 50 x P_A and 50 x P_B + receipt_picking = self.env['stock.picking'].create({ + 'picking_type_id': warehouse.in_type_id.id, + 'location_id': self.env.ref('stock.stock_location_suppliers').id, + 'location_dest_id': warehouse.wh_input_stock_loc_id.id, + }) + self.env['stock.move'].create([{ + 'name': p.name, + 'product_id': p.id, + 'product_uom': p.uom_id.id, + 'product_uom_qty': 50, + 'picking_id': receipt_picking.id, + 'location_id': receipt_picking.location_id.id, + 'location_dest_id': receipt_picking.location_dest_id.id, + } for p in [self.productA, self.productB]]) + receipt_picking.action_confirm() + + move_form = Form(receipt_picking.move_ids[0], view="stock.view_stock_move_operations") + with move_form.move_line_ids.edit(0) as line: + line.qty_done = 50 + line.result_package_id = package_02 + move_form.save() + move_form = Form(receipt_picking.move_ids[1], view="stock.view_stock_move_operations") + with move_form.move_line_ids.edit(0) as line: + line.qty_done = 50 + line.result_package_id = package_02 + move_form.save() + receipt_picking.button_validate() + + # We are in two-steps receipt -> check the internal picking + internal_picking = self.env['stock.picking'].search([], order='id desc', limit=1) + self.assertRecordValues(internal_picking.move_line_ids, [ + {'product_id': self.productA.id, 'reserved_uom_qty': 50, 'qty_done': 0, 'result_package_id': package_02.id, 'location_dest_id': sub_loc_02.id}, + {'product_id': self.productB.id, 'reserved_uom_qty': 50, 'qty_done': 0, 'result_package_id': package_02.id, 'location_dest_id': sub_loc_02.id}, + ]) + + # Change the constraints of the storage category: + # max 200kg and max 1 pallet + stor_category.max_weight = 200 + stor_category.package_capacity_ids.quantity = 1 + internal_picking.do_unreserve() + internal_picking.action_assign() + self.assertRecordValues(internal_picking.move_line_ids, [ + {'product_id': self.productA.id, 'reserved_uom_qty': 50, 'qty_done': 0, 'result_package_id': package_02.id, 'location_dest_id': sub_loc_02.id}, + {'product_id': self.productB.id, 'reserved_uom_qty': 50, 'qty_done': 0, 'result_package_id': package_02.id, 'location_dest_id': sub_loc_02.id}, + ])