[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:
|
for product in self:
|
||||||
product.used_in_bom_count = self.env['mrp.bom'].search_count([('bom_line_ids.product_id', '=', product.id)])
|
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):
|
def action_used_in_bom(self):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
action = self.env.ref('mrp.mrp_bom_form_action').read()[0]
|
action = self.env.ref('mrp.mrp_bom_form_action').read()[0]
|
||||||
@@ -101,6 +112,9 @@ class ProductProduct(models.Model):
|
|||||||
ratios_free_qty = []
|
ratios_free_qty = []
|
||||||
for bom_line, bom_line_data in bom_sub_lines:
|
for bom_line, bom_line_data in bom_sub_lines:
|
||||||
component = bom_line.product_id
|
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']
|
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)
|
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)
|
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_incoming_qty.append(component.incoming_qty / qty_per_kit)
|
||||||
ratios_outgoing_qty.append(component.outgoing_qty / qty_per_kit)
|
ratios_outgoing_qty.append(component.outgoing_qty / qty_per_kit)
|
||||||
ratios_free_qty.append(component.free_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.virtual_available = min(ratios_virtual_available) // 1
|
||||||
product.qty_available = min(ratios_qty_available) // 1
|
product.qty_available = min(ratios_qty_available) // 1
|
||||||
product.incoming_qty = min(ratios_incoming_qty) // 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):
|
class SaleOrderLine(models.Model):
|
||||||
_inherit = 'sale.order.line'
|
_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):
|
def _compute_qty_delivered(self):
|
||||||
super(SaleOrderLine, self)._compute_qty_delivered()
|
super(SaleOrderLine, self)._compute_qty_delivered()
|
||||||
for order_line in self:
|
for order_line in self:
|
||||||
@@ -51,44 +61,6 @@ class SaleOrderLine(models.Model):
|
|||||||
components[product] = {'qty': qty, 'uom': to_uom.id}
|
components[product] = {'qty': qty, 'uom': to_uom.id}
|
||||||
return components
|
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):
|
def _get_qty_procurement(self, previous_product_uom_qty=False):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
# Specific case when we change the qty on a SO for a kit product.
|
# 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.tests import common, Form
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
from odoo.tools import mute_logger
|
from odoo.tools import mute_logger, float_compare
|
||||||
|
|
||||||
|
|
||||||
class TestSaleMrpFlow(common.SavepointCase):
|
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_wh_order.virtual_available, 0)
|
||||||
self.assertEquals(kit_parent_wh1.virtual_available, 1)
|
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
|
# Check there arn't enough quantities available for the sale order
|
||||||
warning = order_line._onchange_product_id_check_availability()
|
self.assertTrue(float_compare(order_line.virtual_available_at_date - order_line.product_uom_qty, 0, precision_rounding=line.product_uom.rounding) == -1)
|
||||||
self.assertTrue(warning)
|
|
||||||
|
|
||||||
# We receive enoug of each component in Warehouse 2 to make 3 kit_parent
|
# We receive enoug of each component in Warehouse 2 to make 3 kit_parent
|
||||||
qty_to_process = {
|
qty_to_process = {
|
||||||
@@ -1075,9 +1074,8 @@ class TestSaleMrpFlow(common.SavepointCase):
|
|||||||
self.assertEquals(kit_parent_wh_order.virtual_available, 3)
|
self.assertEquals(kit_parent_wh_order.virtual_available, 3)
|
||||||
self.assertEquals(kit_parent_wh1.virtual_available, 1)
|
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
|
# Check there arn't enough quantities available for the sale order
|
||||||
warning = order_line._onchange_product_id_check_availability()
|
self.assertTrue(float_compare(order_line.virtual_available_at_date - order_line.product_uom_qty, 0, precision_rounding=line.product_uom.rounding) == -1)
|
||||||
self.assertTrue(warning)
|
|
||||||
|
|
||||||
# We receive enough of each component in Warehouse 2 to make 7 kit_parent
|
# We receive enough of each component in Warehouse 2 to make 7 kit_parent
|
||||||
qty_to_process = {
|
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)
|
kit_parent_wh_order = self.kit_parent.with_context(warehouse=so.warehouse_id.id)
|
||||||
self.assertEquals(kit_parent_wh_order.virtual_available, 7)
|
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):
|
def test_06_kit_qty_delivered_mixed_uom(self):
|
||||||
"""
|
"""
|
||||||
Check that the quantities delivered are correct when a kit involves
|
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
|
virtual_available_wh_order = kit_uom_in_kit.virtual_available
|
||||||
self.assertEquals(virtual_available_wh_order, 1)
|
self.assertEquals(virtual_available_wh_order, 1)
|
||||||
|
|
||||||
# A warning message should be returned as there arn't enough quantities available for the sale order
|
# Check there arn't enough quantities available for the sale order
|
||||||
warning = order_line._onchange_product_id_check_availability()
|
self.assertTrue(float_compare(order_line.virtual_available_at_date - order_line.product_uom_qty, 0, precision_rounding=line.product_uom.rounding) == -1)
|
||||||
self.assertTrue(warning)
|
|
||||||
|
|
||||||
# We receive enough of each component in Warehouse 1 to make 3 kit_uom_in_kit.
|
# 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.
|
# 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)
|
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
|
# Check there arn't enough quantities available for the sale order
|
||||||
warning = order_line._onchange_product_id_check_availability()
|
self.assertTrue(float_compare(order_line.virtual_available_at_date - order_line.product_uom_qty, 0, precision_rounding=line.product_uom.rounding) == -1)
|
||||||
self.assertTrue(warning)
|
|
||||||
kit_uom_in_kit.with_context(warehouse=warehouse_1.id)._compute_quantities()
|
kit_uom_in_kit.with_context(warehouse=warehouse_1.id)._compute_quantities()
|
||||||
virtual_available_wh_order = kit_uom_in_kit.virtual_available
|
virtual_available_wh_order = kit_uom_in_kit.virtual_available
|
||||||
self.assertEquals(virtual_available_wh_order, 3)
|
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()
|
kit_uom_in_kit.with_context(warehouse=warehouse_1.id)._compute_quantities()
|
||||||
self.assertEquals(kit_uom_in_kit.virtual_available, 5)
|
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):
|
def test_10_sale_mrp_kits_routes(self):
|
||||||
|
|
||||||
# Create a kit 'kit_1' :
|
# Create a kit 'kit_1' :
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ Preferences
|
|||||||
'wizard/stock_rules_report_views.xml',
|
'wizard/stock_rules_report_views.xml',
|
||||||
],
|
],
|
||||||
'demo': ['data/sale_order_demo.xml'],
|
'demo': ['data/sale_order_demo.xml'],
|
||||||
|
'qweb': ['static/src/xml/qty_at_date.xml'],
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'auto_install': True,
|
'auto_install': True,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
from odoo import api, fields, models, _
|
from odoo import api, fields, models, _
|
||||||
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, float_compare
|
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)
|
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')
|
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')
|
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')
|
@api.depends('product_id')
|
||||||
def _compute_qty_delivered_method(self):
|
def _compute_qty_delivered_method(self):
|
||||||
@@ -262,56 +349,6 @@ class SaleOrderLine(models.Model):
|
|||||||
if self.product_packaging:
|
if self.product_packaging:
|
||||||
return self._check_package()
|
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')
|
@api.onchange('product_uom_qty')
|
||||||
def _onchange_product_uom_qty(self):
|
def _onchange_product_uom_qty(self):
|
||||||
# When modifying a one2many, _origin doesn't guarantee that its values will be the ones
|
# When modifying a one2many, _origin doesn't guarantee that its values will be the ones
|
||||||
@@ -450,38 +487,6 @@ class SaleOrderLine(models.Model):
|
|||||||
}
|
}
|
||||||
return {}
|
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):
|
def _update_line_quantity(self, values):
|
||||||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||||
line_products = self.filtered(lambda l: l.product_id.type in ['product', 'consu'])
|
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 -*-
|
# -*- coding: utf-8 -*-
|
||||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
# 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.addons.sale.tests.test_sale_common import TestSale
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
@@ -559,3 +560,49 @@ class TestSaleStock(TestSale):
|
|||||||
self.assertEqual(so1.order_line.qty_delivered, 5)
|
self.assertEqual(so1.order_line.qty_delivered, 5)
|
||||||
self.assertEqual(so1.picking_ids[-1].move_lines.product_qty, 10)
|
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>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</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>
|
</data>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -189,6 +189,10 @@ class Product(models.Model):
|
|||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
def get_components(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return self.ids
|
||||||
|
|
||||||
def _get_description(self, picking_type_id):
|
def _get_description(self, picking_type_id):
|
||||||
""" return product receipt/delivery/picking description depending on
|
""" return product receipt/delivery/picking description depending on
|
||||||
picking type passed as argument.
|
picking type passed as argument.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<field name="product_id"/>
|
<field name="product_id"/>
|
||||||
<field name="state"/>
|
<field name="state"/>
|
||||||
<field name="product_tmpl_id"/>
|
<field name="product_tmpl_id"/>
|
||||||
|
<field name="warehouse_id"/>
|
||||||
<group expand="0" string="State">
|
<group expand="0" string="State">
|
||||||
<filter string="Forecasted Stock" name="filter_forecast" domain="[('state', '=', 'forecast')]"/>
|
<filter string="Forecasted Stock" name="filter_forecast" domain="[('state', '=', 'forecast')]"/>
|
||||||
<filter string="Forecasted Receipts" name="filter_in" domain="[('state', '=', 'in')]"/>
|
<filter string="Forecasted Receipts" name="filter_in" domain="[('state', '=', 'in')]"/>
|
||||||
|
|||||||
@@ -19,3 +19,15 @@ class SaleOrderLine(models.Model):
|
|||||||
return qty
|
return qty
|
||||||
else:
|
else:
|
||||||
return super(SaleOrderLine, self)._get_qty_procurement(previous_product_uom_qty=previous_product_uom_qty)
|
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