[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:
@@ -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']
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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},
|
||||
])
|
||||
|
||||
@@ -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},
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user