[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:
William Henrotin
2019-08-13 07:54:38 +00:00
parent c2c9d6d143
commit fff2a30afb
12 changed files with 358 additions and 144 deletions
+15 -1
View File
@@ -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
+10 -38
View File
@@ -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.
+9 -23
View File
@@ -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' :
+1
View File
@@ -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,
}
+87 -82
View File
@@ -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 &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>
@@ -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>
+4
View File
@@ -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')]"/>
+12
View File
@@ -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