[FIX] stock: create/write on stock.move.lineresponssible for quant reservation

A lot of issue with "It is not possible to unreserve more products of ... than you have in stock".
It's the result of a desynchronisation between
`stock.move.line`.`reserved_qty` and `stock.quant`.`reserved_quantity` fields.

It should never happens in theory. However we already faced it a lot due
to bugs/custom code/server actions... It's not trivial to fix because
the issue come from data corruption. So it is hard to spot the different
main issues.

In order to avoid it, this commit try to modify the structure of stock
module. Before the operations was made in this order:
- The `stock.move` checks the quantity available on `stock.quant`
- The `stock.move` write the quantity reserved on the `stock.quant` and
  save it in a variable
- The `stock.move` create a `stock.move.line` with the quanity reserved.
The main issue is that a `stock.move.line` could be easily created with
a reserved quantity while the `stock.quant` are not update nor checked.

After this commit the operations will be:
- The `stock.move` checks the quantity available
- The `stock.move` dispatch the available quantity among the `stock.move.line`
  based on create or write calls.
- The `stock.move.line` reserve the quantity on `stock.quant`
- If the quantity is bigger than available, we write the max available on
  `stock.move.line`

The idea is to respect the different layers
`stock.move` <-> `stock.move.line` <-> `stock.quant`.
Avoid the interactions bewteen `stock.move` and `stock.quant`

This behavior is already well managed in other use cases.
E.g. the real quantity itself, `_do_unreserve` of `stock.move`,...

opw - a lot

