diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index 01707e4ee11..4312d66bf14 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -302,8 +302,8 @@ class StockMove(models.Model): super().write({'move_line_ids': move_line_vals}) return super().write(vals) - def _action_assign(self): - res = super(StockMove, self)._action_assign() + def _action_assign(self, force_qty=False): + res = super(StockMove, self)._action_assign(force_qty=force_qty) for move in self.filtered(lambda x: x.production_id or x.raw_material_production_id): if move.move_line_ids: move.move_line_ids.write({'production_id': move.raw_material_production_id.id, diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 6a17ef9735e..c89b5a57c6b 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -320,7 +320,14 @@ class StockMove(models.Model): else: move.delay_alert_date = False - @api.depends('move_line_ids.qty_done', 'move_line_ids.product_uom_id', 'move_line_nosuggest_ids.qty_done', 'picking_type_id.show_reserved') + def _quantity_done_sml(self): + self.ensure_one() + quantity = 0 + for move_line in self._get_move_lines(): + quantity += move_line.product_uom_id._compute_quantity(move_line.qty_done, self.product_uom, round=False) + return quantity + + @api.depends('move_line_ids.qty_done', 'move_line_ids.product_uom_id', 'move_line_nosuggest_ids.qty_done') def _quantity_done_compute(self): """ This field represents the sum of the move lines `qty_done`. It allows the user to know if there is still work to do. @@ -333,11 +340,7 @@ class StockMove(models.Model): if not any(self._ids): # onchange for move in self: - quantity_done = 0 - for move_line in move._get_move_lines(): - quantity_done += move_line.product_uom_id._compute_quantity( - move_line.qty_done, move.product_uom, round=False) - move.quantity_done = quantity_done + move.quantity_done = move._quantity_done_sml() else: # compute move_lines_ids = set() @@ -362,19 +365,59 @@ class StockMove(models.Model): ) def _quantity_done_set(self): - quantity_done = self[0].quantity_done # any call to create will invalidate `move.quantity_done` + def _process_decrease(move, quantity): + """Prioritize decrease the ml without reserved qty""" + res_mls = move._get_move_lines().sorted(lambda ml: float_is_zero(ml.reserved_uom_qty, precision_rounding=ml.product_uom_id.rounding), reverse=True) + qty_to_unreserve = move.reserved_availability - move.product_uom_qty + for ml in res_mls: + if float_is_zero(quantity, precision_rounding=move.product_uom.rounding): + break + qty_ml_dec = min(ml.qty_done, ml.product_uom_id._compute_quantity(quantity, ml.product_uom_id, round=False)) + if float_is_zero(qty_ml_dec, precision_rounding=ml.product_uom_id.rounding): + continue + ml.qty_done -= qty_ml_dec + quantity -= move.product_uom._compute_quantity(qty_ml_dec, move.product_uom, round=False) + # Unreserve + if (not move.picking_id.immediate_transfer and move.reserved_availability < move.product_uom_qty): + continue + if float_compare(ml.reserved_uom_qty, ml.qty_done, precision_rounding=ml.product_uom_id.rounding) <= 0: + continue + if move.picking_id.immediate_transfer: + ml.reserved_uom_qty = ml.qty_done + elif float_compare(qty_to_unreserve, 0, precision_rounding=move.product_uom.rounding) > 0: + qty_unreserved = min(qty_to_unreserve, ml.reserved_qty - ml.qty_done) + ml.reserved_uom_qty = ml.reserved_qty - qty_unreserved + qty_to_unreserve -= qty_unreserved + + def _process_increase(move, quantity): + moves = move + if move.picking_id.immediate_transfer: + moves = move._action_confirm(merge=False) + # Kits, already handle in action_explode, should be clean in master + if len(moves) > 1: + return + if move.reserved_availability < move.quantity_done and move.state not in ['done', 'cancel']: + move._action_assign(force_qty=move.quantity_done) + move._set_quantity_done(quantity) + + err = [] for move in self: - move_lines = move._get_move_lines() - if not move_lines: - if quantity_done: - # do not impact reservation here - move_line = self.env['stock.move.line'].create(dict(move._prepare_move_line_vals(), qty_done=quantity_done)) - move.write({'move_line_ids': [(4, move_line.id)]}) - move_line._apply_putaway_strategy() - elif len(move_lines) == 1: - move_lines[0].qty_done = quantity_done - else: - move._multi_line_quantity_done_set(quantity_done) + uom_qty = float_round(move.quantity_done, precision_rounding=move.product_uom.rounding, rounding_method='HALF-UP') + precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure') + qty = float_round(move.quantity_done, precision_digits=precision_digits, rounding_method='HALF-UP') + if float_compare(uom_qty, qty, precision_digits=precision_digits) != 0: + err.append(_(""" +The quantity done for the product %s doesn't respect the rounding precision defined on the unit of measure %s. +Please change the quantity done or the rounding precision of your unit of measure.""", + move.product_id.display_name, move.product_uom.display_name)) + continue + delta_qty = move.quantity_done - move._quantity_done_sml() + if float_compare(delta_qty, 0, precision_rounding=move.product_uom.rounding) > 0: + _process_increase(move, delta_qty) + elif float_compare(delta_qty, 0, precision_rounding=move.product_uom.rounding) < 0: + _process_decrease(move, abs(delta_qty)) + if err: + raise UserError('\n'.join(err)) def _multi_line_quantity_done_set(self, quantity_done): move_lines = self._get_move_lines() @@ -595,6 +638,13 @@ class StockMove(models.Model): move.location_id.name, move.location_dest_id.name))) return res + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('quantity_done') and 'lot_ids' in vals: + vals.pop('lot_ids') + return super().create(vals_list) + def write(self, vals): # Handle the write on the initial demand by updating the reserved quantity and logging # messages according to the state of the stock.move records. @@ -1433,7 +1483,7 @@ class StockMove(models.Model): 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): + def _action_assign(self, force_qty=False): """ Reserve stock moves by creating their stock move lines. A stock move is considered reserved once the sum of `reserved_qty` for all its move lines is equal to its `product_qty`. If it is less, the stock move is considered @@ -1489,9 +1539,16 @@ class StockMove(models.Model): # Once the quantities are assigned, we want to find a better destination location thanks # to the putaway rules. This redirection will be applied on moves of `moves_to_redirect`. moves_to_redirect = OrderedSet() - for move in self.filtered(lambda m: m.state in ['confirmed', 'waiting', 'partially_available']): + moves_to_assign = self + if not force_qty: + moves_to_assign = self.filtered(lambda m: m.state in ['confirmed', 'waiting', 'partially_available']) + for move in moves_to_assign: rounding = roundings[move] - missing_reserved_uom_quantity = move.product_uom_qty - reserved_availability[move] + if not force_qty: + missing_reserved_uom_quantity = move.product_uom_qty + else: + missing_reserved_uom_quantity = force_qty + missing_reserved_uom_quantity -= reserved_availability[move] 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 @@ -1803,7 +1860,7 @@ class StockMove(models.Model): rounding = move.product_uom.rounding if move.state in ('cancel', 'done', 'draft'): continue - elif float_compare(move.reserved_availability, move.product_uom_qty, precision_rounding=rounding) == 0: + elif float_compare(move.reserved_availability, move.product_uom_qty, precision_rounding=rounding) >= 0: moves_state_to_write['assigned'].add(move.id) elif move.reserved_availability and float_compare(move.reserved_availability, move.product_uom_qty, precision_rounding=rounding) <= 0: moves_state_to_write['partially_available'].add(move.id) diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index 55e0e88c1b7..57fb796dced 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -457,18 +457,12 @@ class StockMoveLine(models.Model): res = super(StockMoveLine, self).write(vals) - # Update scrap object linked to move_lines to the new quantity. - if 'qty_done' in vals: - for move in self.mapped('move_id'): - if move.scrapped: - move.scrap_ids.write({'scrap_qty': move.quantity_done}) - # As stock_account values according to a move's `product_uom_qty`, we consider that any # done stock move should have its `quantity_done` equals to its `product_uom_qty`, and # this is what move's `action_done` will do. So, we replicate the behavior here. if updates or 'qty_done' in vals: moves = self.filtered(lambda ml: ml.move_id.state == 'done').mapped('move_id') - moves |= self.filtered(lambda ml: ml.move_id.state not in ('done', 'cancel') and ml.move_id.picking_id.immediate_transfer and not ml.reserved_uom_qty).mapped('move_id') + moves |= self.filtered(lambda ml: ml.move_id.state not in ('done', 'cancel') and ml.move_id.picking_id.immediate_transfer).mapped('move_id') for move in moves: move.product_uom_qty = move.quantity_done next_moves._do_unreserve() diff --git a/addons/stock/models/stock_scrap.py b/addons/stock/models/stock_scrap.py index 85b47f11102..4192a8e0fc1 100644 --- a/addons/stock/models/stock_scrap.py +++ b/addons/stock/models/stock_scrap.py @@ -52,13 +52,22 @@ class StockScrap(models.Model): scrap_location_id = fields.Many2one( 'stock.location', 'Scrap Location', default=_get_default_scrap_location_id, domain="[('scrap_location', '=', True), ('company_id', 'in', [company_id, False])]", required=True, states={'done': [('readonly', True)]}, check_company=True) - scrap_qty = fields.Float('Quantity', default=1.0, required=True, states={'done': [('readonly', True)]}, digits='Product Unit of Measure') + scrap_qty = fields.Float( + 'Quantity', required=True, states={'done': [('readonly', True)]}, digits='Product Unit of Measure', + compute='_compute_scrap_qty', precompute=True, store=True) state = fields.Selection([ ('draft', 'Draft'), ('done', 'Done')], string='Status', default="draft", readonly=True, tracking=True) date_done = fields.Datetime('Date', readonly=True) + @api.depends('move_id', 'move_id.move_line_ids.qty_done') + def _compute_scrap_qty(self): + self.scrap_qty = 1 + for scrap in self: + if scrap.move_id: + scrap.scrap_qty = scrap.move_id.quantity_done + @api.onchange('picking_id') def _onchange_picking_id(self): if self.picking_id: diff --git a/addons/stock/static/src/views/picking_form/picking_form_controller.js b/addons/stock/static/src/views/picking_form/picking_form_controller.js new file mode 100644 index 00000000000..8e982f330cb --- /dev/null +++ b/addons/stock/static/src/views/picking_form/picking_form_controller.js @@ -0,0 +1,49 @@ +/** @odoo-module **/ + +import { sortBy } from "@web/core/utils/arrays"; +import { FormController } from "@web/views/form/form_controller"; +import { useBus, useService } from "@web/core/utils/hooks"; + +export class StockPickingFormController extends FormController { + setup() { + super.setup(); + this.action = useService("action"); + useBus(this.env.bus, "STOCK_MOVE:UPDATED", (ev) => this._qtyUpdated(ev)); + useBus(this.env.bus, "STOCK_MOVE:SAVED", (ev) => this._actionOpenShowDetails(ev)); + } + + // TRIGGER + async _actionOpenShowDetails(ev) { + let id = ev.detail.id; + let record; + if (id) { + record = this.model.root.data.move_ids_without_package.records.find( + (e) => e.data.id == id + ); + } else { + const moveByIdsDesc = sortBy( + this.model.root.data.move_ids_without_package.records, + "id", + "desc" + ); + record = moveByIdsDesc.find((e) => e.data.product_id[0] == ev.detail.product_id[0]); + id = record.data.id; + } + const action = await this.model.orm.call("stock.move", "action_show_details", [id]); + if (action) { + this.action.doAction(action, { onClose: () => this._onClose() }); + } + ev.detail.resolve(); + } + + async _qtyUpdated(ev) { + await this.model.root.save(); + ev.detail.resolve(); + } + + // PRIVATE + async _onClose() { + await this.model.root.load(); + this.render(true); + } +} diff --git a/addons/stock/static/src/views/picking_form/picking_form_model.js b/addons/stock/static/src/views/picking_form/picking_form_model.js new file mode 100644 index 00000000000..ed0e61f68f4 --- /dev/null +++ b/addons/stock/static/src/views/picking_form/picking_form_model.js @@ -0,0 +1,25 @@ +/** @odoo-module **/ + +import { Record, RelationalModel } from "@web/views/basic_relational_model"; + +export class StockPickingAutoSaveRecord extends Record { + setup(params, state) { + super.setup(params, state); + } + + async saveAndOpenDetails() { + await new Promise((resolve) => { + this.model.env.bus.trigger("STOCK_MOVE:UPDATED", { resolve }); + }); + await new Promise((resolve) => { + this.model.env.bus.trigger("STOCK_MOVE:SAVED", { + id: this.data.id, + product_id: this.data.product_id, + resolve, + }); + }); + } +} + +export class StockPickingModel extends RelationalModel {} +StockPickingModel.Record = StockPickingAutoSaveRecord; diff --git a/addons/stock/static/src/views/picking_form/picking_form_view.js b/addons/stock/static/src/views/picking_form/picking_form_view.js new file mode 100644 index 00000000000..8b6d1f395ba --- /dev/null +++ b/addons/stock/static/src/views/picking_form/picking_form_view.js @@ -0,0 +1,14 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { formView } from "@web/views/form/form_view"; +import { StockPickingModel } from "@stock/views/picking_form/picking_form_model"; +import { StockPickingFormController } from "@stock/views/picking_form/picking_form_controller"; + +export const StockPickingFormView = { + ...formView, + Controller: StockPickingFormController, + Model: StockPickingModel, +}; + +registry.category("views").add("picking_form", StockPickingFormView); diff --git a/addons/stock/static/src/views/picking_form/stock_move_one2many.js b/addons/stock/static/src/views/picking_form/stock_move_one2many.js new file mode 100644 index 00000000000..1c4ca1b6a45 --- /dev/null +++ b/addons/stock/static/src/views/picking_form/stock_move_one2many.js @@ -0,0 +1,33 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { ListRenderer } from "@web/views/list/list_renderer"; +import { X2ManyField } from "@web/views/fields/x2many/x2many_field"; +import { ViewButton } from "@web/views/view_button/view_button"; + +class MoveViewButton extends ViewButton { + async onClick(ev) { + if (this.props.clickParams.name != "action_show_details") { + super.onClick(ev); + } else { + await this.props.record.saveAndOpenDetails(); + } + } + + get disabled() { + if (this.props.clickParams.name == "action_show_details") { + return false; + } + return super.disabled; + } +} + +MoveViewButton.props = [...ViewButton.props]; +export class MovesListRenderer extends ListRenderer {} + +MovesListRenderer.components = { ...ListRenderer.components, ViewButton: MoveViewButton }; + +export class StockMoveX2ManyField extends X2ManyField {} +StockMoveX2ManyField.components = { ...X2ManyField.components, ListRenderer: MovesListRenderer }; + +registry.category("fields").add("stock_move_one2many", StockMoveX2ManyField); diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index a24535bd828..50f028b75bb 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -104,7 +104,7 @@ stock.picking -
+ @@ -246,6 +246,7 @@ @@ -287,7 +288,7 @@ - +