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 @@ - + diff --git a/addons/stock/views/stock_move_views.xml b/addons/stock/views/stock_move_views.xml index b99dddf6ccf..6c1899779e6 100644 --- a/addons/stock/views/stock_move_views.xml +++ b/addons/stock/views/stock_move_views.xml @@ -222,6 +222,10 @@ 1000 + + + + @@ -250,7 +254,7 @@ - + @@ -288,7 +292,7 @@ - + diff --git a/addons/stock/wizard/__init__.py b/addons/stock/wizard/__init__.py index bfef3be7214..2f49e68cbcc 100644 --- a/addons/stock/wizard/__init__.py +++ b/addons/stock/wizard/__init__.py @@ -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 diff --git a/addons/stock/wizard/stock_quant_reserve.py b/addons/stock/wizard/stock_quant_reserve.py new file mode 100644 index 00000000000..7d3669014ee --- /dev/null +++ b/addons/stock/wizard/stock_quant_reserve.py @@ -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() diff --git a/addons/stock/wizard/stock_quant_reserve.xml b/addons/stock/wizard/stock_quant_reserve.xml new file mode 100644 index 00000000000..bc1ddffaec5 --- /dev/null +++ b/addons/stock/wizard/stock_quant_reserve.xml @@ -0,0 +1,43 @@ + + + + + Reserve stock + stock.quant.reserve + + + + + + + + + + + + + + + Choose from the following available stock and enter the quantity to reserve. + + + + + + + + + + + + + + + + + + +
Choose from the following available stock and enter the quantity to reserve.