From fff2a30afba8b8f13ca03b1719f4e3c93174d1ae Mon Sep 17 00:00:00 2001 From: William Henrotin Date: Thu, 8 Aug 2019 09:19:28 +0000 Subject: [PATCH] [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) --- addons/mrp/models/product.py | 16 +- addons/sale_mrp/models/sale_mrp.py | 48 ++--- addons/sale_mrp/tests/test_sale_mrp_flow.py | 32 +--- addons/sale_stock/__manifest__.py | 1 + addons/sale_stock/models/sale_order.py | 169 +++++++++--------- .../static/src/js/qty_at_date_widget.js | 90 ++++++++++ .../sale_stock/static/src/xml/qty_at_date.xml | 55 ++++++ addons/sale_stock/tests/test_sale_stock.py | 47 +++++ addons/sale_stock/views/sale_order_views.xml | 27 +++ addons/stock/models/product.py | 4 + addons/stock/report/report_stock_quantity.xml | 1 + addons/stock_dropshipping/models/sale.py | 12 ++ 12 files changed, 358 insertions(+), 144 deletions(-) create mode 100644 addons/sale_stock/static/src/js/qty_at_date_widget.js create mode 100644 addons/sale_stock/static/src/xml/qty_at_date.xml diff --git a/addons/mrp/models/product.py b/addons/mrp/models/product.py index 325b303458a..7b4982b27bd 100644 --- a/addons/mrp/models/product.py +++ b/addons/mrp/models/product.py @@ -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 diff --git a/addons/sale_mrp/models/sale_mrp.py b/addons/sale_mrp/models/sale_mrp.py index 33f3bfc40f8..8bba330ca30 100644 --- a/addons/sale_mrp/models/sale_mrp.py +++ b/addons/sale_mrp/models/sale_mrp.py @@ -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. diff --git a/addons/sale_mrp/tests/test_sale_mrp_flow.py b/addons/sale_mrp/tests/test_sale_mrp_flow.py index bafa2a17882..a849b8e82c4 100644 --- a/addons/sale_mrp/tests/test_sale_mrp_flow.py +++ b/addons/sale_mrp/tests/test_sale_mrp_flow.py @@ -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' : 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 @@ + +
+
+ + + + + + +
+
+
+
+
+
+
+
Delivery Date
+
+
+
+
Forecasted Stock
On delivery date
+
+
+
+
+
+ +
+
+
+
+
+

Stock On Hand

+
+
+
+
+

Stock Available

On Hand - Reserved
+
+
+
+
+
+
+

This product is replenished on demand.

+
+
+
+
+
+
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 @@ + + + sale.order.line.tree.sale.stock.qty + + sale.order + + + + + + + + + + + + + + + + + + diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index 1e230033233..638086e96ca 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -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. diff --git a/addons/stock/report/report_stock_quantity.xml b/addons/stock/report/report_stock_quantity.xml index a0987a1ca94..39a030e3e14 100644 --- a/addons/stock/report/report_stock_quantity.xml +++ b/addons/stock/report/report_stock_quantity.xml @@ -20,6 +20,7 @@ + diff --git a/addons/stock_dropshipping/models/sale.py b/addons/stock_dropshipping/models/sale.py index 3f136c605d7..53c471e19f4 100644 --- a/addons/stock_dropshipping/models/sale.py +++ b/addons/stock_dropshipping/models/sale.py @@ -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