[IMP] stock: reserve on return to select delivered lot

Since return moves are linked to their original move, it's easy to
manage reservation in those case. We just don't bypass the reservation
when it comes from a reservation outside our stock in order to manage
the computation on linked moves and reserve pieces that were not yet
return.

Also add a return picking type for 2 reasons:
- Allow to select existing lot.
- Display reserved move line for an incoming picking.

Note that this picking type is just a default configuration and could be
modify by the user without any issue.

closes odoo/odoo#64384

Related: odoo/enterprise#20605
Signed-off-by: Arnold Moyaux <amoyaux@users.noreply.github.com>
This commit is contained in:
Arnold Moyaux
2021-09-04 11:58:36 +00:00
parent 48aa2445e8
commit 55588eba57
9 changed files with 169 additions and 76 deletions
+3 -2
View File
@@ -367,8 +367,9 @@ class StockMove(models.Model):
return True
return False
def _should_bypass_reservation(self):
res = super(StockMove, self)._should_bypass_reservation()
def _should_bypass_reservation(self, forced_location=False):
res = super(StockMove, self)._should_bypass_reservation(
forced_location=forced_location)
return bool(res and not self.production_id)
def _key_assign_picking(self):
@@ -5,7 +5,6 @@ from . import product
from . import res_company
from . import res_partner
from . import stock_move
from . import stock_move_line
from . import stock_picking
from . import stock_quant
from . import stock_rule
@@ -197,9 +197,9 @@ class StockMove(models.Model):
return False
return super()._should_bypass_set_qty_producing()
def _should_bypass_reservation(self):
def _should_bypass_reservation(self, forced_location=False):
""" If the move is subcontracted then ignore the reservation. """
should_bypass_reservation = super(StockMove, self)._should_bypass_reservation()
should_bypass_reservation = super()._should_bypass_reservation(forced_location=forced_location)
if not should_bypass_reservation and self.is_subcontract:
return True
return should_bypass_reservation
@@ -1,15 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
class StockMoveLine(models.Model):
_inherit = 'stock.move.line'
def _should_bypass_reservation(self, location):
""" If the move line is subcontracted then ignore the reservation. """
should_bypass_reservation = super(StockMoveLine, self)._should_bypass_reservation(location)
if not should_bypass_reservation and self.move_id.is_subcontract:
return True
return should_bypass_reservation
+66 -43
View File
@@ -1320,13 +1320,16 @@ class StockMove(models.Model):
self.env['stock.move.line'].create(self._prepare_move_line_vals(quantity=quantity, reserved_quant=reserved_quant))
return taken_quantity
def _should_bypass_reservation(self):
def _should_bypass_reservation(self, forced_location=False):
self.ensure_one()
return self.location_id.should_bypass_reservation() or self.product_id.type != 'product'
location = forced_location or self.location_id
return location.should_bypass_reservation() or self.product_id.type != 'product'
# necessary hook to be able to override move reservation to a restrict lot, owner, pack, location...
def _get_available_quantity(self, location_id, lot_id=None, package_id=None, owner_id=None, strict=False, allow_negative=False):
self.ensure_one()
if location_id.should_bypass_reservation():
return self.product_qty
return self.env['stock.quant']._get_available_quantity(self.product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict, allow_negative=allow_negative)
def _action_assign(self):
@@ -1335,6 +1338,46 @@ class StockMove(models.Model):
equal to its `product_qty`. If it is less, the stock move is considered
partially available.
"""
def _get_available_move_lines(move):
move_lines_in = move.move_orig_ids.filtered(lambda m: m.state == 'done').mapped('move_line_ids')
keys_in_groupby = ['location_dest_id', 'lot_id', 'result_package_id', 'owner_id']
def _keys_in_sorted(ml):
return (ml.location_dest_id.id, ml.lot_id.id, ml.result_package_id.id, ml.owner_id.id)
grouped_move_lines_in = {}
for k, g in groupby(sorted(move_lines_in, key=_keys_in_sorted), key=itemgetter(*keys_in_groupby)):
qty_done = 0
for ml in g:
qty_done += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id)
grouped_move_lines_in[k] = qty_done
move_lines_out_done = (move.move_orig_ids.mapped('move_dest_ids') - move)\
.filtered(lambda m: m.state in ['done'])\
.mapped('move_line_ids')
# As we defer the write on the stock.move's state at the end of the loop, there
# could be moves to consider in what our siblings already took.
moves_out_siblings = move.move_orig_ids.mapped('move_dest_ids') - move
moves_out_siblings_to_consider = moves_out_siblings & (StockMove.browse(assigned_moves_ids) + StockMove.browse(partially_available_moves_ids))
reserved_moves_out_siblings = moves_out_siblings.filtered(lambda m: m.state in ['partially_available', 'assigned'])
move_lines_out_reserved = (reserved_moves_out_siblings | moves_out_siblings_to_consider).mapped('move_line_ids')
keys_out_groupby = ['location_id', 'lot_id', 'package_id', 'owner_id']
def _keys_out_sorted(ml):
return (ml.location_id.id, ml.lot_id.id, ml.package_id.id, ml.owner_id.id)
grouped_move_lines_out = {}
for k, g in groupby(sorted(move_lines_out_done, key=_keys_out_sorted), key=itemgetter(*keys_out_groupby)):
qty_done = 0
for ml in g:
qty_done += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id)
grouped_move_lines_out[k] = qty_done
for k, g in groupby(sorted(move_lines_out_reserved, key=_keys_out_sorted), key=itemgetter(*keys_out_groupby)):
grouped_move_lines_out[k] = sum(self.env['stock.move.line'].concat(*list(g)).mapped('product_qty'))
available_move_lines = {key: grouped_move_lines_in[key] - grouped_move_lines_out.get(key, 0) for key in grouped_move_lines_in}
# pop key if the quantity available amount to 0
return dict((k, v) for k, v in available_move_lines.items() if v)
StockMove = self.env['stock.move']
assigned_moves_ids = OrderedSet()
partially_available_moves_ids = OrderedSet()
@@ -1349,10 +1392,26 @@ class StockMove(models.Model):
missing_reserved_quantity = move.product_uom._compute_quantity(missing_reserved_uom_quantity, move.product_id.uom_id, rounding_method='HALF-UP')
if move._should_bypass_reservation():
# create the move line(s) but do not impact quants
if move.product_id.tracking == 'serial' and (move.picking_type_id.use_create_lots or move.picking_type_id.use_existing_lots):
if move.move_orig_ids:
available_move_lines = _get_available_move_lines(move)
for (location_id, lot_id, package_id, owner_id), quantity in available_move_lines.items():
qty_added = min(missing_reserved_quantity, quantity)
move_line_vals = move._prepare_move_line_vals(qty_added)
move_line_vals.update({
'location_id': location_id.id,
'lot_id': lot_id.id,
'lot_name': lot_id.name,
'owner_id': owner_id.id,
})
move_line_vals_list.append(move_line_vals)
missing_reserved_quantity -= qty_added
if float_is_zero(missing_reserved_quantity, precision_rounding=move.product_id.uom_id.rounding):
break
if missing_reserved_quantity and move.product_id.tracking == 'serial' and (move.picking_type_id.use_create_lots or move.picking_type_id.use_existing_lots):
for i in range(0, int(missing_reserved_quantity)):
move_line_vals_list.append(move._prepare_move_line_vals(quantity=1))
else:
elif missing_reserved_quantity:
to_update = move.move_line_ids.filtered(lambda ml: ml.product_uom_id == move.product_uom and
ml.location_id == move.location_id and
ml.location_dest_id == move.location_dest_id and
@@ -1361,7 +1420,8 @@ class StockMove(models.Model):
not ml.package_id and
not ml.owner_id)
if to_update:
to_update[0].product_uom_qty += missing_reserved_uom_quantity
to_update[0].product_uom_qty += move.product_id.uom_id._compute_quantity(
missing_reserved_quantity, move.product_uom, rounding_method='HALF-UP')
else:
move_line_vals_list.append(move._prepare_move_line_vals(quantity=missing_reserved_quantity))
assigned_moves_ids.add(move.id)
@@ -1394,44 +1454,7 @@ class StockMove(models.Model):
# `qty_done` is in `ml.product_uom_id` and, as we will later increase
# the reserved quantity on the quants, convert it here in
# `product_id.uom_id` (the UOM of the quants is the UOM of the product).
move_lines_in = move.move_orig_ids.filtered(lambda m: m.state == 'done').mapped('move_line_ids')
keys_in_groupby = ['location_dest_id', 'lot_id', 'result_package_id', 'owner_id']
def _keys_in_sorted(ml):
return (ml.location_dest_id.id, ml.lot_id.id, ml.result_package_id.id, ml.owner_id.id)
grouped_move_lines_in = {}
for k, g in groupby(sorted(move_lines_in, key=_keys_in_sorted), key=itemgetter(*keys_in_groupby)):
qty_done = 0
for ml in g:
qty_done += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id)
grouped_move_lines_in[k] = qty_done
move_lines_out_done = (move.move_orig_ids.mapped('move_dest_ids') - move)\
.filtered(lambda m: m.state in ['done'])\
.mapped('move_line_ids')
# As we defer the write on the stock.move's state at the end of the loop, there
# could be moves to consider in what our siblings already took.
moves_out_siblings = move.move_orig_ids.mapped('move_dest_ids') - move
moves_out_siblings_to_consider = moves_out_siblings & (StockMove.browse(assigned_moves_ids) + StockMove.browse(partially_available_moves_ids))
reserved_moves_out_siblings = moves_out_siblings.filtered(lambda m: m.state in ['partially_available', 'assigned'])
move_lines_out_reserved = (reserved_moves_out_siblings | moves_out_siblings_to_consider).mapped('move_line_ids')
keys_out_groupby = ['location_id', 'lot_id', 'package_id', 'owner_id']
def _keys_out_sorted(ml):
return (ml.location_id.id, ml.lot_id.id, ml.package_id.id, ml.owner_id.id)
grouped_move_lines_out = {}
for k, g in groupby(sorted(move_lines_out_done, key=_keys_out_sorted), key=itemgetter(*keys_out_groupby)):
qty_done = 0
for ml in g:
qty_done += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id)
grouped_move_lines_out[k] = qty_done
for k, g in groupby(sorted(move_lines_out_reserved, key=_keys_out_sorted), key=itemgetter(*keys_out_groupby)):
grouped_move_lines_out[k] = sum(self.env['stock.move.line'].concat(*list(g)).mapped('product_qty'))
available_move_lines = {key: grouped_move_lines_in[key] - grouped_move_lines_out.get(key, 0) for key in grouped_move_lines_in.keys()}
# pop key if the quantity available amount to 0
available_move_lines = dict((k, v) for k, v in available_move_lines.items() if v)
available_move_lines = _get_available_move_lines(move)
if not available_move_lines:
continue
for move_line in move.move_line_ids.filtered(lambda m: m.product_qty):
+7 -11
View File
@@ -340,7 +340,7 @@ class StockMoveLine(models.Model):
new_product_uom_qty = ml.product_qty
# Unreserve the old charateristics of the move line.
if not ml._should_bypass_reservation(ml.location_id):
if not ml.move_id._should_bypass_reservation(ml.location_id):
try:
Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
except UserError:
@@ -351,7 +351,7 @@ class StockMoveLine(models.Model):
raise
# Reserve the maximum available of the new charateristics of the move line.
if not ml._should_bypass_reservation(updates.get('location_id', ml.location_id)):
if not ml.move_id._should_bypass_reservation(updates.get('location_id', ml.location_id)):
reserved_qty = 0
try:
q = Quant._update_reserved_quantity(ml.product_id, updates.get('location_id', ml.location_id), new_product_uom_qty, lot_id=updates.get('lot_id', ml.lot_id),
@@ -395,7 +395,7 @@ class StockMoveLine(models.Model):
result_package_id = updates.get('result_package_id', ml.result_package_id)
owner_id = updates.get('owner_id', ml.owner_id)
quantity = ml.move_id.product_uom._compute_quantity(qty_done, ml.move_id.product_id.uom_id, rounding_method='HALF-UP')
if not ml._should_bypass_reservation(location_id):
if not ml.move_id._should_bypass_reservation(location_id):
ml._free_reservation(product_id, location_id, quantity, lot_id=lot_id, package_id=package_id, owner_id=owner_id)
if not float_is_zero(quantity, precision_digits=precision):
available_qty, in_date = Quant._update_available_quantity(product_id, location_id, -quantity, lot_id=lot_id, package_id=package_id, owner_id=owner_id)
@@ -406,7 +406,7 @@ class StockMoveLine(models.Model):
taken_from_untracked_qty = min(untracked_qty, abs(available_qty))
Quant._update_available_quantity(product_id, location_id, -taken_from_untracked_qty, lot_id=False, package_id=package_id, owner_id=owner_id)
Quant._update_available_quantity(product_id, location_id, taken_from_untracked_qty, lot_id=lot_id, package_id=package_id, owner_id=owner_id)
if not ml._should_bypass_reservation(location_id):
if not ml.move_id._should_bypass_reservation(location_id):
ml._free_reservation(ml.product_id, location_id, untracked_qty, lot_id=False, package_id=package_id, owner_id=owner_id)
Quant._update_available_quantity(product_id, location_dest_id, quantity, lot_id=lot_id, package_id=result_package_id, owner_id=owner_id, in_date=in_date)
@@ -451,7 +451,7 @@ class StockMoveLine(models.Model):
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
for ml in self:
# Unlinking a move line should unreserve.
if ml.product_id.type == 'product' and not ml._should_bypass_reservation(ml.location_id) and not float_is_zero(ml.product_qty, precision_digits=precision):
if ml.product_id.type == 'product' and not ml.move_id._should_bypass_reservation(ml.location_id) and not float_is_zero(ml.product_qty, precision_digits=precision):
try:
self.env['stock.quant']._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
except UserError:
@@ -553,12 +553,12 @@ class StockMoveLine(models.Model):
rounding = ml.product_uom_id.rounding
# if this move line is force assigned, unreserve elsewhere if needed
if not ml._should_bypass_reservation(ml.location_id) and float_compare(ml.qty_done, ml.product_uom_qty, precision_rounding=rounding) > 0:
if not ml.move_id._should_bypass_reservation(ml.location_id) and float_compare(ml.qty_done, ml.product_uom_qty, precision_rounding=rounding) > 0:
qty_done_product_uom = ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id, rounding_method='HALF-UP')
extra_qty = qty_done_product_uom - ml.product_qty
ml._free_reservation(ml.product_id, ml.location_id, extra_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, ml_ids_to_ignore=ml_ids_to_ignore)
# unreserve what's been reserved
if not ml._should_bypass_reservation(ml.location_id) and ml.product_id.type == 'product' and ml.product_qty:
if not ml.move_id._should_bypass_reservation(ml.location_id) and ml.product_id.type == 'product' and ml.product_qty:
try:
Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
except UserError:
@@ -702,10 +702,6 @@ class StockMoveLine(models.Model):
self.env['stock.move.line'].browse(to_unlink_candidate_ids).unlink()
move_to_recompute_state._recompute_state()
def _should_bypass_reservation(self, location):
self.ensure_one()
return location.should_bypass_reservation() or self.product_id.type != 'product'
def _get_aggregated_product_quantities(self, **kwargs):
""" Returns a dictionary of products (key = id+name+description+uom) and corresponding values of interest.
+21 -1
View File
@@ -76,6 +76,7 @@ class Warehouse(models.Model):
out_type_id = fields.Many2one('stock.picking.type', 'Out Type', check_company=True)
in_type_id = fields.Many2one('stock.picking.type', 'In Type', check_company=True)
int_type_id = fields.Many2one('stock.picking.type', 'Internal Type', check_company=True)
return_type_id = fields.Many2one('stock.picking.type', 'Return Type', check_company=True)
crossdock_route_id = fields.Many2one('stock.location.route', 'Crossdock Route', ondelete='restrict')
reception_route_id = fields.Many2one('stock.location.route', 'Receipt Route', ondelete='restrict')
delivery_route_id = fields.Many2one('stock.location.route', 'Delivery Route', ondelete='restrict')
@@ -332,7 +333,7 @@ class Warehouse(models.Model):
warehouse_data[picking_type] = PickingType.create(values).id
if 'out_type_id' in warehouse_data:
PickingType.browse(warehouse_data['out_type_id']).write({'return_picking_type_id': warehouse_data.get('in_type_id', False)})
PickingType.browse(warehouse_data['out_type_id']).write({'return_picking_type_id': warehouse_data.get('return_type_id', False)})
if 'in_type_id' in warehouse_data:
PickingType.browse(warehouse_data['in_type_id']).write({'return_picking_type_id': warehouse_data.get('out_type_id', False)})
return warehouse_data
@@ -915,6 +916,10 @@ class Warehouse(models.Model):
'int_type_id': {
'barcode': self.code.replace(" ", "").upper() + "-INTERNAL",
},
'return_type_id': {
'default_location_dest_id': output_loc.id,
'barcode': self.code.replace(" ", "").upper() + "-RETURNS",
},
}
def _get_picking_type_create_values(self, max_sequence):
@@ -976,6 +981,16 @@ class Warehouse(models.Model):
'sequence': max_sequence + 2,
'sequence_code': 'INT',
'company_id': self.company_id.id,
}, 'return_type_id': {
'name': _('Returns'),
'code': 'incoming',
'use_create_lots': False,
'use_existing_lots': True,
'default_location_src_id': False,
'sequence': max_sequence + 6,
'show_reserved': True,
'sequence_code': 'IN',
'company_id': self.company_id.id,
},
}, max_sequence + 6
@@ -1009,6 +1024,11 @@ class Warehouse(models.Model):
'prefix': self.code + '/INT/', 'padding': 5,
'company_id': self.company_id.id,
},
'return_type_id': {
'name': self.name + ' ' + _('Sequence return'),
'prefix': self.code + '/RET/', 'padding': 5,
'company_id': self.company_id.id,
},
}
def _format_rulename(self, from_loc, dest_loc, suffix):
+69
View File
@@ -740,6 +740,75 @@ class TestPickShip(TestStockCommon):
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.productA, return_location), 5.0)
self.assertEqual(len(self.env['stock.quant'].search([('product_id', '=', self.productA.id), ('quantity', '!=', 0)])), 2)
def test_return_lot(self):
""" With two distinct deliveries for the same product tracked by lot, ensure that the
return of the second picking suggest the lot from the picking returned.
"""
self.productA.tracking = 'lot'
lot1 = self.env['stock.production.lot'].create({
'name': 'lot1',
'product_id': self.productA.id,
'company_id': self.env.company.id,
})
lot2 = self.env['stock.production.lot'].create({
'name': 'lot2',
'product_id': self.productA.id,
'company_id': self.env.company.id,
})
lot3 = self.env['stock.production.lot'].create({
'name': 'lot3',
'product_id': self.productA.id,
'company_id': self.env.company.id,
})
stock_location = self.env['stock.location'].browse(self.stock_location)
self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 7.0, lot_id=lot1)
self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 7.0, lot_id=lot2)
self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 7.0, lot_id=lot3)
picking_pick, picking_client = self.create_pick_ship()
picking_pick.action_confirm()
picking_pick.action_assign()
for move_line in picking_pick.move_line_ids:
move_line.qty_done = move_line.product_uom_qty
picking_pick._action_done()
picking_client.action_confirm()
picking_client.action_assign()
for move_line in picking_client.move_line_ids:
move_line.qty_done = move_line.product_uom_qty
picking_client._action_done()
picking_pick, picking_client = self.create_pick_ship()
picking_pick.action_confirm()
picking_pick.action_assign()
for move_line in picking_pick.move_line_ids:
move_line.qty_done = move_line.product_uom_qty
picking_pick._action_done()
picking_client.action_confirm()
picking_client.action_assign()
for move_line in picking_client.move_line_ids:
move_line.qty_done = move_line.product_uom_qty
picking_client._action_done()
# Following FIFO strategy, First picking should have empty lot1 and took 3 of lot2.
# So the second picking contains 4 lot2 and 6 lot3
self.assertEqual(picking_client.move_line_ids[0].lot_id, lot2)
self.assertEqual(picking_client.move_line_ids[0].qty_done, 4)
self.assertEqual(picking_client.move_line_ids[1].lot_id, lot3)
self.assertEqual(picking_client.move_line_ids[1].qty_done, 6)
stock_return_picking_form = Form(self.env['stock.return.picking'].with_context(
active_ids=picking_client.ids, active_id=picking_client.ids[0], active_model='stock.picking'))
stock_return_picking = stock_return_picking_form.save()
stock_return_picking.product_return_moves.quantity = 10.0
return_pick = self.env['stock.picking'].browse(stock_return_picking.create_returns()['res_id'])
self.assertEqual(len(return_pick.move_line_ids), 2)
self.assertEqual(return_pick.move_line_ids[0].lot_id, lot2)
self.assertEqual(return_pick.move_line_ids[0].product_uom_qty, 4)
self.assertEqual(return_pick.move_line_ids[1].lot_id, lot3)
self.assertEqual(return_pick.move_line_ids[1].product_uom_qty, 6)
self.assertEqual(return_pick.picking_type_id, picking_client.location_id.warehouse_id.return_type_id)
class TestSinglePicking(TestStockCommon):
def test_backorder_1(self):
+1 -1
View File
@@ -218,7 +218,7 @@ class TestPacking(TestPackingCommon):
})
picking = self.env['stock.picking'].create({
'picking_type_id': self.warehouse.in_type_id.id,
'location_id': self.stock_location.id,
'location_id': self.customer_location.id,
'location_dest_id': self.stock_location.id,
'state': 'draft',
})