[IMP] sale_stock,sale_mrp: available quantities on sales order
Selling a product you don't have in stock anymore triggers a message without pertinent information about available quantity. This commit adds a little widget on sale order line to inform the salesman there are not enough quantity available. Clicking on it will reveal: * The estimated delivery date * The actual quantity on hand * The quantity forcasted at the estimated delivery date For kit product, the quantities are the maximum occurence of kit available regarding components quantities. Task 2003485 closes odoo/odoo#34336 Signed-off-by: Simon Lejeune (sle) <sle@openerp.com>
This commit is contained in:
@@ -64,6 +64,17 @@ class ProductProduct(models.Model):
|
||||
for product in self:
|
||||
product.used_in_bom_count = self.env['mrp.bom'].search_count([('bom_line_ids.product_id', '=', product.id)])
|
||||
|
||||
def get_components(self):
|
||||
""" Return the components list ids in case of kit product.
|
||||
Return the product itself otherwise"""
|
||||
self.ensure_one()
|
||||
bom_kit = self.env['mrp.bom']._bom_find(product=self, bom_type='phantom')
|
||||
if bom_kit:
|
||||
boms, bom_sub_lines = bom_kit.explode(self, 1)
|
||||
return [bom_line.product_id.id for bom_line, data in bom_sub_lines if bom_line.product_id.type == 'product']
|
||||
else:
|
||||
return super(ProductProduct, self).get_components()
|
||||
|
||||
def action_used_in_bom(self):
|
||||
self.ensure_one()
|
||||
action = self.env.ref('mrp.mrp_bom_form_action').read()[0]
|
||||
@@ -101,6 +112,9 @@ class ProductProduct(models.Model):
|
||||
ratios_free_qty = []
|
||||
for bom_line, bom_line_data in bom_sub_lines:
|
||||
component = bom_line.product_id
|
||||
if component.type != 'product':
|
||||
# Consumable product have 0 qty_available so we exclude them
|
||||
continue
|
||||
uom_qty_per_kit = bom_line_data['qty'] / bom_line_data['original_qty']
|
||||
qty_per_kit = bom_line.product_uom_id._compute_quantity(uom_qty_per_kit, bom_line.product_id.uom_id)
|
||||
ratios_virtual_available.append(component.virtual_available / qty_per_kit)
|
||||
@@ -108,7 +122,7 @@ class ProductProduct(models.Model):
|
||||
ratios_incoming_qty.append(component.incoming_qty / qty_per_kit)
|
||||
ratios_outgoing_qty.append(component.outgoing_qty / qty_per_kit)
|
||||
ratios_free_qty.append(component.free_qty / qty_per_kit)
|
||||
if bom_sub_lines:
|
||||
if bom_sub_lines and ratios_virtual_available: # Guard against all cnsumable bom: at least one ratio should be present.
|
||||
product.virtual_available = min(ratios_virtual_available) // 1
|
||||
product.qty_available = min(ratios_qty_available) // 1
|
||||
product.incoming_qty = min(ratios_incoming_qty) // 1
|
||||
|
||||
@@ -8,6 +8,16 @@ from odoo.tools import float_compare, float_round
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = 'sale.order.line'
|
||||
|
||||
@api.depends('product_uom_qty', 'qty_delivered', 'product_id', 'state')
|
||||
def _compute_qty_to_deliver(self):
|
||||
"""The inventory widget should now be visible in more cases if the product is consumable."""
|
||||
super(SaleOrderLine, self)._compute_qty_to_deliver()
|
||||
for line in self:
|
||||
if line.state == 'draft' and line.product_type == 'consu':
|
||||
components = line.product_id.get_components()
|
||||
if components and components != [line.product_id.id]:
|
||||
line.display_qty_widget = True
|
||||
|
||||
def _compute_qty_delivered(self):
|
||||
super(SaleOrderLine, self)._compute_qty_delivered()
|
||||
for order_line in self:
|
||||
@@ -51,44 +61,6 @@ class SaleOrderLine(models.Model):
|
||||
components[product] = {'qty': qty, 'uom': to_uom.id}
|
||||
return components
|
||||
|
||||
def _check_availability(self, product_id):
|
||||
""" If the 'product_id' is a kit, this method check if every component's
|
||||
availability and catch every warning returned in order to merge them in a single
|
||||
comprehensive warning
|
||||
"""
|
||||
bom_kit = self.env['mrp.bom']._bom_find(product=product_id, bom_type='phantom')
|
||||
if not bom_kit:
|
||||
return super(SaleOrderLine, self)._check_availability(product_id)
|
||||
|
||||
kit = product_id
|
||||
kit_qty = self.product_uom._compute_quantity(self.product_uom_qty, self.product_id.uom_id)
|
||||
|
||||
# We check if we need to display the quantities of each missing components for all warehouses
|
||||
kit_by_wh = self.product_id.with_context(warehouse=self.order_id.warehouse_id.id)
|
||||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
if float_compare(kit_by_wh.virtual_available, kit_qty, precision_digits=precision) != -1:
|
||||
return {}
|
||||
ignore_warehouse = float_compare(kit.virtual_available, kit_qty, precision_digits=precision) != -1
|
||||
|
||||
message = ''
|
||||
boms, bom_sub_lines = bom_kit.explode(kit, kit_qty)
|
||||
for bom_line, bom_line_data in bom_sub_lines:
|
||||
component = bom_line.product_id
|
||||
component_uom_qty = bom_line_data['qty']
|
||||
component_qty = bom_line.product_uom_id._compute_quantity(component_uom_qty, bom_line.product_id.uom_id)
|
||||
component_warning = self._check_availability_warning(component, component_qty, ignore_warehouse=ignore_warehouse)
|
||||
if component_warning:
|
||||
message += component_warning['warning']['message'] + '\n'
|
||||
|
||||
warning = {}
|
||||
if message:
|
||||
warning_mess = {
|
||||
'title': _('Not enough inventory!'),
|
||||
'message': message
|
||||
}
|
||||
warning = {'warning': warning_mess}
|
||||
return warning
|
||||
|
||||
def _get_qty_procurement(self, previous_product_uom_qty=False):
|
||||
self.ensure_one()
|
||||
# Specific case when we change the qty on a SO for a kit product.
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
||||
|
||||
from odoo.tests import common, Form
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools import mute_logger
|
||||
from odoo.tools import mute_logger, float_compare
|
||||
|
||||
|
||||
class TestSaleMrpFlow(common.SavepointCase):
|
||||
@@ -1052,9 +1052,8 @@ class TestSaleMrpFlow(common.SavepointCase):
|
||||
self.assertEquals(kit_parent_wh_order.virtual_available, 0)
|
||||
self.assertEquals(kit_parent_wh1.virtual_available, 1)
|
||||
|
||||
# A warning message should be returned as there arn't enough quantities available for the sale order
|
||||
warning = order_line._onchange_product_id_check_availability()
|
||||
self.assertTrue(warning)
|
||||
# Check there arn't enough quantities available for the sale order
|
||||
self.assertTrue(float_compare(order_line.virtual_available_at_date - order_line.product_uom_qty, 0, precision_rounding=line.product_uom.rounding) == -1)
|
||||
|
||||
# We receive enoug of each component in Warehouse 2 to make 3 kit_parent
|
||||
qty_to_process = {
|
||||
@@ -1075,9 +1074,8 @@ class TestSaleMrpFlow(common.SavepointCase):
|
||||
self.assertEquals(kit_parent_wh_order.virtual_available, 3)
|
||||
self.assertEquals(kit_parent_wh1.virtual_available, 1)
|
||||
|
||||
# A warning message should be returned as there arn't enough quantities available for the sale order
|
||||
warning = order_line._onchange_product_id_check_availability()
|
||||
self.assertTrue(warning)
|
||||
# Check there arn't enough quantities available for the sale order
|
||||
self.assertTrue(float_compare(order_line.virtual_available_at_date - order_line.product_uom_qty, 0, precision_rounding=line.product_uom.rounding) == -1)
|
||||
|
||||
# We receive enough of each component in Warehouse 2 to make 7 kit_parent
|
||||
qty_to_process = {
|
||||
@@ -1095,10 +1093,6 @@ class TestSaleMrpFlow(common.SavepointCase):
|
||||
kit_parent_wh_order = self.kit_parent.with_context(warehouse=so.warehouse_id.id)
|
||||
self.assertEquals(kit_parent_wh_order.virtual_available, 7)
|
||||
|
||||
# Some cache issue prevents the following to work in a test
|
||||
#warning = order_line._onchange_product_id_check_availability()
|
||||
#self.assertFalse(warning)
|
||||
|
||||
def test_06_kit_qty_delivered_mixed_uom(self):
|
||||
"""
|
||||
Check that the quantities delivered are correct when a kit involves
|
||||
@@ -1294,9 +1288,8 @@ class TestSaleMrpFlow(common.SavepointCase):
|
||||
virtual_available_wh_order = kit_uom_in_kit.virtual_available
|
||||
self.assertEquals(virtual_available_wh_order, 1)
|
||||
|
||||
# A warning message should be returned as there arn't enough quantities available for the sale order
|
||||
warning = order_line._onchange_product_id_check_availability()
|
||||
self.assertTrue(warning)
|
||||
# Check there arn't enough quantities available for the sale order
|
||||
self.assertTrue(float_compare(order_line.virtual_available_at_date - order_line.product_uom_qty, 0, precision_rounding=line.product_uom.rounding) == -1)
|
||||
|
||||
# We receive enough of each component in Warehouse 1 to make 3 kit_uom_in_kit.
|
||||
# Moves are created instead of only updating the quant quantities in order to trigger every compute fields.
|
||||
@@ -1308,9 +1301,8 @@ class TestSaleMrpFlow(common.SavepointCase):
|
||||
}
|
||||
self._create_move_quantities(qty_to_process, components, warehouse_1)
|
||||
|
||||
# Enough quantities should be available to make 3 kit_uom_in_kit and a warning message should be displayed
|
||||
warning = order_line._onchange_product_id_check_availability()
|
||||
self.assertTrue(warning)
|
||||
# Check there arn't enough quantities available for the sale order
|
||||
self.assertTrue(float_compare(order_line.virtual_available_at_date - order_line.product_uom_qty, 0, precision_rounding=line.product_uom.rounding) == -1)
|
||||
kit_uom_in_kit.with_context(warehouse=warehouse_1.id)._compute_quantities()
|
||||
virtual_available_wh_order = kit_uom_in_kit.virtual_available
|
||||
self.assertEquals(virtual_available_wh_order, 3)
|
||||
@@ -1322,12 +1314,6 @@ class TestSaleMrpFlow(common.SavepointCase):
|
||||
kit_uom_in_kit.with_context(warehouse=warehouse_1.id)._compute_quantities()
|
||||
self.assertEquals(kit_uom_in_kit.virtual_available, 5)
|
||||
|
||||
# Some cache issue prevents the following to work in a test
|
||||
|
||||
# No warning should be raised
|
||||
# warning = order_line._onchange_product_id_check_availability()
|
||||
# self.assertFalse(warning)
|
||||
|
||||
def test_10_sale_mrp_kits_routes(self):
|
||||
|
||||
# Create a kit 'kit_1' :
|
||||
|
||||
@@ -35,6 +35,7 @@ Preferences
|
||||
'wizard/stock_rules_report_views.xml',
|
||||
],
|
||||
'demo': ['data/sale_order_demo.xml'],
|
||||
'qweb': ['static/src/xml/qty_at_date.xml'],
|
||||
'installable': True,
|
||||
'auto_install': True,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, float_compare
|
||||
@@ -192,6 +193,92 @@ class SaleOrderLine(models.Model):
|
||||
product_packaging = fields.Many2one('product.packaging', string='Package', default=False)
|
||||
route_id = fields.Many2one('stock.location.route', string='Route', domain=[('sale_selectable', '=', True)], ondelete='restrict')
|
||||
move_ids = fields.One2many('stock.move', 'sale_line_id', string='Stock Moves')
|
||||
product_type = fields.Selection(related='product_id.type')
|
||||
virtual_available_at_date = fields.Float(compute='_compute_qty_at_date')
|
||||
scheduled_date = fields.Datetime(compute='_compute_qty_at_date')
|
||||
free_qty_today = fields.Float(compute='_compute_qty_at_date')
|
||||
qty_available_today = fields.Float(compute='_compute_qty_at_date')
|
||||
warehouse_id = fields.Many2one(related='order_id.warehouse_id')
|
||||
qty_to_deliver = fields.Float(compute='_compute_qty_to_deliver')
|
||||
is_mto = fields.Boolean(compute='_compute_is_mto')
|
||||
display_qty_widget = fields.Boolean(compute='_compute_qty_to_deliver')
|
||||
|
||||
@api.depends('product_id', 'product_uom_qty', 'qty_delivered', 'state')
|
||||
def _compute_qty_to_deliver(self):
|
||||
"""Compute the visibility of the inventory widget."""
|
||||
for line in self:
|
||||
line.qty_to_deliver = line.product_uom_qty - line.qty_delivered
|
||||
if line.state == 'draft' and line.product_type == 'product' and line.qty_to_deliver > 0:
|
||||
line.display_qty_widget = True
|
||||
else:
|
||||
line.display_qty_widget = False
|
||||
|
||||
@api.depends('product_id', 'customer_lead', 'product_uom_qty', 'order_id.warehouse_id', 'order_id.commitment_date')
|
||||
def _compute_qty_at_date(self):
|
||||
""" Compute the quantity forecasted of product at delivery date. There are
|
||||
two cases:
|
||||
1. The quotation has a commitment_date, we take it as delivery date
|
||||
2. The quotation hasn't commitment_date, we compute the estimated delivery
|
||||
date based on lead time"""
|
||||
qty_processed_per_product = defaultdict(lambda: 0)
|
||||
grouped_lines = defaultdict(lambda: self.env['sale.order.line'])
|
||||
# We first loop over the SO lines to group them by warehouse and schedule
|
||||
# date in order to batch the read of the quantities computed field.
|
||||
for line in self:
|
||||
if not line.display_qty_widget:
|
||||
continue
|
||||
warehouse = line.order_id.warehouse_id
|
||||
if line.order_id.commitment_date:
|
||||
date = line.order_id.commitment_date
|
||||
else:
|
||||
confirm_date = line.order_id.confirmation_date if line.order_id.state in ['sale', 'done'] else datetime.now()
|
||||
date = confirm_date + timedelta(days=line.customer_lead or 0.0)
|
||||
grouped_lines[(warehouse.id, date)] |= line
|
||||
|
||||
for (warehouse, scheduled_date), lines in grouped_lines.items():
|
||||
product_qties = lines.mapped('product_id').with_context(to_date=scheduled_date, warehouse=warehouse).read([
|
||||
'qty_available',
|
||||
'free_qty',
|
||||
'virtual_available',
|
||||
])
|
||||
qties_per_product = {
|
||||
product['id']: (product['qty_available'], product['free_qty'], product['virtual_available'])
|
||||
for product in product_qties
|
||||
}
|
||||
for line in lines:
|
||||
line.scheduled_date = scheduled_date
|
||||
qty_available_today, free_qty_today, virtual_available_at_date = qties_per_product[line.product_id.id]
|
||||
line.qty_available_today = qty_available_today - qty_processed_per_product[line.product_id.id]
|
||||
line.free_qty_today = free_qty_today - qty_processed_per_product[line.product_id.id]
|
||||
line.virtual_available_at_date = virtual_available_at_date - qty_processed_per_product[line.product_id.id]
|
||||
qty_processed_per_product[line.product_id.id] += line.product_uom_qty
|
||||
|
||||
@api.depends('product_id', 'route_id', 'order_id.warehouse_id')
|
||||
def _compute_is_mto(self):
|
||||
""" Verify the route of the product based on the warehouse
|
||||
set 'is_available' at True if the product availibility in stock does
|
||||
not need to be verified, which is the case in MTO, Cross-Dock or Drop-Shipping
|
||||
"""
|
||||
for line in self:
|
||||
if not line.display_qty_widget:
|
||||
continue
|
||||
product = line.product_id
|
||||
line.is_mto = False
|
||||
product_routes = line.route_id or (product.route_ids + product.categ_id.total_route_ids)
|
||||
|
||||
# Check MTO
|
||||
wh_mto_route = line.order_id.warehouse_id.mto_pull_id.route_id
|
||||
if wh_mto_route and wh_mto_route.mapped('sequence') <= product_routes.mapped('sequence'):
|
||||
line.is_mto = True
|
||||
else:
|
||||
mto_route = False
|
||||
try:
|
||||
mto_route = self.env['stock.warehouse']._find_global_route('stock.route_warehouse0_mto', _('Make To Order'))
|
||||
except UserError:
|
||||
# if route MTO not found in ir_model_data, we treat the product as in MTS
|
||||
pass
|
||||
if mto_route and mto_route in product_routes:
|
||||
line.is_mto = True
|
||||
|
||||
@api.depends('product_id')
|
||||
def _compute_qty_delivered_method(self):
|
||||
@@ -262,56 +349,6 @@ class SaleOrderLine(models.Model):
|
||||
if self.product_packaging:
|
||||
return self._check_package()
|
||||
|
||||
@api.onchange('product_id')
|
||||
def _onchange_product_id_uom_check_availability(self):
|
||||
if not self.product_uom or (self.product_id.uom_id.category_id.id != self.product_uom.category_id.id):
|
||||
self.product_uom = self.product_id.uom_id
|
||||
self._onchange_product_id_check_availability()
|
||||
|
||||
@api.onchange('product_uom_qty', 'product_uom', 'route_id')
|
||||
def _onchange_product_id_check_availability(self):
|
||||
if not self.product_id or not self.product_uom_qty or not self.product_uom:
|
||||
self.product_packaging = False
|
||||
return {}
|
||||
return self._check_availability(self.product_id)
|
||||
|
||||
def _check_availability(self, product_id):
|
||||
""" The purpose of this method is only to be overridden if
|
||||
'product_id' is a kit
|
||||
"""
|
||||
product_qty = self.product_uom._compute_quantity(self.product_uom_qty, self.product_id.uom_id)
|
||||
return self._check_availability_warning(product_id, product_qty)
|
||||
|
||||
def _check_availability_warning(self, product_id, product_qty, ignore_warehouse=False):
|
||||
if product_id.type == 'product':
|
||||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
product_by_wh = product_id.with_context(
|
||||
warehouse=self.order_id.warehouse_id.id,
|
||||
lang=self.order_id.partner_id.lang or self.env.user.lang or 'en_US'
|
||||
)
|
||||
if float_compare(product_by_wh.virtual_available, product_qty, precision_digits=precision) == -1:
|
||||
is_available = self._check_routing(product_id)
|
||||
if not is_available:
|
||||
message = _('You plan to sell %s %s of %s but you only have %s %s available in %s warehouse.') % \
|
||||
(self.product_uom_qty, self.product_uom.name, product_id.name,
|
||||
product_by_wh.virtual_available,
|
||||
product_by_wh.uom_id.name, self.order_id.warehouse_id.name)
|
||||
# We check if some products are available in other warehouses.
|
||||
if not ignore_warehouse and float_compare(product_by_wh.virtual_available, product_id.virtual_available,
|
||||
precision_digits=precision) == -1:
|
||||
message += _('\nThere are %s %s available across all warehouses.\n\n') % \
|
||||
(product_id.virtual_available, product_by_wh.uom_id.name)
|
||||
for warehouse in self.env['stock.warehouse'].search([]):
|
||||
quantity = product_id.with_context(warehouse=warehouse.id).virtual_available
|
||||
if quantity > 0:
|
||||
message += "%s: %s %s\n" % (warehouse.name, quantity, product_id.uom_id.name)
|
||||
warning_mess = {
|
||||
'title': _('Not enough inventory!'),
|
||||
'message': message
|
||||
}
|
||||
return {'warning': warning_mess}
|
||||
return {}
|
||||
|
||||
@api.onchange('product_uom_qty')
|
||||
def _onchange_product_uom_qty(self):
|
||||
# When modifying a one2many, _origin doesn't guarantee that its values will be the ones
|
||||
@@ -450,38 +487,6 @@ class SaleOrderLine(models.Model):
|
||||
}
|
||||
return {}
|
||||
|
||||
def _check_routing(self, product_id):
|
||||
""" Verify the route of the product based on the warehouse
|
||||
return True if the product availibility in stock does not need to be verified,
|
||||
which is the case in MTO, Cross-Dock or Drop-Shipping
|
||||
"""
|
||||
is_available = False
|
||||
product_routes = self.route_id or (product_id.route_ids + product_id.categ_id.total_route_ids)
|
||||
|
||||
# Check MTO
|
||||
wh_mto_route = self.order_id.warehouse_id.mto_pull_id.route_id
|
||||
if wh_mto_route and wh_mto_route <= product_routes:
|
||||
is_available = True
|
||||
else:
|
||||
mto_route = False
|
||||
try:
|
||||
mto_route = self.env['stock.warehouse']._find_global_route('stock.route_warehouse0_mto', _('Make To Order'))
|
||||
except UserError:
|
||||
# if route MTO not found in ir_model_data, we treat the product as in MTS
|
||||
pass
|
||||
if mto_route and mto_route in product_routes:
|
||||
is_available = True
|
||||
|
||||
# Check Drop-Shipping
|
||||
if not is_available:
|
||||
for pull_rule in product_routes.mapped('rule_ids'):
|
||||
if pull_rule.picking_type_id.sudo().default_location_src_id.usage == 'supplier' and\
|
||||
pull_rule.picking_type_id.sudo().default_location_dest_id.usage == 'customer':
|
||||
is_available = True
|
||||
break
|
||||
|
||||
return is_available
|
||||
|
||||
def _update_line_quantity(self, values):
|
||||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
line_products = self.filtered(lambda l: l.product_id.type in ['product', 'consu'])
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
odoo.define('sale_stock.QtyAtDateWidget', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
var QWeb = core.qweb;
|
||||
|
||||
var Widget = require('web.Widget');
|
||||
var Context = require('web.Context');
|
||||
var Dialog = require('web.Dialog');
|
||||
var data_manager = require('web.data_manager');
|
||||
var widget_registry = require('web.widget_registry');
|
||||
|
||||
var _t = core._t;
|
||||
var time = require('web.time');
|
||||
|
||||
var QtyAtDateWidget = Widget.extend({
|
||||
template: 'sale_stock.qtyAtDate',
|
||||
events: _.extend({}, Widget.prototype.events, {
|
||||
'click .fa-info-circle': '_onClickButton',
|
||||
}),
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {Widget|null} parent
|
||||
* @param {Object} params
|
||||
*/
|
||||
init: function (parent, params) {
|
||||
this.data = params.data;
|
||||
this._super(parent);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
_onClickButton: function (ev) {
|
||||
ev.stopPropagation();
|
||||
var self = this;
|
||||
this.data.delivery_date = this.data.scheduled_date.clone().add(this.getSession().getTZOffset(this.data.scheduled_date), 'minutes').format(time.getLangDateFormat());
|
||||
// The grid view need a specific date format that could be different than
|
||||
// the user one.
|
||||
this.data.delivery_date_grid = this.data.scheduled_date.clone().add(this.getSession().getTZOffset(this.data.scheduled_date), 'minutes').format('YYYY-MM-DD');
|
||||
var $content = $(QWeb.render('sale_stock.QtyDetailDialog', {
|
||||
data: this.data,
|
||||
}));
|
||||
this.dialog = new Dialog(this, {
|
||||
size: 'medium',
|
||||
title: _.str.sprintf(_t('%s Availability'), self.data.product_id.data.display_name),
|
||||
$content: $content,
|
||||
buttons: [{
|
||||
text: _t('Close'),
|
||||
classes: 'btn-primary',
|
||||
close: true,
|
||||
}],
|
||||
});
|
||||
this.dialog.opened().then(function () {
|
||||
var $forecastButton = self.dialog.$('.action_open_forecast');
|
||||
$forecastButton.on('click', function(ev) {
|
||||
ev.stopPropagation();
|
||||
data_manager.load_action('stock.report_stock_quantity_action').then(function (action) {
|
||||
// Change action context to choose a specific date and product(s)
|
||||
// As grid_anchor is set to now() by default in the data, we need
|
||||
// to load the action first, change the context then launch it via do_action
|
||||
// additional_context cannot replace a context value, only add new
|
||||
//
|
||||
// in case of kit product, the forecast view show the kit's components
|
||||
self._rpc({
|
||||
model: 'product.product',
|
||||
method: 'get_components',
|
||||
args: [[self.data.product_id.data.id]]
|
||||
}).then(function (res) {
|
||||
var additional_context = {};
|
||||
additional_context.grid_anchor = self.data.delivery_date_grid;
|
||||
additional_context.search_default_warehouse_id = [self.data.warehouse_id.data.id]
|
||||
action.context = new Context(action.context, additional_context);
|
||||
action.domain = [
|
||||
['product_id', 'in', res]
|
||||
];
|
||||
self.do_action(action);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
this.dialog.open();
|
||||
},
|
||||
});
|
||||
|
||||
widget_registry.add('qty_at_date_widget', QtyAtDateWidget);
|
||||
|
||||
return QtyAtDateWidget;
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
<templates>
|
||||
<div t-name="sale_stock.qtyAtDate">
|
||||
<div t-att-class="!widget.data.id ? 'd-none' : ''">
|
||||
<t t-if="widget.data.virtual_available_at_date < widget.data.qty_to_deliver and !widget.data.is_mto">
|
||||
<a class="fa fa-info-circle text-danger"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<a class="fa fa-info-circle text-primary"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<div t-name="sale_stock.QtyDetailDialog">
|
||||
<div class="container my-2">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="row mt-2">
|
||||
<div class="col-6"><h5><b>Delivery Date</b></h5></div>
|
||||
<div class="col-6"><span t-esc="data.delivery_date"/></div>
|
||||
</div>
|
||||
<div t-if="!data.is_mto" class="row mt-2">
|
||||
<div class="col-6"><h5 class="mb-0"><b>Forecasted Stock</b></h5><small>On delivery date</small></div>
|
||||
<div class="col-6"><t t-esc='data.virtual_available_at_date'/>
|
||||
<label class="mb-0"><t t-esc='data.product_uom.data.display_name'/></label></div>
|
||||
</div>
|
||||
<div t-if="!data.is_mto" class="row mt-3">
|
||||
<div class="col-12">
|
||||
<button class="text-left btn btn-link p-0 action_open_forecast"
|
||||
type="button">
|
||||
<i class="fa fa-fw o_button_icon fa-arrow-right mr-1"></i>
|
||||
View Detailed Forecast
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="!data.is_mto" class="col-6 text-muted">
|
||||
<div class="row mt-2">
|
||||
<div class="col-6"><p class="mb-0"><b>Stock On Hand</b></p></div>
|
||||
<div class="col-6"><t t-esc='data.qty_available_today'/>
|
||||
<label class="mb-0"><t t-esc='data.product_uom.data.display_name'/></label></div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-6"><p class="mb-0"><b>Stock Available </b></p><small>On Hand - Reserved</small></div>
|
||||
<div class="col-6"><t t-esc='data.free_qty_today'/>
|
||||
<label class="mb-0"><t t-esc='data.product_uom.data.display_name'/></label></div>
|
||||
</div>
|
||||
</div>
|
||||
<div t-else="" class="col-6">
|
||||
<div class="row mt-2">
|
||||
<p>This product is replenished on demand.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</templates>
|
||||
@@ -1,5 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from odoo.addons.sale.tests.test_sale_common import TestSale
|
||||
from odoo.exceptions import UserError
|
||||
@@ -559,3 +560,49 @@ class TestSaleStock(TestSale):
|
||||
self.assertEqual(so1.order_line.qty_delivered, 5)
|
||||
self.assertEqual(so1.picking_ids[-1].move_lines.product_qty, 10)
|
||||
|
||||
def test_09_qty_available(self):
|
||||
""" create a sale order in warehouse1, change to warehouse2 and check the
|
||||
available quantities on sale order lines are well updated """
|
||||
# sell two products
|
||||
item1 = self.products['prod_order']
|
||||
item1.type = 'product'
|
||||
warehouse1 = self.env.ref('stock.warehouse0')
|
||||
self.env['stock.quant']._update_available_quantity(item1, warehouse1.lot_stock_id, 10)
|
||||
self.env['stock.quant']._update_reserved_quantity(item1, warehouse1.lot_stock_id, 3)
|
||||
warehouse2 = self.env['stock.warehouse'].search([('id', '!=', warehouse1.id)], limit=1)
|
||||
self.env['stock.quant']._update_available_quantity(item1, warehouse2.lot_stock_id, 5)
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'order_line': [
|
||||
(0, 0, {'name': item1.name, 'product_id': item1.id, 'product_uom_qty': 1, 'product_uom': item1.uom_id.id, 'price_unit': item1.list_price}),
|
||||
],
|
||||
})
|
||||
line = so.order_line[0]
|
||||
self.assertAlmostEqual(line.scheduled_date, datetime.now(), delta=timedelta(seconds=10))
|
||||
self.assertEqual(line.virtual_available_at_date, 10)
|
||||
self.assertEqual(line.free_qty_today, 7)
|
||||
self.assertEqual(line.qty_available_today, 10)
|
||||
self.assertEqual(line.warehouse_id, warehouse1)
|
||||
self.assertEqual(line.qty_to_deliver, 1)
|
||||
so.warehouse_id = warehouse2
|
||||
self.assertEqual(line.virtual_available_at_date, 5)
|
||||
self.assertEqual(line.free_qty_today, 5)
|
||||
self.assertEqual(line.qty_available_today, 5)
|
||||
self.assertEqual(line.warehouse_id, warehouse2)
|
||||
self.assertEqual(line.qty_to_deliver, 1)
|
||||
|
||||
def test_10_qty_available(self):
|
||||
"""create a sale order containing three times the same product. The
|
||||
quantity available should be different for the 3 lines"""
|
||||
item1 = self.products['prod_order']
|
||||
item1.type = 'product'
|
||||
self.env['stock.quant']._update_available_quantity(item1, self.env.ref('stock.stock_location_stock'), 10)
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'order_line': [
|
||||
(0, 0, {'name': item1.name, 'product_id': item1.id, 'product_uom_qty': 5, 'product_uom': item1.uom_id.id, 'price_unit': item1.list_price}),
|
||||
(0, 0, {'name': item1.name, 'product_id': item1.id, 'product_uom_qty': 5, 'product_uom': item1.uom_id.id, 'price_unit': item1.list_price}),
|
||||
(0, 0, {'name': item1.name, 'product_id': item1.id, 'product_uom_qty': 5, 'product_uom': item1.uom_id.id, 'price_unit': item1.list_price}),
|
||||
],
|
||||
})
|
||||
self.assertEqual(so.order_line.mapped('free_qty_today'), [10, 5, 0])
|
||||
|
||||
@@ -75,5 +75,32 @@
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_order_form_inherit_sale_stock_qty" model="ir.ui.view">
|
||||
<field name="name">sale.order.line.tree.sale.stock.qty</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//page/field[@name='order_line']/tree/field[@name='product_uom_qty']" position="after">
|
||||
<!-- below fields are used in the widget qty_at_date_widget -->
|
||||
<field name="product_type" invisible="1"/>
|
||||
<field name="virtual_available_at_date" invisible="1"/>
|
||||
<field name="qty_available_today" invisible="1"/>
|
||||
<field name="free_qty_today" invisible="1"/>
|
||||
<field name="scheduled_date" invisible="1"/>
|
||||
<field name="warehouse_id" invisible="1"/>
|
||||
<field name="qty_to_deliver" invisible="1"/>
|
||||
<field name="is_mto" invisible="1"/>
|
||||
<field name="display_qty_widget" invisible="1"/>
|
||||
<widget name="qty_at_date_widget" attrs="{'invisible': [('display_qty_widget', '=', False)]}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<template id="sale_order_line_view_list" name="sale.order.line.view.list" inherit_id="web.assets_backend">
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript" src="/sale_stock/static/src/js/qty_at_date_widget.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -189,6 +189,10 @@ class Product(models.Model):
|
||||
|
||||
return res
|
||||
|
||||
def get_components(self):
|
||||
self.ensure_one()
|
||||
return self.ids
|
||||
|
||||
def _get_description(self, picking_type_id):
|
||||
""" return product receipt/delivery/picking description depending on
|
||||
picking type passed as argument.
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<field name="product_id"/>
|
||||
<field name="state"/>
|
||||
<field name="product_tmpl_id"/>
|
||||
<field name="warehouse_id"/>
|
||||
<group expand="0" string="State">
|
||||
<filter string="Forecasted Stock" name="filter_forecast" domain="[('state', '=', 'forecast')]"/>
|
||||
<filter string="Forecasted Receipts" name="filter_in" domain="[('state', '=', 'in')]"/>
|
||||
|
||||
@@ -19,3 +19,15 @@ class SaleOrderLine(models.Model):
|
||||
return qty
|
||||
else:
|
||||
return super(SaleOrderLine, self)._get_qty_procurement(previous_product_uom_qty=previous_product_uom_qty)
|
||||
|
||||
def _compute_is_mto(self):
|
||||
super(SaleOrderLine, self)._compute_is_mto()
|
||||
for line in self:
|
||||
if not line.display_qty_widget or line.is_mto:
|
||||
continue
|
||||
product_routes = line.route_id or (line.product_id.route_ids + line.product_id.categ_id.total_route_ids)
|
||||
for pull_rule in product_routes.mapped('rule_ids'):
|
||||
if pull_rule.picking_type_id.sudo().default_location_src_id.usage == 'supplier' and\
|
||||
pull_rule.picking_type_id.sudo().default_location_dest_id.usage == 'customer':
|
||||
line.is_mto = True
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user