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
-