Part-of: odoo/odoo#115328
This commit is contained in:
Arnold Moyaux
2023-03-17 19:22:13 +01:00
parent 38f463af32
commit 7d9d6e8834
16 changed files with 146 additions and 113 deletions
+7 -3
View File
@@ -1695,7 +1695,7 @@ class MrpProduction(models.Model):
# Unreserve the quantity removed from initial `stock.move.line` and
# not assigned to a move anymore. In case of a split smaller than initial
# quantity and fully reserved
if quantity:
if quantity and not move_line.move_id._should_bypass_reservation():
self.env['stock.quant']._update_reserved_quantity(
move_line.product_id, move_line.location_id, -quantity,
lot_id=move_line.lot_id, package_id=move_line.package_id,
@@ -1712,7 +1712,7 @@ class MrpProduction(models.Model):
# Avoid triggering a useless _recompute_state
self.env['stock.move.line'].browse(move_lines_to_unlink).write({'move_id': False})
self.env['stock.move.line'].browse(move_lines_to_unlink).unlink()
self.env['stock.move.line'].create(move_lines_vals)
self.env['stock.move.line'].with_context(bypass_reservation_update=True).create(move_lines_vals)
workorders_to_cancel = self.env['mrp.workorder']
for production in self:
@@ -1851,7 +1851,7 @@ class MrpProduction(models.Model):
order._check_sn_uniqueness()
def do_unreserve(self):
self.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel'))._do_unreserve()
(self.move_finished_ids | self.move_raw_ids).filtered(lambda x: x.state not in ('done', 'cancel'))._do_unreserve()
def button_scrap(self):
self.ensure_one()
@@ -2083,6 +2083,8 @@ class MrpProduction(models.Model):
if move.has_tracking != 'serial' or move.product_id == self.product_id:
continue
for move_line in move.move_line_ids:
if float_is_zero(move_line.qty_done, precision_rounding=move_line.product_uom_id.rounding):
continue
if self._is_finished_sn_already_produced(move_line.lot_id, excluded_sml=move_line):
raise UserError(_('The serial number %(number)s used for byproduct %(product_name)s has already been produced',
number=move_line.lot_id.name, product_name=move_line.product_id.name))
@@ -2129,6 +2131,8 @@ class MrpProduction(models.Model):
raise UserError(message)
def _is_finished_sn_already_produced(self, lot, excluded_sml=None):
if not lot:
return False
excluded_sml = excluded_sml or self.env['stock.move.line']
domain = [
('lot_id', '=', lot.id),
-5
View File
@@ -478,11 +478,6 @@ class StockMove(models.Model):
return True
return False
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):
keys = super(StockMove, self)._key_assign_picking()
return keys + (self.created_production_id,)
+4 -4
View File
@@ -1005,19 +1005,19 @@ class TestMrpOrder(TestMrpCommon):
self.assertEqual(len(move_byproduct_1), 1)
self.assertEqual(move_byproduct_1.product_uom_qty, 2.0)
self.assertEqual(move_byproduct_1.quantity_done, 0)
self.assertEqual(len(move_byproduct_1.move_line_ids), 0)
self.assertEqual(len(move_byproduct_1.move_line_ids), 2)
move_byproduct_2 = mo.move_finished_ids.filtered(lambda l: l.product_id == self.byproduct2)
self.assertEqual(len(move_byproduct_2), 1)
self.assertEqual(move_byproduct_2.product_uom_qty, 4.0)
self.assertEqual(move_byproduct_2.quantity_done, 0)
self.assertEqual(len(move_byproduct_2.move_line_ids), 0)
self.assertEqual(len(move_byproduct_2.move_line_ids), 1)
move_byproduct_3 = mo.move_finished_ids.filtered(lambda l: l.product_id == self.byproduct3)
self.assertEqual(move_byproduct_3.product_uom_qty, 4.0)
self.assertEqual(move_byproduct_3.quantity_done, 0)
self.assertEqual(move_byproduct_3.product_uom, dozen)
self.assertEqual(len(move_byproduct_3.move_line_ids), 0)
self.assertEqual(len(move_byproduct_3.move_line_ids), 1)
mo_form = Form(mo)
mo_form.qty_producing = 1.0
@@ -1043,7 +1043,7 @@ class TestMrpOrder(TestMrpCommon):
ml.qty_done = 1
details_operation_form.save()
details_operation_form = Form(move_byproduct_2, view=self.env.ref('stock.view_stock_move_operations'))
with details_operation_form.move_line_ids.new() as ml:
with details_operation_form.move_line_ids.edit(0) as ml:
ml.lot_id = self.lot_1
ml.qty_done = 2
details_operation_form.save()
@@ -485,7 +485,7 @@ class TestMultistepManufacturingWarehouse(TestMrpCommon):
"""
self.warehouse.mto_pull_id.route_id.active = True
# Creating complex product which trigger another manifacture
# Creating complex product which trigger another manufacture
routes = self.warehouse.manufacture_pull_id.route_id + self.warehouse.mto_pull_id.route_id
self.complex_product = self.env['product.product'].create({
@@ -525,6 +525,7 @@ class TestMultistepManufacturingWarehouse(TestMrpCommon):
production_form.picking_type_id = self.warehouse.manu_type_id
production = production_form.save()
production.action_confirm()
self.env.invalidate_all()
move_raw_ids = production.move_raw_ids
self.assertEqual(len(move_raw_ids), 2)
+2 -1
View File
@@ -45,7 +45,8 @@ class ChangeProductionQty(models.TransientModel):
move.write({'product_uom_qty': move.product_uom_qty + qty})
if push_moves:
push_moves._action_confirm()._action_assign()
push_moves._action_confirm()
production.move_finished_ids._action_assign()
return modification
@@ -137,11 +137,11 @@ class MrpProduction(models.Model):
'qty_done': new_quantity_done,
'lot_id': self.lot_producing_id and self.lot_producing_id.id,
}
ml.copy(default=default)
ml.with_context(bypass_reservation_update=True).write({
ml.write({
'reserved_uom_qty': new_qty_reserved,
'qty_done': 0
})
ml.copy(default=default)
if float_compare(quantity, 0, precision_rounding=self.product_uom_id.rounding) > 0:
self.env['stock.move.line'].create({
@@ -70,6 +70,7 @@ class StockPicking(models.Model):
amounts = [move_line.qty_done for move_line in move.move_line_ids]
len_amounts = len(amounts)
productions = production._split_productions({production: amounts}, set_consumed_qty=True)
productions.move_finished_ids.move_line_ids.write({'qty_done': 0})
for production, move_line in zip(productions, move.move_line_ids):
if move_line.lot_id:
production.lot_producing_id = move_line.lot_id
+1 -2
View File
@@ -1262,10 +1262,9 @@ class TestSaleStock(TestSaleCommon, ValuationReconciliationTestCommon):
so = so_form.save()
so.action_confirm()
pick_picking, pack_picking, _ = so.picking_ids
_, pack_picking, pick_picking = so.picking_ids
(pick_picking + pack_picking).move_ids.quantity_done = 5
(pick_picking + pack_picking).button_validate()
with Form(so) as so_form:
with so_form.order_line.edit(0) as line:
line.product_uom_qty = 3
+9 -15
View File
@@ -1445,6 +1445,7 @@ Please change the quantity done or the rounding precision of your unit of measur
'company_id': self.company_id.id,
}
if quantity:
# TODO could be also move in create/write
rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure')
uom_quantity = self.product_id.uom_id._compute_quantity(quantity, self.product_uom, rounding_method='HALF-UP')
uom_quantity = float_round(uom_quantity, precision_digits=rounding)
@@ -1502,18 +1503,13 @@ Please change the quantity done or the rounding precision of your unit of measur
if float_compare(taken_quantity, int(taken_quantity), precision_digits=rounding) != 0:
taken_quantity = 0
try:
with self.env.cr.savepoint():
if not float_is_zero(taken_quantity, precision_rounding=self.product_id.uom_id.rounding):
quants = self.env['stock.quant']._update_reserved_quantity(
self.product_id, location_id, taken_quantity, lot_id=lot_id,
package_id=package_id, owner_id=owner_id, strict=strict
)
except UserError:
taken_quantity = 0
if not float_is_zero(taken_quantity, precision_rounding=self.product_id.uom_id.rounding):
quants = self.env['stock.quant']._get_reserve_quantity(
self.product_id, location_id, taken_quantity, lot_id=lot_id,
package_id=package_id, owner_id=owner_id, strict=strict
)
# Find a candidate move line to update or create a new one.
serial_move_line_vals = []
for reserved_quant, quantity in quants:
to_update = next((line for line in self.move_line_ids if line._reservation_is_updatable(quantity, reserved_quant)), False)
if to_update:
@@ -1521,14 +1517,12 @@ Please change the quantity done or the rounding precision of your unit of measur
uom_quantity = float_round(uom_quantity, precision_digits=rounding)
uom_quantity_back_to_product_uom = to_update.product_uom_id._compute_quantity(uom_quantity, self.product_id.uom_id, rounding_method='HALF-UP')
if to_update and float_compare(quantity, uom_quantity_back_to_product_uom, precision_digits=rounding) == 0:
to_update.with_context(bypass_reservation_update=True).reserved_uom_qty += uom_quantity
to_update.with_context(reserved_quant=reserved_quant).reserved_uom_qty += uom_quantity
else:
if self.product_id.tracking == 'serial':
# Move lines with serial tracked product_id cannot be to-update candidates. Delay the creation to speed up candidates search + create.
serial_move_line_vals.extend([self._prepare_move_line_vals(quantity=1, reserved_quant=reserved_quant) for i in range(int(quantity))])
self.env['stock.move.line'].with_context(reserved_quant=reserved_quant).create([self._prepare_move_line_vals(quantity=1, reserved_quant=reserved_quant) for i in range(int(quantity))])
else:
self.env['stock.move.line'].create(self._prepare_move_line_vals(quantity=quantity, reserved_quant=reserved_quant))
self.env['stock.move.line'].create(serial_move_line_vals)
self.env['stock.move.line'].with_context(reserved_quant=reserved_quant).create(self._prepare_move_line_vals(quantity=quantity, reserved_quant=reserved_quant))
return taken_quantity
def _should_bypass_reservation(self, forced_location=False):
+43 -30
View File
@@ -311,6 +311,26 @@ class StockMoveLine(models.Model):
else:
create_move(move_line)
for move_line in mls:
reserved_uom_qty = move_line.reserved_uom_qty
location = move_line.location_id
product = move_line.product_id
move = move_line.move_id
if move:
bypass_reservation = not move._should_bypass_reservation()
else:
bypass_reservation = product.type == 'product' and not location.should_bypass_reservation()
if reserved_uom_qty and not self.env.context.get('bypass_reservation_update') and bypass_reservation:
ml_uom = move_line.product_uom_id
reserved_qty = ml_uom._compute_quantity(reserved_uom_qty, product.uom_id, rounding_method='HALF-UP')
reserved_quants = self.env.context.get('reserved_quant', self.env['stock.quant'])._update_reserved_quantity(
product, location, reserved_qty, lot_id=move_line.lot_id, package_id=move_line.package_id, owner_id=move_line.owner_id, strict=True)
if not reserved_quants or float_compare(reserved_qty, sum(map(lambda q: q[1], reserved_quants)), product.uom_id.rounding) != 0:
reserved_uom_qty = product.uom_id._compute_quantity(reserved_qty, ml_uom, rounding_method='HALF-UP')
move_line.with_context(bypass_reservation=True).reserved_uom_qty = reserved_uom_qty
for ml, vals in zip(mls, vals_list):
if ml.move_id and \
ml.move_id.picking_id and \
@@ -340,9 +360,10 @@ class StockMoveLine(models.Model):
return mls
def write(self, vals):
# Very dangerous key. It could create some desynchronization between `stock.move.line` and `stock.quant`.
# It exists for performances purposes. Only use it if you handle `stock.quant` reservation manualy.
if self.env.context.get('bypass_reservation_update'):
return super(StockMoveLine, self).write(vals)
return super().write(vals)
if 'product_id' in vals and any(vals.get('state', ml.state) != 'draft' and vals['product_id'] != ml.product_id.id for ml in self):
raise UserError(_("Changing the product is only allowed in 'Draft' state."))
@@ -382,40 +403,35 @@ class StockMoveLine(models.Model):
# the quants). If the new charateristics are not available on the quants, we chose to
# reserve the maximum possible.
if updates or 'reserved_uom_qty' in vals:
for ml in self.filtered(lambda ml: ml.state in ['partially_available', 'assigned', 'confirmed'] and ml.product_id.type == 'product'):
for ml in self:
if ml.product_id.type != 'product':
continue
if 'reserved_uom_qty' in vals:
new_reserved_uom_qty = ml.product_uom_id._compute_quantity(
new_reserved_qty = ml.product_uom_id._compute_quantity(
vals['reserved_uom_qty'], ml.product_id.uom_id, rounding_method='HALF-UP')
# Make sure `reserved_uom_qty` is not negative.
if float_compare(new_reserved_uom_qty, 0, precision_rounding=ml.product_id.uom_id.rounding) < 0:
if float_compare(new_reserved_qty, 0, precision_rounding=ml.product_id.uom_id.rounding) < 0:
raise UserError(_('Reserving a negative quantity is not allowed.'))
else:
new_reserved_uom_qty = ml.reserved_qty
new_reserved_qty = ml.reserved_qty
# Unreserve the old charateristics of the move line.
if not ml.move_id._should_bypass_reservation(ml.location_id):
Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.reserved_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
if not float_is_zero(ml.reserved_qty, precision_rounding=ml.product_uom_id.rounding):
if not ml.move_id._should_bypass_reservation(ml.location_id):
Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.reserved_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
# Reserve the maximum available of the new charateristics of the move line.
reserved_qty = new_reserved_qty
if not ml.move_id._should_bypass_reservation(updates.get('location_id', ml.location_id)):
reserved_qty = 0
try:
available_qty = Quant._get_available_quantity(ml.product_id, updates.get('location_id', ml.location_id), lot_id=updates.get('lot_id', ml.lot_id),
package_id=updates.get('package_id', ml.package_id), owner_id=updates.get('owner_id', ml.owner_id), strict=True)
to_reserve = min(available_qty, new_reserved_uom_qty)
q = []
if to_reserve:
q = Quant._update_reserved_quantity(ml.product_id, updates.get('location_id', ml.location_id), to_reserve, lot_id=updates.get('lot_id', ml.lot_id),
package_id=updates.get('package_id', ml.package_id), owner_id=updates.get('owner_id', ml.owner_id), strict=True)
reserved_qty = sum([x[1] for x in q])
except UserError:
pass
if reserved_qty != new_reserved_uom_qty:
new_reserved_uom_qty = ml.product_id.uom_id._compute_quantity(reserved_qty, ml.product_uom_id, rounding_method='HALF-UP')
ml.with_context(bypass_reservation_update=True).reserved_uom_qty = new_reserved_uom_qty
# we don't want to override the new reserved quantity
vals.pop('reserved_uom_qty', None)
q = Quant._update_reserved_quantity(ml.product_id, updates.get('location_id', ml.location_id), new_reserved_qty, lot_id=updates.get('lot_id', ml.lot_id),
package_id=updates.get('package_id', ml.package_id), owner_id=updates.get('owner_id', ml.owner_id), strict=True)
reserved_qty = sum([x[1] for x in q])
if reserved_qty != new_reserved_qty:
reserved_uom_qty = ml.product_id.uom_id._compute_quantity(reserved_qty, ml.product_uom_id, rounding_method='HALF-UP')
vals['reserved_uom_qty'] = reserved_uom_qty
if 'reserved_uom_qty' in vals and vals['reserved_uom_qty'] != ml.reserved_uom_qty:
moves_to_recompute_state |= ml.move_id
# When editing a done move line, the reserved availability of a potential chained move is impacted. Take care of running again `_action_assign` on the concerned moves.
@@ -593,9 +609,6 @@ class StockMoveLine(models.Model):
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.reserved_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.move_id._should_bypass_reservation(ml.location_id) and ml.product_id.type == 'product' and ml.reserved_qty:
Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.reserved_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
# move what's been actually done
quantity = ml.product_uom_id._compute_quantity(ml.qty_done, ml.move_id.product_id.uom_id, rounding_method='HALF-UP')
@@ -610,7 +623,7 @@ class StockMoveLine(models.Model):
Quant._update_available_quantity(ml.product_id, ml.location_dest_id, quantity, lot_id=ml.lot_id, package_id=ml.result_package_id, owner_id=ml.owner_id, in_date=in_date)
ml_ids_to_ignore.add(ml.id)
# Reset the reserved quantity as we just moved it to the destination location.
mls_todo.with_context(bypass_reservation_update=True).write({
mls_todo.write({
'reserved_uom_qty': 0.00,
'date': fields.Datetime.now(),
})
+61 -38
View File
@@ -4,6 +4,7 @@
import logging
from ast import literal_eval
from collections import defaultdict
from psycopg2 import Error
from odoo import _, api, fields, models
@@ -625,6 +626,62 @@ class StockQuant(models.Model):
else:
return sum([available_quantity for available_quantity in availaible_quantities.values() if float_compare(available_quantity, 0, precision_rounding=rounding) > 0])
def _get_reserve_quantity(self, product_id, location_id, quantity, lot_id=None, package_id=None, owner_id=None, strict=False):
""" Get the quantity available to reserve for the set of quants
sharing the combination of `product_id, location_id` if `strict` is set to False or sharing
the *exact same characteristics* otherwise. Typically, this method is called before the
`stock.move.line` creation to know the reserved_qty that could be use. It's also call by
`_update_reserve_quantity` to find the quant to reserve.
:return: a list of tuples (quant, quantity_reserved) showing on which quant the reservation
could be done and how much the system is able to reserve on it
"""
self = self.sudo()
rounding = product_id.uom_id.rounding
quants = self or self._gather(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict)
reserved_quants = []
if float_compare(quantity, 0, precision_rounding=rounding) > 0:
# if we want to reserve
available_quantity = sum(quants.filtered(lambda q: float_compare(q.quantity, 0, precision_rounding=rounding) > 0).mapped('quantity')) - sum(quants.mapped('reserved_quantity'))
elif float_compare(quantity, 0, precision_rounding=rounding) < 0:
# if we want to unreserve
available_quantity = sum(quants.mapped('reserved_quantity'))
if float_compare(abs(quantity), available_quantity, precision_rounding=rounding) > 0:
raise UserError(_('It is not possible to unreserve more products of %s than you have in stock.', product_id.display_name))
else:
return reserved_quants
negative_reserved_quantity = defaultdict(float)
for quant in quants:
if float_compare(quant.quantity - quant.reserved_quantity, 0, precision_rounding=rounding) < 0:
negative_reserved_quantity[(quant.location_id, quant.lot_id, quant.package_id, quant.owner_id)] += quant.quantity - quant.reserved_quantity
for quant in quants:
if float_compare(quantity, 0, precision_rounding=rounding) > 0:
max_quantity_on_quant = quant.quantity - quant.reserved_quantity
if float_compare(max_quantity_on_quant, 0, precision_rounding=rounding) <= 0:
continue
negative_quantity = negative_reserved_quantity[(quant.location_id, quant.lot_id, quant.package_id, quant.owner_id)]
if negative_quantity:
negative_qty_to_remove = min(abs(negative_quantity), max_quantity_on_quant)
negative_reserved_quantity[(quant.location_id, quant.lot_id, quant.package_id, quant.owner_id)] += negative_qty_to_remove
max_quantity_on_quant -= negative_qty_to_remove
if float_compare(max_quantity_on_quant, 0, precision_rounding=rounding) <= 0:
continue
max_quantity_on_quant = min(max_quantity_on_quant, quantity)
reserved_quants.append((quant, max_quantity_on_quant))
quantity -= max_quantity_on_quant
available_quantity -= max_quantity_on_quant
else:
max_quantity_on_quant = min(quant.reserved_quantity, abs(quantity))
reserved_quants.append((quant, -max_quantity_on_quant))
quantity += max_quantity_on_quant
available_quantity += max_quantity_on_quant
if float_is_zero(quantity, precision_rounding=rounding) or float_is_zero(available_quantity, precision_rounding=rounding):
break
return reserved_quants
@api.onchange('location_id', 'product_id', 'lot_id', 'package_id', 'owner_id')
def _onchange_location_or_product_id(self):
vals = {}
@@ -773,7 +830,6 @@ class StockQuant(models.Model):
})
return self._get_available_quantity(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=False, allow_negative=True), in_date
@api.model
def _update_reserved_quantity(self, product_id, location_id, quantity, lot_id=None, package_id=None, owner_id=None, strict=False):
""" Increase the reserved quantity, i.e. increase `reserved_quantity` for the set of quants
sharing the combination of `product_id, location_id` if `strict` is set to False or sharing
@@ -787,43 +843,10 @@ class StockQuant(models.Model):
was done and how much the system was able to reserve on it
"""
self = self.sudo()
rounding = product_id.uom_id.rounding
quants = self._gather(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict)
reserved_quants = []
if float_compare(quantity, 0, precision_rounding=rounding) > 0:
# if we want to reserve
available_quantity = sum(quants.filtered(lambda q: float_compare(q.quantity, 0, precision_rounding=rounding) > 0).mapped('quantity')) - sum(quants.mapped('reserved_quantity'))
if float_compare(quantity, available_quantity, precision_rounding=rounding) > 0:
raise UserError(_('It is not possible to reserve more products of %s than you have in stock.', product_id.display_name))
elif float_compare(quantity, 0, precision_rounding=rounding) < 0:
# if we want to unreserve
available_quantity = sum(quants.mapped('reserved_quantity'))
if float_compare(abs(quantity), available_quantity, precision_rounding=rounding) > 0:
raise UserError(_('It is not possible to unreserve more products of %s than you have in stock.', product_id.display_name))
else:
return reserved_quants
for quant in quants:
if float_compare(quantity, 0, precision_rounding=rounding) > 0:
max_quantity_on_quant = quant.quantity - quant.reserved_quantity
if float_compare(max_quantity_on_quant, 0, precision_rounding=rounding) <= 0:
continue
max_quantity_on_quant = min(max_quantity_on_quant, quantity)
quant.reserved_quantity += max_quantity_on_quant
reserved_quants.append((quant, max_quantity_on_quant))
quantity -= max_quantity_on_quant
available_quantity -= max_quantity_on_quant
else:
max_quantity_on_quant = min(quant.reserved_quantity, abs(quantity))
quant.reserved_quantity -= max_quantity_on_quant
reserved_quants.append((quant, -max_quantity_on_quant))
quantity += max_quantity_on_quant
available_quantity += max_quantity_on_quant
if float_is_zero(quantity, precision_rounding=rounding) or float_is_zero(available_quantity, precision_rounding=rounding):
break
return reserved_quants
quants_to_reserve = self._get_reserve_quantity(product_id, location_id, quantity, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict)
for quant, qty_to_reserve in quants_to_reserve:
quant.reserved_quantity += qty_to_reserve
return quants_to_reserve
@api.model
def _unlink_zero_quants(self):
@@ -36,6 +36,7 @@ class StockGenerate(TransactionCase):
def get_new_move(self, nbre_of_lines):
move_lines_val = []
self.env['stock.quant']._update_available_quantity(self.product_serial, self.location, nbre_of_lines)
for i in range(nbre_of_lines):
move_lines_val.append({
'product_id': self.product_serial.id,
+1
View File
@@ -393,6 +393,7 @@ class TestInventory(TransactionCase):
'product_uom': self.uom_unit.id,
'product_uom_qty': 4.0,
})
quant.invalidate_recordset()
move_out._action_confirm()
move_out._action_assign()
move_out.move_line_ids.qty_done = 4
+4 -3
View File
@@ -3612,8 +3612,9 @@ class StockMove(TransactionCase):
def test_edit_reserved_move_line_9(self):
"""
When writing on the reserved quantity on the SML, a process tries to
reserve the quants with that new quantity. If the written quantity is
more than actually available, this quantity should be set to the available quantity.
reserve the quants with that new quantity. If it fails (for instance
because the written quantity is more than actually available), it should
take the maximum available.
"""
self.env['stock.quant']._update_available_quantity(self.product, self.stock_location, 1.0)
@@ -3632,7 +3633,7 @@ class StockMove(TransactionCase):
out_move.move_line_ids.reserved_uom_qty = 2
self.assertTrue(out_move.move_line_ids)
self.assertEqual(out_move.move_line_ids.reserved_uom_qty, 1, "The reserved quantity should be what is available")
self.assertEqual(out_move.move_line_ids.reserved_uom_qty, 1, "The maximum available still one")
def test_edit_done_move_line_1(self):
""" Test that editing a done stock move line linked to an untracked product correctly and
+5 -6
View File
@@ -410,15 +410,15 @@ class StockQuant(TransactionCase):
})
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product, self.stock_location), 0.0)
self.assertEqual(len(self.gather_relevant(self.product, self.stock_location)), 2)
with self.assertRaises(UserError):
self.env['stock.quant']._update_reserved_quantity(self.product, self.stock_location, 10.0)
reserved_quants = self.env['stock.quant']._update_reserved_quantity(self.product, self.stock_location, 10.0)
self.assertFalse(reserved_quants)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product, self.stock_location), 0.0)
def test_increase_reserved_quantity_5(self):
""" Decrease the available quantity when no quant are in a location.
"""
with self.assertRaises(UserError):
self.env['stock.quant']._update_reserved_quantity(self.product, self.stock_location, 1.0)
reserved_quants = self.env['stock.quant']._update_reserved_quantity(self.product, self.stock_location, 1.0)
self.assertFalse(reserved_quants)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product, self.stock_location), 0.0)
def test_decrease_reserved_quantity_1(self):
@@ -437,8 +437,7 @@ class StockQuant(TransactionCase):
def test_increase_decrease_reserved_quantity_1(self):
""" Decrease then increase reserved quantity when no quant are in a location.
"""
with self.assertRaises(UserError):
self.env['stock.quant']._update_reserved_quantity(self.product, self.stock_location, 1.0)
self.env['stock.quant']._update_reserved_quantity(self.product, self.stock_location, 1.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product, self.stock_location), 0.0)
with self.assertRaises(UserError):
self.env['stock.quant']._update_reserved_quantity(self.product, self.stock_location, -1.0, strict=True)
+3 -3
View File
@@ -1319,7 +1319,7 @@ class TestStockFlow(TestStockCommon):
pack2 = pack_obj.create({'name': 'PACKINOUTTEST2'})
picking_in.move_line_ids[0].result_package_id = pack1
picking_in.move_line_ids[0].qty_done = 4
packop2 = picking_in.move_line_ids[0].with_context(bypass_reservation_update=True).copy({'reserved_uom_qty': 0})
packop2 = picking_in.move_line_ids[0].copy({'reserved_uom_qty': 0})
packop2.qty_done = 6
packop2.result_package_id = pack2
picking_in._action_done()
@@ -1343,7 +1343,7 @@ class TestStockFlow(TestStockCommon):
picking_out.action_confirm()
picking_out.action_assign()
packout1 = picking_out.move_line_ids[0]
packout2 = picking_out.move_line_ids[0].with_context(bypass_reservation_update=True).copy({'reserved_uom_qty': 0})
packout2 = picking_out.move_line_ids[0].copy({'reserved_uom_qty': 0})
packout1.qty_done = 2
packout1.package_id = pack1
packout2.package_id = pack2
@@ -1377,7 +1377,7 @@ class TestStockFlow(TestStockCommon):
pack2 = pack_obj.create({'name': 'PACKINOUTTEST2'})
picking_in.move_line_ids[0].result_package_id = pack1
picking_in.move_line_ids[0].qty_done = 120
packop2 = picking_in.move_line_ids[0].with_context(bypass_reservation_update=True).copy({'reserved_uom_qty': 0})
packop2 = picking_in.move_line_ids[0].copy({'reserved_uom_qty': 0})
packop2.qty_done = 80
packop2.result_package_id = pack2
picking_in._action_done()