From c2c1fa2811df3bddf52c993c8a0348b920d05084 Mon Sep 17 00:00:00 2001 From: Ahmed Khalaf Date: Tue, 25 Oct 2022 13:48:06 +0000 Subject: [PATCH] [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) --- addons/product_expiry/__manifest__.py | 1 + addons/product_expiry/wizard/__init__.py | 1 + .../wizard/stock_quant_reserve.py | 13 ++++ .../wizard/stock_quant_reserve.xml | 15 ++++ addons/stock/__manifest__.py | 1 + addons/stock/models/stock_move_line.py | 26 +++++-- addons/stock/security/ir.model.access.csv | 2 + .../static/src/widgets/stock_quant_reserve.js | 26 +++++++ .../src/widgets/stock_quant_reserve.xml | 6 ++ addons/stock/tests/test_move.py | 7 +- addons/stock/tests/test_quant.py | 54 +++++++++++++++ addons/stock/views/stock_move_line_views.xml | 2 +- addons/stock/views/stock_move_views.xml | 8 ++- addons/stock/wizard/__init__.py | 1 + addons/stock/wizard/stock_quant_reserve.py | 68 +++++++++++++++++++ addons/stock/wizard/stock_quant_reserve.xml | 43 ++++++++++++ 16 files changed, 262 insertions(+), 12 deletions(-) create mode 100644 addons/product_expiry/wizard/stock_quant_reserve.py create mode 100644 addons/product_expiry/wizard/stock_quant_reserve.xml create mode 100644 addons/stock/static/src/widgets/stock_quant_reserve.js create mode 100644 addons/stock/static/src/widgets/stock_quant_reserve.xml create mode 100644 addons/stock/wizard/stock_quant_reserve.py create mode 100644 addons/stock/wizard/stock_quant_reserve.xml diff --git a/addons/product_expiry/__manifest__.py b/addons/product_expiry/__manifest__.py index 7653316fb6d..b6ff964e7e2 100644 --- a/addons/product_expiry/__manifest__.py +++ b/addons/product_expiry/__manifest__.py @@ -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', diff --git a/addons/product_expiry/wizard/__init__.py b/addons/product_expiry/wizard/__init__.py index f96376b2bd2..ac924467fb0 100644 --- a/addons/product_expiry/wizard/__init__.py +++ b/addons/product_expiry/wizard/__init__.py @@ -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 diff --git a/addons/product_expiry/wizard/stock_quant_reserve.py b/addons/product_expiry/wizard/stock_quant_reserve.py new file mode 100644 index 00000000000..74657173f64 --- /dev/null +++ b/addons/product_expiry/wizard/stock_quant_reserve.py @@ -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') diff --git a/addons/product_expiry/wizard/stock_quant_reserve.xml b/addons/product_expiry/wizard/stock_quant_reserve.xml new file mode 100644 index 00000000000..994a86a0329 --- /dev/null +++ b/addons/product_expiry/wizard/stock_quant_reserve.xml @@ -0,0 +1,15 @@ + + + stock.quant.reserve.inherit + stock.quant.reserve + + + + + + + + + + + diff --git a/addons/stock/__manifest__.py b/addons/stock/__manifest__.py index cca29a99a5b..00e4a7967c9 100644 --- a/addons/stock/__manifest__.py +++ b/addons/stock/__manifest__.py @@ -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', diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index 7ce871dbd10..aff746e0d23 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -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), + } diff --git a/addons/stock/security/ir.model.access.csv b/addons/stock/security/ir.model.access.csv index 924799880c8..ab922f3fc65 100644 --- a/addons/stock/security/ir.model.access.csv +++ b/addons/stock/security/ir.model.access.csv @@ -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 diff --git a/addons/stock/static/src/widgets/stock_quant_reserve.js b/addons/stock/static/src/widgets/stock_quant_reserve.js new file mode 100644 index 00000000000..5b8e309a720 --- /dev/null +++ b/addons/stock/static/src/widgets/stock_quant_reserve.js @@ -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); diff --git a/addons/stock/static/src/widgets/stock_quant_reserve.xml b/addons/stock/static/src/widgets/stock_quant_reserve.xml new file mode 100644 index 00000000000..7d9dbf74d3c --- /dev/null +++ b/addons/stock/static/src/widgets/stock_quant_reserve.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/addons/stock/tests/test_move.py b/addons/stock/tests/test_move.py index eeeb334588b..70cb09a04cb 100644 --- a/addons/stock/tests/test_move.py +++ b/addons/stock/tests/test_move.py @@ -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 diff --git a/addons/stock/tests/test_quant.py b/addons/stock/tests/test_quant.py index b8e50473bf3..b233788a6fd 100644 --- a/addons/stock/tests/test_quant.py +++ b/addons/stock/tests/test_quant.py @@ -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') diff --git a/addons/stock/views/stock_move_line_views.xml b/addons/stock/views/stock_move_line_views.xml index b66f95f1592..ad0637b2379 100644 --- a/addons/stock/views/stock_move_line_views.xml +++ b/addons/stock/views/stock_move_line_views.xml @@ -85,7 +85,7 @@