[IMP] stock: stock.move.line flexible reservation

This commit allows the user to choose which quants to reserve from stock
and the quantity to reserve from each quant, making reservation much more
flexible from the picking form.

In addition, the `reserved_uom_qty` of stock.move.line
is now editable in views to allow for users to change the qty reserved
by existing move lines. When editing the reserved qty, if quantity is not available,
it will reserve only what is available..

closes odoo/odoo#106006

Taskid: 3090913
Signed-off-by: Arnold Moyaux (arm) <arm@odoo.com>
This commit is contained in:
Ahmed Khalaf
2023-03-07 10:06:00 +01:00
parent a19c29c6fd
commit c2c1fa2811
16 changed files with 262 additions and 12 deletions
+1
View File
@@ -24,6 +24,7 @@ Also implements the removal strategy First Expiry First Out (FEFO) widely used,
'views/stock_move_views.xml',
'views/stock_quant_views.xml',
'wizard/confirm_expiry_view.xml',
'wizard/stock_quant_reserve.xml',
'report/report_deliveryslip.xml',
'report/report_lot_barcode.xml',
'report/report_package_barcode.xml',
+1
View File
@@ -1,3 +1,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import confirm_expiry
from . import stock_quant_reserve
@@ -0,0 +1,13 @@
from odoo import fields, models
class ReserveStockQuantLine(models.TransientModel):
_inherit = 'stock.quant.reserve.line'
expiration_date = fields.Datetime(related='lot_id.expiration_date')
class ReserveStockQuant(models.TransientModel):
_inherit = 'stock.quant.reserve'
show_expiry = fields.Boolean(related='move_id.product_id.use_expiration_date')
@@ -0,0 +1,15 @@
<odoo>
<record id="stock_quant_reserve_inherit" model="ir.ui.view">
<field name="name">stock.quant.reserve.inherit</field>
<field name="model">stock.quant.reserve</field>
<field name="inherit_id" ref="stock.view_stock_quant_reserve_form"/>
<field name="arch" type="xml">
<field name="move_id" position="after">
<field name="show_expiry" invisible="1"/>
</field>
<field name="lot_id" position='after'>
<field name="expiration_date" groups="stock.group_production_lot" attrs="{'column_invisible': [('parent.show_expiry', '=', False)]}"/>
</field>
</field>
</record>
</odoo>
+1
View File
@@ -65,6 +65,7 @@
'wizard/stock_inventory_warning.xml',
'wizard/stock_label_type.xml',
'wizard/stock_lot_label_layout.xml',
'wizard/stock_quant_reserve.xml',
'views/res_partner_views.xml',
'views/product_strategy_views.xml',
+21 -5
View File
@@ -321,7 +321,7 @@ class StockMoveLine(models.Model):
if ml.state == 'done':
if 'qty_done' in vals:
ml.move_id.product_uom_qty = ml.move_id.quantity_done
if ml.product_id.type == 'product':
if ml.product_id.type == 'product' and not self.env.context.get('bypass_reservation_update'):
Quant = self.env['stock.quant']
quantity = ml.product_uom_id._compute_quantity(ml.qty_done, ml.move_id.product_id.uom_id,rounding_method='HALF-UP')
in_date = None
@@ -382,7 +382,7 @@ 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'] and ml.product_id.type == 'product'):
for ml in self.filtered(lambda ml: ml.state in ['partially_available', 'assigned', 'confirmed'] and ml.product_id.type == 'product'):
if 'reserved_uom_qty' in vals:
new_reserved_uom_qty = ml.product_uom_id._compute_quantity(
@@ -401,17 +401,22 @@ class StockMoveLine(models.Model):
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_reserved_uom_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)
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')
moves_to_recompute_state |= ml.move_id
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)
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.
if updates or 'qty_done' in vals:
@@ -919,3 +924,14 @@ class StockMoveLine(models.Model):
'message': _("The inventory adjustments have been reverted."),
}
}
def action_open_reserve_stock(self):
move_id = self.env['stock.move'].browse(self.env.context.get('default_move_id'))
return {
'type': 'ir.actions.act_window',
'res_model': 'stock.quant.reserve',
'view_mode': 'form',
'context': {'default_move_id': move_id.id},
'target': 'new',
'name': _('Reserve stock: %(product)s', product=move_id.product_id.name),
}
@@ -91,3 +91,5 @@ access_stock_replenishment_info,stock.replenishment.info,model_stock_replenishme
access_stock_picking_label_type_user,picking.label.type.user,model_picking_label_type,stock.group_stock_user,1,1,1,0
access_stock_lot_label_layout_user,lot.label.layout.user,model_lot_label_layout,stock.group_stock_user,1,1,1,0
access_stock_replenish_option,stock.replenishment.option,model_stock_replenishment_option,stock.group_stock_user,1,1,1,0
access_stock_quant_reserve,stock.quant.reserve,model_stock_quant_reserve,stock.group_stock_manager,1,1,1,0
access_stock_quant_reserve_line,stock.quant.reserve.line,model_stock_quant_reserve_line,stock.group_stock_manager,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
91 access_stock_picking_label_type_user picking.label.type.user model_picking_label_type stock.group_stock_user 1 1 1 0
92 access_stock_lot_label_layout_user lot.label.layout.user model_lot_label_layout stock.group_stock_user 1 1 1 0
93 access_stock_replenish_option stock.replenishment.option model_stock_replenishment_option stock.group_stock_user 1 1 1 0
94 access_stock_quant_reserve stock.quant.reserve model_stock_quant_reserve stock.group_stock_manager 1 1 1 0
95 access_stock_quant_reserve_line stock.quant.reserve.line model_stock_quant_reserve_line stock.group_stock_manager 1 1 1 0
@@ -0,0 +1,26 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
const { Component } = owl;
export class StockQuantReserve extends Component {
setup() {
this.reserve_line_record = this.props.record;
this.reserve_record = this.props.record.model.root;
}
async setToReserve() {
const qty_to_reserve = Math.min(this.reserve_record.data.qty_to_reserve, this.reserve_line_record.data.available_quantity);
await this.reserve_line_record.update({ qty_to_reserve });
}
}
StockQuantReserve.template = "stock.StockQuantReserve";
const stockQuantReserve = {
component: StockQuantReserve,
};
registry.category("view_widgets").add("stock_quant_reserve", stockQuantReserve);
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="stock.StockQuantReserve" owl="1">
<a t-on-click.stop.prevent="setToReserve" class="btn-link"><icon class="fa fa-check" /></a>
</t>
</templates>
+3 -4
View File
@@ -3612,9 +3612,8 @@ 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 it fails (for instance
because the written quantity is more than actually available), this
quantity should be reset to 0.
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.
"""
self.env['stock.quant']._update_available_quantity(self.product, self.stock_location, 1.0)
@@ -3633,7 +3632,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, 0, "The reserved quantity should be cancelled")
self.assertEqual(out_move.move_line_ids.reserved_uom_qty, 1, "The reserved quantity should be what is available")
def test_edit_done_move_line_1(self):
""" Test that editing a done stock move line linked to an untracked product correctly and
+54
View File
@@ -796,3 +796,57 @@ class StockQuant(TransactionCase):
# cache to ensure that the value will be the newest
quant.invalidate_recordset(['quantity'])
self.assertEqual(quant.quantity, 11)
def test_quant_reserve(self):
""" Tests the reserve stock wizard which allows to choose a specific quant to reserve from
also checks if editing the reserved_uom_qty on stock.move.line updates the quants
"""
customer_location = self.env.ref('stock.stock_location_customers')
self.env['stock.quant'].create({
'product_id': self.product.id,
'quantity': 2,
'location_id': self.stock_location.id
})
move_id = self.env['stock.move'].create({
'name': 'move out',
'location_id': self.stock_location.id,
'location_dest_id': customer_location.id,
'product_id': self.product.id,
'product_uom': self.product.uom_id.id,
'product_uom_qty': 2.0,
})
move_id._action_confirm()
move_id._action_assign()
self.assertEqual(move_id.state, 'assigned')
self.assertEqual(len(move_id.move_line_ids), 1)
self.assertEqual(move_id.move_line_ids.reserved_uom_qty, 2)
# available should be 0
available_qty = self.env['stock.quant']._get_available_quantity(self.product, self.stock_location)
self.assertEqual(available_qty, 0)
# unreserve, qty available should be 2 and move state back to confirmed
move_id.move_line_ids.reserved_uom_qty = 0
available_qty = self.env['stock.quant']._get_available_quantity(self.product, self.stock_location)
self.assertEqual(available_qty, 2)
self.assertEqual(move_id.state, 'confirmed')
# reserve qty again, checks to be able to reserve from confirmed state, and only reserve what's available
move_id.move_line_ids.reserved_uom_qty = 4
self.assertEqual(move_id.state, 'assigned')
self.assertEqual(move_id.move_line_ids.reserved_uom_qty, 2)
# unreserve and try to reserve from wizard
move_id.move_line_ids.unlink()
wiz_action = self.env['stock.move.line'].with_context(default_move_id=move_id.id).action_open_reserve_stock()
wiz = self.env[wiz_action['res_model']].with_context(wiz_action['context']).create({})
self.assertEqual(wiz.move_id.id, move_id.id)
self.assertEqual(wiz.demand_qty, move_id.product_qty)
self.assertEqual(len(wiz.quant_line_ids), 1)
wiz.quant_line_ids.qty_to_reserve = 4
with self.assertRaises(UserError):
wiz.reserve_stock()
wiz.quant_line_ids.qty_to_reserve = 2
wiz.reserve_stock()
self.assertEqual(len(move_id.move_line_ids), 1)
self.assertEqual(move_id.state, 'assigned')
self.assertEqual(move_id.move_line_ids.reserved_uom_qty, 2)
move_id._set_quantities_to_reservation()
move_id._action_done()
self.assertEqual(move_id.state, 'done')
+1 -1
View File
@@ -85,7 +85,7 @@
<group>
<label for="reserved_uom_qty" string="Quantity Reserved" attrs="{'invisible': [('state', '=', 'done')]}"/>
<div class="o_row" attrs="{'invisible': [('state', '=', 'done')]}">
<field name="reserved_uom_qty" readonly="1"/>
<field name="reserved_uom_qty" attrs="{'readonly': [('id', '=', False)]}" />
<field name="product_uom_id" options="{'no_create': True}" string="Unit of Measure" groups="uom.group_uom"/>
</div>
<label for="qty_done" string="Quantity Done"/>
+6 -2
View File
@@ -222,6 +222,10 @@
<field name="priority">1000</field>
<field name="arch" type="xml">
<tree editable="bottom" decoration-muted="state == 'done' and is_locked == True" decoration-success="reserved_uom_qty==qty_done" decoration-danger="qty_done &gt; reserved_uom_qty and state != 'done' and picking_code != 'incoming'">
<control>
<create name="add_move_line" string="Add a line"/>
<button name="action_open_reserve_stock" string="Reserve stock" type="object" class="btn-link" />
</control>
<field name="company_id" invisible="1" force_save="1"/>
<field name="picking_id" invisible="1" force_save="1"/>
<field name="move_id" invisible="1" force_save="1" />
@@ -250,7 +254,7 @@
<field name="package_id" attrs="{'readonly': ['&amp;', ('package_level_id', '!=', False), ('parent.picking_type_entire_packs', '=', True)]}" invisible="not context.get('show_package')" groups="stock.group_tracking_lot"/>
<field name="result_package_id" attrs="{'readonly': ['&amp;', ('package_level_id', '!=', False), ('parent.picking_type_entire_packs', '=', True)]}" groups="stock.group_tracking_lot" context="{'picking_id': picking_id}"/>
<field name="owner_id" attrs="{'readonly': ['&amp;', ('package_level_id', '!=', False), ('parent.picking_type_entire_packs', '=', True)]}" invisible="not context.get('show_owner')" groups="stock.group_tracking_owner"/>
<field name="reserved_uom_qty" invisible="not context.get('show_reserved_quantity')" readonly="1"/>
<field name="reserved_uom_qty" attrs="{'readonly': [('id', '=', False)]}" invisible="not context.get('show_reserved_quantity')"/>
<field name="state" invisible="1"/>
<field name="is_locked" invisible="1"/>
<field name="picking_code" invisible="1"/>
@@ -288,7 +292,7 @@
<field name="lot_id" groups="stock.group_production_lot" attrs="{'column_invisible': [('parent.show_lots_text', '=', True)], 'invisible': [('lots_visible', '=', False)]}" context="{'default_product_id': product_id, 'default_company_id': company_id, 'active_picking_id': picking_id}" optional="show"/>
<field name="lot_name" groups="stock.group_production_lot" attrs="{'column_invisible': [('parent.show_lots_text', '=', False)], 'invisible': [('lots_visible', '=', False)]}" context="{'default_product_id': product_id}"/>
<field name="is_initial_demand_editable" invisible="1"/>
<field name="reserved_uom_qty" readonly="1" attrs="{'column_invisible': ['|',('parent.immediate_transfer', '=', True),('parent.picking_type_code','=','incoming')]}" optional="show"/>
<field name="reserved_uom_qty" attrs="{'column_invisible': ['|',('parent.immediate_transfer', '=', True),('parent.picking_type_code','=','incoming')], 'readonly': [('id', '=', False)]}" optional="show"/>
<field name="is_locked" invisible="1"/>
<field name="qty_done" attrs="{'readonly': [('state', 'in', ('done', 'cancel')), ('is_locked', '=', True)]}" force_save="1"/>
<field name="product_uom_id" force_save="1" attrs="{'readonly': [('state', '!=', 'draft'), ('id', '!=', False)]}" groups="uom.group_uom"/>
+1
View File
@@ -22,3 +22,4 @@ from . import stock_package_destination
from . import stock_orderpoint_snooze
from . import stock_request_count
from . import stock_replenishment_info
from . import stock_quant_reserve
@@ -0,0 +1,68 @@
from odoo import _, api, Command, fields, models
from odoo.exceptions import UserError
class ReserveStockQuantLine(models.TransientModel):
_name = 'stock.quant.reserve.line'
_description = 'Choose quantity to reserve from each stock quant'
quant_id = fields.Many2one('stock.quant', required=True, readonly=True)
location_id = fields.Many2one(related='quant_id.location_id')
product_id = fields.Many2one(related='quant_id.product_id')
product_uom_id = fields.Many2one(related='product_id.uom_id')
lot_id = fields.Many2one(related='quant_id.lot_id')
package_id = fields.Many2one(related='quant_id.package_id')
available_quantity = fields.Float(related='quant_id.available_quantity', string='Available')
qty_to_reserve = fields.Float('To reserve')
reserve_id = fields.Many2one('stock.quant.reserve')
class ReserveStockQuant(models.TransientModel):
_name = 'stock.quant.reserve'
_description = 'Manually reserve stock'
move_id = fields.Many2one('stock.move')
product_tracking = fields.Selection(related='move_id.has_tracking')
product_uom_id = fields.Many2one(related='move_id.product_id.uom_id', readonly=True)
demand_qty = fields.Float(compute='_compute_demand_qty', store=True)
qty_to_reserve = fields.Float('Quantity to reserve', compute='_compute_qty_to_reserve')
quant_line_ids = fields.One2many('stock.quant.reserve.line', 'reserve_id', compute='_compute_quant_line_ids',
readonly=False, store=True)
@api.depends('quant_line_ids.qty_to_reserve', 'demand_qty')
def _compute_qty_to_reserve(self):
for wiz in self:
wiz.qty_to_reserve = max(wiz.demand_qty - sum(wiz.quant_line_ids.mapped('qty_to_reserve')), 0)
@api.depends('move_id')
def _compute_demand_qty(self):
for wiz in self:
wiz.demand_qty = wiz.move_id.product_qty - sum(wiz.move_id.move_line_ids.mapped('reserved_qty'))
@api.depends('move_id')
def _compute_quant_line_ids(self):
for wiz in self:
move_id = wiz.move_id
if not move_id:
wiz.quant_line_ids = False
continue
if wiz.quant_line_ids:
continue
quant_line_cmds = [Command.clear()]
quant_ids = self.env['stock.quant'].search([('product_id', '=', move_id.product_id.id),
('location_id', 'child_of', move_id.location_id.id)])
quant_line_cmds += [Command.create({'quant_id': quant.id}) for quant in quant_ids.filtered(lambda q: q.available_quantity > 0)]
wiz.quant_line_ids = quant_line_cmds
def reserve_stock(self):
move_line_vals = []
for wiz in self:
for line in wiz.quant_line_ids.filtered(lambda l: l.qty_to_reserve > 0):
if line.qty_to_reserve > line.available_quantity:
raise UserError(_('Cannot reserve more quantity than available!'))
line.quant_id.reserved_quantity += line.qty_to_reserve
move_line_vals.append(wiz.move_id._prepare_move_line_vals(quantity=line.qty_to_reserve, reserved_quant=line.quant_id))
StockMoveLine = self.env['stock.move.line'].with_context(bypass_reservation_update=True)
StockMoveLine.create(move_line_vals)
self.move_id._recompute_state()
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_stock_quant_reserve_form" model="ir.ui.view">
<field name="name">Reserve stock</field>
<field name="model">stock.quant.reserve</field>
<field name="arch" type="xml">
<form>
<field name="move_id" invisible="1"/>
<field name="product_tracking" invisible="1"/>
<group>
<group>
<label for="qty_to_reserve"/>
<div class="o_row">
<span><field name="qty_to_reserve" nolabel="1"/></span>
<span><field name="product_uom_id" nolabel="1"/></span>
</div>
</group>
</group>
<div class="oe_grey">
<p>Choose from the following available stock and enter the quantity to reserve.</p>
</div>
<field name="quant_line_ids" force_save="1">
<tree editable="top" create="0">
<field name="quant_id" invisible="1" force_save="1"/>
<field name="product_id"/>
<field name="location_id" groups="stock.group_stock_multi_locations"/>
<field name="lot_id" groups="stock.group_production_lot" attrs="{'column_invisible': [('parent.product_tracking','=','none')]}"/>
<field name="package_id" groups="stock.group_tracking_lot"/>
<field name="available_quantity"/>
<field name="product_uom_id" groups="uom.group_uom"/>
<field name="qty_to_reserve"/>
<widget name="stock_quant_reserve"/>
</tree>
</field>
<footer>
<button name="reserve_stock" class="btn btn-primary" type="object" string="Confirm" data-hotkey="v"/>
<button special="cancel" string="Cancel" data-hotkey="z"/>
</footer>
</form>
</field>
</record>
</odoo>