[IMP] stock: create stock move lines from quants

In a flow where the reservation is used (internal transfers, deliveries,
...) adding a new stock move line is now made from the quantities
available in stock. The 'add a line' button in the show detail wizard
trigger the quant list view to directly pick the wanted lot or location
where the stock is available. Only the quantity done is needed to be
updated before the validation.

This commit remove the 'quant reserve wizard' as the behaviour is an
extension of it.

Task: 3256447
Part-of: odoo/odoo#117513
This commit is contained in:
William Henrotin
2023-05-17 13:37:47 +02:00
parent 5e5d92ef79
commit d45f6ab226
11 changed files with 199 additions and 166 deletions
@@ -1,13 +0,0 @@
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')
@@ -1,15 +0,0 @@
<odoo>
<record id="stock_quant_reserve_inherit" model="ir.ui.view">
<field name="name">stock.quant.reserve.inherit</field>
<field name="model">stock.quant.reserve</field>
<field name="inherit_id" ref="stock.view_stock_quant_reserve_form"/>
<field name="arch" type="xml">
<field name="move_id" position="after">
<field name="show_expiry" invisible="1"/>
</field>
<field name="lot_id" position='after'>
<field name="expiration_date" groups="stock.group_production_lot" attrs="{'column_invisible': [('parent.show_expiry', '=', False)]}"/>
</field>
</field>
</record>
</odoo>
+7 -7
View File
@@ -768,7 +768,7 @@ Please change the quantity done or the rounding precision of your unit of measur
if self.product_id.tracking == "serial" and self.state == "assigned":
self.next_serial = self.env['stock.lot']._get_next_serial(self.company_id, self.product_id)
quant_mode = self.picking_type_id.code != 'incoming'
return {
'name': _('Detailed Operations'),
'type': 'ir.actions.act_window',
@@ -780,13 +780,13 @@ Please change the quantity done or the rounding precision of your unit of measur
'res_id': self.id,
'context': dict(
self.env.context,
show_owner=self.picking_type_id.code != 'incoming',
show_lots_m2o=self.has_tracking != 'none' and (self.picking_type_id.use_existing_lots or self.state == 'done' or self.origin_returned_move_id.id), # able to create lots, whatever the value of ` use_create_lots`.
show_owner=not quant_mode,
show_quant=quant_mode,
show_lots_m2o=not quant_mode and self.has_tracking != 'none' and (self.picking_type_id.use_existing_lots or self.state == 'done' or self.origin_returned_move_id.id), # able to create lots, whatever the value of ` use_create_lots`.
show_lots_text=self.has_tracking != 'none' and self.picking_type_id.use_create_lots and not self.picking_type_id.use_existing_lots and self.state != 'done' and not self.origin_returned_move_id.id,
show_source_location=self.picking_type_id.code != 'incoming',
show_destination_location=self.picking_type_id.code != 'outgoing',
show_package=not self.location_id.usage == 'supplier',
show_reserved_quantity=self.state != 'done' and self.picking_type_id.code != 'incoming'
show_destination_location=not quant_mode,
show_package=not quant_mode,
show_reserved_quantity=self.state != 'done' and quant_mode
),
}
+32 -12
View File
@@ -37,7 +37,8 @@ class StockMoveLine(models.Model):
compute='_compute_reserved_qty', inverse='_set_reserved_qty', store=True)
reserved_uom_qty = fields.Float(
'Reserved', default=0.0, digits='Product Unit of Measure', required=True, copy=False)
qty_done = fields.Float('Done', default=0.0, digits='Product Unit of Measure', copy=False)
qty_done = fields.Float('Done', default=0.0, digits='Product Unit of Measure', copy=False,
compute='_compute_qty_done', store=True, readonly=False)
package_id = fields.Many2one(
'stock.quant.package', 'Source Package', ondelete='restrict',
check_company=True,
@@ -82,6 +83,7 @@ class StockMoveLine(models.Model):
tracking = fields.Selection(related='product_id.tracking', readonly=True)
origin = fields.Char(related='move_id.origin', string='Source')
description_picking = fields.Text(string="Description picking")
quant_id = fields.Many2one('stock.quant', "Pick From", store=False) # Dummy field for the detailed operation view
@api.depends('product_uom_id.category_id', 'product_id.uom_id.category_id', 'move_id.product_uom', 'product_id.uom_id')
def _compute_product_uom_id(self):
@@ -131,6 +133,12 @@ class StockMoveLine(models.Model):
detect errors. """
raise UserError(_('The requested operation cannot be processed because of a programming error setting the `reserved_qty` field instead of the `reserved_uom_qty`.'))
@api.depends('quant_id')
def _compute_qty_done(self):
for record in self:
if not record.qty_done:
record.qty_done = min(record.quant_id.quantity, record.move_id.product_qty)
@api.constrains('lot_id', 'product_id')
def _check_lot_product(self):
for line in self:
@@ -288,6 +296,8 @@ class StockMoveLine(models.Model):
vals['company_id'] = self.env['stock.move'].browse(vals['move_id']).company_id.id
elif vals.get('picking_id'):
vals['company_id'] = self.env['stock.picking'].browse(vals['picking_id']).company_id.id
if vals.get('quant_id'):
vals.update(self._copy_quant_info(vals))
mls = super().create(vals_list)
@@ -379,6 +389,8 @@ class StockMoveLine(models.Model):
('owner_id', 'res.partner'),
('product_uom_id', 'uom.uom')
]
if vals.get('quant_id'):
vals.update(self._copy_quant_info(vals))
updates = {}
for key, model in triggers:
if key in vals:
@@ -872,6 +884,25 @@ class StockMoveLine(models.Model):
'partner_id': self.picking_id.partner_id.id,
}
def _copy_quant_info(self, vals):
quant = self.env['stock.quant'].browse(vals.get('quant_id', 0))
if self:
uom = self.product_uom_id
elif vals.get('product_uom_id'):
uom = self.env['uom.uom'].browse(vals.get('product_uom_id'))
else:
uom = quant.product_uom_id
line_data = {
'product_id': quant.product_id.id,
'lot_id': quant.lot_id.id,
'package_id': quant.package_id.id,
'location_id': quant.location_id.id,
'owner_id': quant.owner_id.id,
}
if not (self.qty_done or vals.get('qty_done')):
line_data['qty_done'] = uom._compute_quantity(quant.available_quantity, quant.product_uom_id, rounding_method='HALF_UP')
return line_data
def action_open_reference(self):
self.ensure_one()
if self.move_id:
@@ -937,14 +968,3 @@ 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),
}
+14
View File
@@ -523,6 +523,20 @@ class StockQuant(models.Model):
'target': 'new',
}
def name_get(self):
"""name that will be displayed in the detailed operation"""
name_parts = []
for record in self:
name = []
if self.env.user.has_group('stock.group_stock_multi_locations'):
name.append(record.location_id.display_name)
if self.env.user.has_group('stock.group_production_lot') and record.lot_id:
name.append(record.lot_id.name)
name_parts.append(name)
if name_parts:
return [(quant.id, ' - '.join(name)) for quant, name in zip(self, name_parts)]
return []
@api.constrains('product_id')
def check_product_id(self):
if any(elem.product_id.type != 'product' for elem in self):
@@ -0,0 +1,47 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
import { sprintf } from "@web/core/utils/strings";
import { useSelectCreate } from "@web/views/fields/relational_utils";
export class SMLX2ManyField extends X2ManyField {
setup() {
super.setup();
const selectCreate = useSelectCreate({
resModel: "stock.quant",
activeActions: this.activeActions,
onSelected: (resIds) => this.selectRecord(resIds),
onCreateEdit: ({ context }) => this._openRecord({ context }),
});
this.selectCreate = (params) => {
const p = Object.assign({}, params);
return selectCreate(p);
};
}
async onAdd({ context, editable } = {}) {
context = {};
const { string } = this.props;
const title = sprintf(this.env._t("Add: %s"), string);
const domain = [
["product_id", "=", this.props.record.data.product_id[0]],
["location_id", "child_of", this.props.context.default_location_id],
];
return this.selectCreate({ domain, context, title });
}
selectRecord(res_ids) {
const params = {
context: { default_quant_id: res_ids[0] },
};
this.addInLine(params);
}
}
export const smlX2ManyField = {
...x2ManyField,
component: SMLX2ManyField,
};
registry.category("fields").add("sml_x2_many", smlX2ManyField);
@@ -0,0 +1,53 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Many2OneField, many2OneField } from "@web/views/fields/many2one/many2one_field";
const { onWillStart} = owl;
export class StockPickFrom extends Many2OneField {
setup() {
super.setup();
this.user = useService('user');
onWillStart(async () => {
const testedGroup = [['location', 'stock.group_stock_multi_locations'], ['lot', 'stock.group_production_lot']];
const userGroups = await Promise.all(
testedGroup.map((group) => this.user.hasGroup(group[1]))
);
this.enabledGroups = {};
for (const [index, group] of testedGroup.entries()) {
this.enabledGroups[group[0]] = userGroups[index];
}
});
}
get displayName() {
return super.displayName || this._quant_display_name();
}
get value() {
return super.value || [0, this._quant_display_name()];
}
_quant_display_name() {
let name_parts = [];
if (this.props.record.data.id) {
// if location group is activated
if (this.enabledGroups?.location) {
name_parts.push(this.props.record.data.location_id?.[1])
}
if (this.enabledGroups?.lot) {
name_parts.push(this.props.record.data.lot_id?.[1] || this.props.record.data.lot_name)
}
return name_parts.join(" - ");
}
return "";
}
}
export const stockPickFrom = {
...many2OneField,
component: StockPickFrom,
};
registry.category("fields").add("pick_from", stockPickFrom);
+16 -8
View File
@@ -157,6 +157,7 @@
<field name="from_immediate_transfer" invisible="1"/>
<field name="product_uom_category_id" invisible="1"/>
<field name="display_clear_serial" invisible="1"/>
<field name="picking_code" invisible="1"/>
<group>
<group>
<field name="product_id" readonly="1"/>
@@ -191,8 +192,11 @@
</div>
</group>
</group>
<field name="move_line_ids" attrs="{'readonly': ['|', ('state', '=', 'cancel'), '&amp;', ('state', '=', 'done'), ('is_locked', '=', True)]}" context="{'tree_view_ref': 'stock.view_stock_move_line_operation_tree', 'default_product_uom_id': product_uom, 'default_picking_id': picking_id, 'default_move_id': id, 'default_product_id': product_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id, 'default_company_id': company_id}"/>
<footer class="oe_edit_only" attrs="{'invisible': ['|', ('state', '=', 'cancel'), '&amp;', ('state', '=', 'done'), ('is_locked', '=', True)]}">
<field name="move_line_ids"
attrs="{'readonly': ['|', ('state', '=', 'cancel'), '&amp;', ('state', '=', 'done'), ('is_locked', '=', True)]}"
context="{'tree_view_ref': 'stock.view_stock_move_line_operation_tree', 'default_product_uom_id': product_uom, 'default_picking_id': picking_id, 'default_move_id': id, 'default_product_id': product_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id, 'default_company_id': company_id}"
widget="sml_x2_many"/>
<footer class="d-flex justify-content-between" attrs="{'invisible': ['|', ('state', '=', 'cancel'), '&amp;', ('state', '=', 'done'), ('is_locked', '=', True)]}">
<button string="Confirm" special="save" data-hotkey="v" class="oe_highlight"/>
<button string="Discard" special="cancel" data-hotkey="z"/>
</footer>
@@ -222,10 +226,6 @@
<field name="priority">1000</field>
<field name="arch" type="xml">
<tree editable="bottom" decoration-muted="state == 'done' and is_locked == True" decoration-success="reserved_uom_qty==qty_done" decoration-danger="qty_done &gt; reserved_uom_qty and state != 'done' and picking_code != 'incoming'">
<control>
<create name="add_move_line" string="Add a line"/>
<button name="action_open_reserve_stock" string="Reserve stock" type="object" class="btn-link" />
</control>
<field name="company_id" invisible="1" force_save="1"/>
<field name="picking_id" invisible="1" force_save="1"/>
<field name="move_id" invisible="1" force_save="1" />
@@ -235,8 +235,16 @@
<field name="location_id" invisible="1"/>
<field name="location_dest_id" invisible="1"/>
<field name="package_id" invisible="1"/>
<field name="location_id" options="{'no_create': True}" attrs="{'readonly': ['&amp;', ('package_level_id', '!=', False), ('parent.picking_type_entire_packs', '=', True)]}" invisible="not context.get('show_source_location')" domain="[('id', 'child_of', parent.location_id), '|', ('company_id', '=', False), ('company_id', '=', company_id), ('usage', '!=', 'view')]" groups="stock.group_stock_multi_locations"/>
<field name="location_dest_id" attrs="{'readonly': ['&amp;', ('package_level_id', '!=', False), ('parent.picking_type_entire_packs', '=', True)]}" invisible="not context.get('show_destination_location')" domain="[('id', 'child_of', parent.location_dest_id), '|', ('company_id', '=', False), ('company_id', '=', company_id), ('usage', '!=', 'view')]" groups="stock.group_stock_multi_locations"/>
<field name="quant_id"
domain="[('product_id', '=', product_id), ('location_id', 'child_of', parent.location_id)]"
context="{'default_location_id': location_id, 'default_product_id': product_id, 'search_view_ref': 'stock.quant_search_view', 'tree_view_ref': 'stock.view_stock_quant_tree', 'form_view_ref': 'stock.view_stock_quant_form', 'readonly_form': True}"
widget="pick_from"
invisible="not context.get('show_quant')"/>
<field name="location_dest_id"
attrs="{'readonly': ['&amp;', ('package_level_id', '!=', False), ('parent.picking_type_entire_packs', '=', True)]}"
invisible="not context.get('show_destination_location')"
domain="[('id', 'child_of', parent.location_dest_id), '|', ('company_id', '=', False), ('company_id', '=', company_id), ('usage', '!=', 'view')]"
groups="stock.group_stock_multi_locations"/>
<field name="lot_id" groups="stock.group_production_lot"
attrs="{'readonly': ['&amp;', ('package_level_id', '!=', False), ('parent.picking_type_entire_packs', '=', True)]}"
invisible="not context.get('show_lots_m2o')"
+30
View File
@@ -357,6 +357,36 @@
</field>
</record>
<record id="view_stock_quant_form" model="ir.ui.view">
<field name="name">view.stock.quant.form</field>
<field name="priority">100</field>
<field name="model">stock.quant</field>
<field name="arch" type="xml">
<form string="Create Stock">
<div class="alert alert-warning" role="alert" invisible="context.get('readonly_form')">
You are going to pick products that are not referenced
in this location. That leads to a negative stock.
</div>
<group>
<group>
<field name="location_id" invisible="1"/>
<field name="company_id" invisible="1"/>
<field name="product_id" force_save="1" invisible="1"/>
<field name="lot_id" domain="[('product_id', '=', product_id)]"
groups="stock.group_production_lot" readonly="context.get('readonly_form')"/>
<field name="location_id" groups="stock.group_stock_multi_locations" readonly="context.get('readonly_form')"/>
<field name="package_id" groups="stock.group_tracking_lot" invisible="context.get('readonly_form')"/>
<field name="owner_id" groups="stock.group_tracking_owner" invisible="context.get('readonly_form')"/>
</group>
</group>
<footer>
<button string="Confirm" class="btn btn-danger" special="save"/>
<button string="Discard" class="btn btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Inventory Adjustments + Update Quantity (via product) view -->
<record model="ir.ui.view" id="view_stock_quant_tree_inventory_editable">
<field name="name">stock.quant.inventory.tree.editable</field>
@@ -1,68 +0,0 @@
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()
@@ -1,43 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_stock_quant_reserve_form" model="ir.ui.view">
<field name="name">Reserve stock</field>
<field name="model">stock.quant.reserve</field>
<field name="arch" type="xml">
<form>
<field name="move_id" invisible="1"/>
<field name="product_tracking" invisible="1"/>
<group>
<group>
<label for="qty_to_reserve"/>
<div class="o_row">
<span><field name="qty_to_reserve" nolabel="1"/></span>
<span><field name="product_uom_id" nolabel="1" options="{'no_open': True}"/></span>
</div>
</group>
</group>
<div class="oe_grey">
<p>Choose from the following available stock and enter the quantity to reserve.</p>
</div>
<field name="quant_line_ids" force_save="1">
<tree editable="top" create="0">
<field name="quant_id" invisible="1" force_save="1"/>
<field name="product_id"/>
<field name="location_id" groups="stock.group_stock_multi_locations"/>
<field name="lot_id" groups="stock.group_production_lot" attrs="{'column_invisible': [('parent.product_tracking','=','none')]}"/>
<field name="package_id" groups="stock.group_tracking_lot"/>
<field name="available_quantity"/>
<field name="product_uom_id" groups="uom.group_uom"/>
<field name="qty_to_reserve"/>
<widget name="stock_quant_reserve"/>
</tree>
</field>
<footer>
<button name="reserve_stock" class="btn btn-primary" type="object" string="Confirm" data-hotkey="v"/>
<button special="cancel" string="Cancel" data-hotkey="z"/>
</footer>
</form>
</field>
</record>
</odoo>