[IMP] stock: quantity done on stock.move is always editable

Quantity done will be always editable. Different use case:
1. If there is not location/package/tracing/owner -> same behavior than
  before
2. If the details are needed:
- Immediate transfer. In case of increase the quants are reserved and the
`qty_done` on `stock.move.line` is increased depending the reservation.
If the quantity couldn't be fully reserve then default `stock.move.line`
are create (depending the putaway rules or multiple ones if the products
are tracked). In case of decrease, the quantity reserved are unreserve
but the `stock.move.line` are not unlink until the validation.
- Planned transfer. The quantity done will fill the reservation. If the
picking is not assign, it will reserve the quantity needed. In case of
decrease, it will only unreserve the extra quantity (compare to the
initial demand). To unreserve the quantity bellow the intial demand, the
`stock.move.line` could be unlink or use the `unreserve` button.

force_qty in `_action_assign` is needed because we don't reserve only
base on the initial demand (`product_uom_qty`) but sometimes on the
quantity_done.

In order to never block the user. The action_show_details is always
available. If the id is not available, it will save and open the record
(we take the record based on the new existing line with same product
and highest id).

closes odoo/odoo#102738

Task-id: 2960983
X-original-commit: cacc677a08066bc23e1c89df36027599d05f2a08
Related: odoo/enterprise#32555
Signed-off-by: William Henrotin (whe) <whe@odoo.com>
This commit is contained in:
Arnold Moyaux
2022-10-08 18:00:19 +02:00
parent 3034f1d0db
commit 5dd70ed8b6
9 changed files with 216 additions and 34 deletions
+2 -2
View File
@@ -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,
+79 -22
View File
@@ -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)
+1 -7
View File
@@ -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()
+10 -1
View File
@@ -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:
@@ -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);
}
}
@@ -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;
@@ -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);
@@ -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);
+3 -2
View File
@@ -104,7 +104,7 @@
<field name="model">stock.picking</field>
<field eval="12" name="priority"/>
<field name="arch" type="xml">
<form string="Transfer">
<form string="Transfer" js_class="picking_form">
<field name="is_locked" invisible="1"/>
<field name="show_mark_as_todo" invisible="1"/>
@@ -246,6 +246,7 @@
<page string="Operations" name="operations">
<field name="move_ids_without_package" mode="tree,kanban"
widget="stock_move_one2many"
attrs="{'readonly': ['&amp;', ('state', '=', 'done'), ('is_locked', '=', True)]}"
context="{'default_company_id': company_id, 'default_date': scheduled_date, 'default_date_deadline': date_deadline, 'picking_type_code': picking_type_code, 'default_picking_id': id, 'form_view_ref':'stock.view_move_form', 'address_in_id': partner_id, 'default_picking_type_id': picking_type_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id, 'default_partner_id': partner_id}"
add-label="Add a Product">
@@ -287,7 +288,7 @@
<field name="reserved_availability" string="Reserved"
attrs="{'column_invisible': ['|', '|', ('parent.state', 'in', ['draft', 'done']), ('parent.picking_type_code', 'in', ['incoming', 'outgoing']), ('parent.immediate_transfer', '=', True)]}"/>
<field name="product_qty" invisible="1" readonly="1"/>
<field name="quantity_done" string="Done" attrs="{'readonly': [('is_quantity_done_editable', '=', False)], 'column_invisible':[('parent.state', '=', 'draft'), ('parent.immediate_transfer', '=', False)]}"/>
<field name="quantity_done" string="Done" attrs="{'readonly': [('product_id', '=', False)], 'column_invisible':[('parent.state', '=', 'draft'), ('parent.immediate_transfer', '=', False)]}"/>
<field name="product_uom" attrs="{'readonly': [('state', '!=', 'draft'), ('additional', '=', False)]}" options="{'no_open': True, 'no_create': True}" string="Unit of Measure" groups="uom.group_uom"/>
<field name="lot_ids" widget="many2many_tags"
groups="stock.group_production_lot"