summaryrefslogtreecommitdiff
path: root/addons/sale_stock
diff options
context:
space:
mode:
authorWilliam Henrotin <whe@odoo.com>2019-08-08 09:19:28 +0000
committerWilliam Henrotin <whe@odoo.com>2019-08-13 07:54:38 +0000
commitfff2a30afba8b8f13ca03b1719f4e3c93174d1ae (patch)
treea18fd91d7390688898570d79ddb66d99472d644a /addons/sale_stock
parentc2c9d6d143af7f00bb07e391fe494c2496cdad88 (diff)
[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>
Diffstat (limited to 'addons/sale_stock')
-rw-r--r--addons/sale_stock/__manifest__.py1
-rw-r--r--addons/sale_stock/models/sale_order.py169
-rw-r--r--addons/sale_stock/static/src/js/qty_at_date_widget.js90
-rw-r--r--addons/sale_stock/static/src/xml/qty_at_date.xml55
-rw-r--r--addons/sale_stock/tests/test_sale_stock.py47
-rw-r--r--addons/sale_stock/views/sale_order_views.xml27
6 files changed, 307 insertions, 82 deletions
diff --git a/addons/sale_stock/__manifest__.py b/addons/sale_stock/__manifest__.py
index e078cc8ea80..aa1e6be8563 100644
--- a/addons/sale_stock/__manifest__.py
+++ b/addons/sale_stock/__manifest__.py
@@ -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,
}
diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py
index cbdb88a10a9..46cf1e16eb7 100644
--- a/addons/sale_stock/models/sale_order.py
+++ b/addons/sale_stock/models/sale_order.py
@@ -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'])
diff --git a/addons/sale_stock/static/src/js/qty_at_date_widget.js b/addons/sale_stock/static/src/js/qty_at_date_widget.js
new file mode 100644
index 00000000000..f19403f4f3b
--- /dev/null
+++ b/addons/sale_stock/static/src/js/qty_at_date_widget.js
@@ -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;
+});
diff --git a/addons/sale_stock/static/src/xml/qty_at_date.xml b/addons/sale_stock/static/src/xml/qty_at_date.xml
new file mode 100644
index 00000000000..b0cd34626f1
--- /dev/null
+++ b/addons/sale_stock/static/src/xml/qty_at_date.xml
@@ -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 &lt; 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>
diff --git a/addons/sale_stock/tests/test_sale_stock.py b/addons/sale_stock/tests/test_sale_stock.py
index 66b61fe8c97..9fd480aa392 100644
--- a/addons/sale_stock/tests/test_sale_stock.py
+++ b/addons/sale_stock/tests/test_sale_stock.py
@@ -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])
diff --git a/addons/sale_stock/views/sale_order_views.xml b/addons/sale_stock/views/sale_order_views.xml
index f328b550e3a..fc7173c469d 100644
--- a/addons/sale_stock/views/sale_order_views.xml
+++ b/addons/sale_stock/views/sale_order_views.xml
@@ -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>