[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) <whe@odoo.com>
Signed-off-by: Adrien Widart <awt@odoo.com>
This commit is contained in:
Adrien Widart
2022-04-22 09:58:03 +02:00
parent ded49ccc48
commit 18e6be5d08
6 changed files with 356 additions and 18 deletions
+14
View File
@@ -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']
+14 -5
View File
@@ -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:
+19 -9
View File
@@ -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:
+29 -4
View File
@@ -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'):
@@ -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},
])
+207
View File
@@ -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},
])