summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--addons/mrp/models/mrp_production.py22
-rw-r--r--addons/mrp/views/mrp_production_views.xml6
-rw-r--r--addons/sale_stock/models/sale_order.py72
-rw-r--r--addons/sale_stock/static/src/js/qty_at_date_widget.js65
-rw-r--r--addons/sale_stock/static/src/xml/sale_stock.xml43
-rw-r--r--addons/sale_stock/views/sale_order_views.xml9
-rw-r--r--addons/stock/models/stock_move.py59
-rw-r--r--addons/stock/models/stock_picking.py23
-rw-r--r--addons/stock/report/report_stock_forecasted.py6
-rw-r--r--addons/stock/static/src/js/forecast_widget.js38
-rw-r--r--addons/stock/static/src/xml/forecast_widget.xml11
-rw-r--r--addons/stock/views/stock_picking_views.xml8
12 files changed, 191 insertions, 171 deletions
diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py
index 433813dffd7..ff169be7321 100644
--- a/addons/mrp/models/mrp_production.py
+++ b/addons/mrp/models/mrp_production.py
@@ -13,6 +13,7 @@ from itertools import groupby
from odoo import api, fields, models, _
from odoo.exceptions import AccessError, UserError
from odoo.tools import float_compare, float_round, float_is_zero, format_datetime
+from odoo.tools.misc import format_date
from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES
@@ -289,25 +290,20 @@ class MrpProduction(models.Model):
for production in self:
production.mrp_production_backorder_count = len(production.procurement_group_id.mrp_production_ids)
- @api.depends('move_raw_ids', 'state')
+ @api.depends('move_raw_ids', 'state', 'date_planned_start', 'move_raw_ids.forecast_availability', 'move_raw_ids.forecast_expected_date')
def _compute_components_availability(self):
self.components_availability = False
self.components_availability_state = 'available'
productions = self.filtered(lambda mo: mo.state not in ['cancel', 'draft', 'done'])
+ productions.components_availability = _('Available')
for production in productions:
- forecast_data = []
- for move in production.move_raw_ids:
- forecast_data.append(json.loads(move.json_forecast))
- production.components_availability = 'Available'
- forecast_data.sort(key=lambda line: (
- line.get('sortingDate', ''),
- ), reverse=True)
- if forecast_data[0].get('sortingDate', False):
- production.components_availability = _('Exp %s', forecast_data[0]['expectedDate'])
- production.components_availability_state = 'late' if forecast_data[0]['isLate'] else 'expected'
- elif any(not data.get('reservedAvailability', False) for data in forecast_data):
- production.components_availability = 'Not Available'
+ forecast_date = max(production.move_raw_ids.filtered('forecast_expected_date').mapped('forecast_expected_date'), default=False)
+ if any(float_compare(move.forecast_availability, move.product_qty, move.product_id.uom_id.rounding) == -1 for move in production.move_raw_ids):
+ production.components_availability = _('Not Available')
production.components_availability_state = 'late'
+ elif forecast_date:
+ production.components_availability = _('Exp %s', format_date(self.env, forecast_date))
+ production.components_availability_state = 'late' if forecast_date > production.date_planned_start else 'expected'
@api.depends('move_finished_ids.date_deadline')
def _compute_date_deadline(self):
diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml
index 30b4e775d9c..b302d952c18 100644
--- a/addons/mrp/views/mrp_production_views.xml
+++ b/addons/mrp/views/mrp_production_views.xml
@@ -263,7 +263,11 @@
<field name="state" invisible="1" force_save="1"/>
<field name="should_consume_qty" invisible="1"/>
<field name="product_uom_qty" widget="mrp_should_consume" string="To Consume" attrs="{'readonly': ['&amp;', ('parent.state', '!=', 'draft'), '|', ('parent.state', 'not in', ('confirmed', 'planned', 'progress', 'to_close')), ('parent.is_locked', '=', True)]}" width="1"/>
- <field name="json_forecast" string="Reserved" attrs="{'column_invisible': [('parent.state', 'in', ['draft', 'done'])]}" widget="forecast_widget"/>
+ <field name="product_type" invisible="1"/>
+ <field name="product_qty" invisible="1" readonly="1"/>
+ <field name="reserved_availability" invisible="1"/>
+ <field name="forecast_expected_date" invisible="1"/>
+ <field name="forecast_availability" string="Reserved" attrs="{'column_invisible': [('parent.state', 'in', ['draft', 'done'])]}" widget="forecast_widget"/>
<field name="is_quantity_done_editable" invisible="1"/>
<field name="quantity_done" string="Consumed"
decoration-success="not is_done and (quantity_done - should_consume_qty == 0)"
diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py
index 86fcf8150bc..93561f2fa3b 100644
--- a/addons/sale_stock/models/sale_order.py
+++ b/addons/sale_stock/models/sale_order.py
@@ -267,47 +267,65 @@ class SaleOrderLine(models.Model):
route_id = fields.Many2one('stock.location.route', string='Route', domain=[('sale_selectable', '=', True)], ondelete='restrict', check_company=True)
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')
+ virtual_available_at_date = fields.Float(compute='_compute_qty_at_date', digits='Product Unit of Measure')
scheduled_date = fields.Datetime(compute='_compute_qty_at_date')
+ forecast_expected_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')
+ qty_to_deliver = fields.Float(compute='_compute_qty_to_deliver', digits='Product Unit of Measure')
is_mto = fields.Boolean(compute='_compute_is_mto')
display_qty_widget = fields.Boolean(compute='_compute_qty_to_deliver')
json_forecast = fields.Char('JSON data for the forecast widget', compute='_compute_json_forecast')
- @api.depends('product_id', 'product_uom_qty', 'qty_delivered', 'state')
+ @api.depends('product_type', 'product_uom_qty', 'qty_delivered', 'state', 'move_ids')
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
+ if line.state in ('draft', 'sent', 'sale') and line.product_type == 'product' and line.qty_to_deliver > 0:
+ if line.state == 'sale' and not line.move_ids:
+ line.display_qty_widget = False
+ else:
+ line.display_qty_widget = True
else:
line.display_qty_widget = False
- @api.depends('product_id', 'customer_lead', 'product_uom_qty', 'product_uom', 'order_id.commitment_date')
+ @api.depends(
+ 'product_id', 'customer_lead', 'product_uom_qty', 'product_uom', 'order_id.commitment_date',
+ 'move_ids', 'move_ids.forecast_expected_date', 'move_ids.forecast_availability')
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"""
+ treated = self.browse()
+ # If the state is already in sale the picking is created and a simple forecasted quantity isn't enough
+ # Then used the forecasted data of the related stock.move
+ for line in self.filtered(lambda l: l.state == 'sale'):
+ if not line.display_qty_widget:
+ continue
+ moves = line.move_ids
+ line.forecast_expected_date = max(moves.filtered("forecast_expected_date").mapped("forecast_expected_date"), default=False)
+ line.qty_available_today = 0
+ line.virtual_available_at_date = 0
+ for move in moves:
+ line.qty_available_today += move.product_uom._compute_quantity(move.reserved_availability, line.product_uom)
+ line.virtual_available_at_date += move.product_id.uom_id._compute_quantity(move.forecast_availability, line.product_uom)
+ line.scheduled_date = line.order_id.commitment_date or line._expected_date()
+ line.free_qty_today = False
+ treated |= line
+
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:
+ for line in self.filtered(lambda l: l.state in ('draft', 'sent')):
if not (line.product_id and line.display_qty_widget):
continue
- if line.order_id.commitment_date:
- date = line.order_id.commitment_date
- else:
- date = line._expected_date()
- grouped_lines[(line.warehouse_id.id, date)] |= line
+ grouped_lines[(line.warehouse_id.id, line.order_id.commitment_date or line._expected_date())] |= line
- treated = self.browse()
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',
@@ -324,6 +342,7 @@ class SaleOrderLine(models.Model):
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]
+ line.forecast_expected_date = False
if line.product_uom and line.product_id.uom_id and line.product_uom != line.product_id.uom_id:
line.qty_available_today = line.product_id.uom_id._compute_quantity(line.qty_available_today, line.product_uom)
line.free_qty_today = line.product_id.uom_id._compute_quantity(line.free_qty_today, line.product_uom)
@@ -333,6 +352,7 @@ class SaleOrderLine(models.Model):
remaining = (self - treated)
remaining.virtual_available_at_date = False
remaining.scheduled_date = False
+ remaining.forecast_expected_date = False
remaining.free_qty_today = False
remaining.qty_available_today = False
@@ -366,31 +386,7 @@ class SaleOrderLine(models.Model):
@api.depends('move_ids', 'order_id.expected_date', 'qty_delivered')
def _compute_json_forecast(self):
self.json_forecast = False
- if not any(self._ids):
- # onchange
- return
- # compute
- precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
- for so_line in self:
- if any(move.state == 'done' for move in so_line.move_ids) or not so_line.move_ids.picking_id:
- qty_delivered = float_repr(so_line.qty_delivered, precision)
- so_line.json_forecast = json.dumps({'reservedAvailability': qty_delivered})
- else:
- # For waiting deliveries, take the info from the report line.
- delivery = so_line.move_ids.picking_id.filtered(lambda picking: picking.state in ['confirmed', 'waiting'])
- if delivery.exists():
- moves_to_process = so_line.move_ids.filtered(lambda move: move.id in delivery.move_lines.ids and move.state not in ['cancel', 'done'])
- if moves_to_process.exists():
- so_line.json_forecast = moves_to_process[0].json_forecast
- else:
- # For assigned deliveries, take the delivery's date.
- delivery = so_line.move_ids.picking_id.filtered(lambda picking: picking.state == 'assigned')
- if delivery.exists():
- date_expected = delivery.scheduled_date
- so_line.json_forecast = json.dumps({
- 'expectedDate': format_date(self.env, date_expected),
- 'isLate': date_expected > so_line.order_id.expected_date,
- })
+ # TODO: remove this fields in master -> computation done in _compute_qty_at_date
@api.depends('product_id')
def _compute_qty_delivered_method(self):
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
index 53fc2668a2a..8e7d3ab498b 100644
--- a/addons/sale_stock/static/src/js/qty_at_date_widget.js
+++ b/addons/sale_stock/static/src/js/qty_at_date_widget.js
@@ -5,10 +5,8 @@ var core = require('web.core');
var QWeb = core.qweb;
var Widget = require('web.Widget');
-var Context = require('web.Context');
-var data_manager = require('web.data_manager');
var widget_registry = require('web.widget_registry');
-var config = require('web.config');
+var utils = require('web.utils');
var _t = core._t;
var time = require('web.time');
@@ -16,7 +14,7 @@ var time = require('web.time');
var QtyAtDateWidget = Widget.extend({
template: 'sale_stock.qtyAtDate',
events: _.extend({}, Widget.prototype.events, {
- 'click .fa-info-circle': '_onClickButton',
+ 'click .fa-area-chart': '_onClickButton',
}),
/**
@@ -26,6 +24,8 @@ var QtyAtDateWidget = Widget.extend({
*/
init: function (parent, params) {
this.data = params.data;
+ this.fields = params.fields;
+ this._updateData();
this._super(parent);
},
@@ -36,11 +36,27 @@ var QtyAtDateWidget = Widget.extend({
});
},
+ _updateData: function() {
+ // add some data to simplify the template
+ if (this.data.scheduled_date) {
+ this.data.will_be_fulfilled = utils.round_decimals(this.data.virtual_available_at_date, this.fields.virtual_available_at_date.digits[1]) >= utils.round_decimals(this.data.qty_to_deliver, this.fields.qty_to_deliver.digits[1]);
+ this.data.will_be_late = this.data.forecast_expected_date && this.data.forecast_expected_date > this.data.scheduled_date;
+ if (['draft', 'sent'].includes(this.data.state)){
+ // Moves aren't created yet, then the forecasted is only based on virtual_available of quant
+ this.data.forecasted_issue = !this.data.will_be_fulfilled && !this.data.is_mto;
+ } else {
+ // Moves are created, using the forecasted data of related moves
+ this.data.forecasted_issue = !this.data.will_be_fulfilled || this.data.will_be_late;
+ }
+ }
+ },
+
updateState: function (state) {
this.$el.popover('dispose');
var candidate = state.data[this.getParent().currentRow];
if (candidate) {
this.data = candidate.data;
+ this._updateData();
this.renderElement();
this._setPopOver();
}
@@ -54,29 +70,17 @@ var QtyAtDateWidget = Widget.extend({
*/
async _openForecast(ev) {
ev.stopPropagation();
- // 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
- const [action, res] = await Promise.all([
- data_manager.load_action('stock.report_stock_quantity_action_product'),
- this._rpc({
- model: 'product.product',
- method: 'get_components',
- args: [[this.data.product_id.data.id]]
- })
- ]);
- const additional_context = {
- grid_anchor: this.data.delivery_date_grid,
- search_default_warehouse_id: [this.data.warehouse_id.data.id],
- search_default_below_warehouse: false
+ // TODO: in case of kit product, the forecast view should show the kit's components (get_component)
+ // The forecast_report doesn't not allow for now multiple products
+ var action = await this._rpc({
+ model: 'product.product',
+ method: 'action_product_forecast_report',
+ args: [[this.data.product_id.data.id]]
+ });
+ action.context = {
+ active_model: 'product.product',
+ active_id: this.data.product_id.data.id,
};
- action.context = new Context(action.context, additional_context);
- action.domain = [
- ['product_id', 'in', res]
- ];
return this.do_action(action);
},
@@ -85,10 +89,9 @@ var QtyAtDateWidget = Widget.extend({
return;
}
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');
- this.data.debug = config.isDebug();
+ if (this.data.forecast_expected_date) {
+ this.data.forecast_expected_date_str = this.data.forecast_expected_date.clone().add(this.getSession().getTZOffset(this.data.forecast_expected_date), 'minutes').format(time.getLangDateFormat());
+ }
const $content = $(QWeb.render('sale_stock.QtyDetailPopOver', {
data: this.data,
}));
@@ -125,7 +128,7 @@ var QtyAtDateWidget = Widget.extend({
// We add the property special click on the widget link.
// This hack allows us to trigger the popover (see _setPopOver) without
// triggering the _onRowClicked that opens the order line form view.
- this.$el.find('.fa-info-circle').prop('special_click', true);
+ this.$el.find('.fa-area-chart').prop('special_click', true);
},
});
diff --git a/addons/sale_stock/static/src/xml/sale_stock.xml b/addons/sale_stock/static/src/xml/sale_stock.xml
index a79e31d0aa0..5fb2646f92f 100644
--- a/addons/sale_stock/static/src/xml/sale_stock.xml
+++ b/addons/sale_stock/static/src/xml/sale_stock.xml
@@ -1,31 +1,26 @@
<templates>
<div t-name="sale_stock.qtyAtDate">
<div t-att-class="!widget.data.display_qty_widget ? 'invisible' : ''">
- <t t-if="widget.data.virtual_available_at_date &lt; widget.data.qty_to_deliver and !widget.data.is_mto">
- <a tabindex="0" class="fa fa-info-circle text-danger"/>
- </t>
- <t t-else="">
- <a tabindex="0" class="fa fa-info-circle text-primary"/>
- </t>
+ <a tabindex="0" t-attf-class="fa fa-area-chart {{ widget.data.forecasted_issue ? 'text-danger' : 'text-primary' }}"/>
</div>
</div>
<div t-name="sale_stock.QtyDetailPopOver">
- <table>
+ <table class="table table-borderless table-sm">
<tbody>
- <t t-if="!data.is_mto">
+ <t t-if="!data.is_mto and ['draft', 'sent'].includes(data.state)">
<tr>
<td><strong>Forecasted Stock</strong><br /><small>On <span t-esc="data.delivery_date"/></small></td>
- <td><t t-esc='data.virtual_available_at_date'/>
+ <td><b t-esc='data.virtual_available_at_date'/>
<t t-esc='data.product_uom.data.display_name'/></td>
</tr>
<tr>
<td><strong>Available</strong><br /><small>All planned operations included</small></td>
- <td><t t-esc='data.free_qty_today'/>
+ <td><b t-esc='data.free_qty_today'/>
<t t-esc='data.product_uom.data.display_name'/></td>
</tr>
</t>
- <t t-else="">
+ <t t-elif="data.is_mto and ['draft', 'sent'].includes(data.state)">
<tr>
<td><strong>Expected Delivery</strong></td>
<td class="oe-right"><span t-esc="data.delivery_date"/></td>
@@ -34,6 +29,32 @@
<p>This product is replenished on demand.</p>
</tr>
</t>
+ <t t-elif="data.state == 'sale'">
+ <tr>
+ <td>
+ <strong>Reserved</strong><br/>
+ </td>
+ <td style="min-width: 50px; text-align: right;">
+ <b t-esc='data.qty_available_today'/> <t t-esc='data.product_uom.data.display_name'/>
+ </td>
+ </tr>
+ <tr t-if="data.qty_available_today &lt; data.qty_to_deliver">
+ <td>
+ <span t-if="data.will_be_fulfilled and data.forecast_expected_date_str">
+ Remaining demand available at <b t-esc="data.forecast_expected_date_str" t-att-class="data.scheduled_date &lt; data.forecast_expected_date ? 'text-danger' : ''"/>
+ </span>
+ <span t-elif="!data.will_be_fulfilled and data.forecast_expected_date_str" class="text-danger">
+ No enough future availaibility
+ </span>
+ <span t-elif="!data.will_be_fulfilled" class="text-danger">
+ No future availaibility
+ </span>
+ <span t-else="">
+ Available in stock
+ </span>
+ </td>
+ </tr>
+ </t>
</tbody>
</table>
<button t-if="!data.is_mto" class="text-left btn btn-link action_open_forecast"
diff --git a/addons/sale_stock/views/sale_order_views.xml b/addons/sale_stock/views/sale_order_views.xml
index e4b4700def7..6ba05d03c13 100644
--- a/addons/sale_stock/views/sale_order_views.xml
+++ b/addons/sale_stock/views/sale_order_views.xml
@@ -53,12 +53,6 @@
<xpath expr="//field[@name='order_line']/tree/field[@name='analytic_tag_ids']" position="after">
<field name="route_id" groups="stock.group_adv_location" options="{'no_create': True}" optional="hide"/>
</xpath>
- <xpath expr="//field[@name='order_line']/tree/field[@name='qty_delivered']" position="attributes">
- <attribute name="attrs">{'column_invisible': [('parent.state', 'not in', ['cancel', 'done'])]}</attribute>
- </xpath>
- <xpath expr="//field[@name='order_line']/tree/field[@name='qty_delivered']" position="after">
- <field name="json_forecast" string="Delivered" widget="forecast_widget" attrs="{'column_invisible': [('parent.state', 'in', ['cancel', 'done'])]}"/>
- </xpath>
</field>
</record>
@@ -100,13 +94,14 @@
<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">
+ <xpath expr="//page/field[@name='order_line']/tree/field[@name='qty_delivered']" 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="forecast_expected_date" invisible="1"/>
<field name="warehouse_id" invisible="1"/>
<field name="qty_to_deliver" invisible="1"/>
<field name="is_mto" invisible="1"/>
diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py
index cd4b4e593f8..0d5e62329eb 100644
--- a/addons/stock/models/stock_move.py
+++ b/addons/stock/models/stock_move.py
@@ -176,7 +176,10 @@ class StockMove(models.Model):
next_serial = fields.Char('First SN')
next_serial_count = fields.Integer('Number of SN')
orderpoint_id = fields.Many2one('stock.warehouse.orderpoint', 'Original Reordering Rule', check_company=True)
+ # TODO : remove `json_forecast` in master, use forcast_ fields instead
json_forecast = fields.Char('JSON data for the forecast widget', compute='_compute_json_forecast')
+ forecast_availability = fields.Float('Forcast Availability', compute='_compute_json_forecast', digits='Product Unit of Measure')
+ forecast_expected_date = fields.Datetime('Forcasted Expected date', compute='_compute_json_forecast')
lot_ids = fields.Many2many('stock.production.lot', compute='_compute_lot_ids', inverse='_set_lot_ids', string='Serial Numbers', readonly=False)
@api.onchange('product_id', 'picking_type_id')
@@ -405,30 +408,24 @@ class StockMove(models.Model):
total_availability = self.env['stock.quant']._get_available_quantity(move.product_id, move.location_id) if move.product_id else 0.0
move.availability = min(move.product_qty, total_availability)
- @api.depends('product_id', 'picking_type_id', 'picking_id', 'reserved_availability', 'priority')
+ @api.depends('product_id', 'picking_type_id', 'picking_id', 'reserved_availability', 'priority', 'state', 'product_uom_qty')
def _compute_json_forecast(self):
+ """ Compute forecasted information of the related product by warehouse."""
self.json_forecast = False
- if not any(self._ids):
- # onchange
- return
- # compute
- precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
- not_product_moves = self.filtered(lambda move: move.product_id.type == 'product')
+ self.forecast_availability = False
+ self.forecast_expected_date = False
+
+ not_product_moves = self.filtered(lambda move: move.product_id.type != 'product')
for move in not_product_moves:
- reserved_availability = float_repr(move.reserved_availability, precision)
- move.json_forecast = json.dumps({'reservedAvailability': reserved_availability})
+ move.forecast_availability = move.product_qty
outgoing_unreserved_moves_per_warehouse = defaultdict(lambda: self.env['stock.move'])
for move in (self - not_product_moves):
picking_type = move.picking_type_id or move.picking_id.picking_type_id
- is_unreserved = float_is_zero(move.reserved_availability, precision_rounding=move.product_uom.rounding)
- if picking_type.code in self._consuming_picking_types():
- if is_unreserved:
- outgoing_unreserved_moves_per_warehouse[picking_type.warehouse_id] |= move
- else:
- reserved_availability = float_repr(move.reserved_availability, precision)
- move.json_forecast = json.dumps({'reservedAvailability': reserved_availability})
- if not outgoing_unreserved_moves_per_warehouse:
- return
+ is_unreserved = move.state in ('waiting', 'confirmed', 'partially_available')
+ if picking_type.code in self._consuming_picking_types() and is_unreserved:
+ outgoing_unreserved_moves_per_warehouse[picking_type.warehouse_id] |= move
+ elif picking_type.code in self._consuming_picking_types():
+ move.forecast_availability = move.reserved_availability
for warehouse, moves in outgoing_unreserved_moves_per_warehouse.items():
product_variant_ids = moves.product_id.ids
@@ -439,25 +436,13 @@ class StockMove(models.Model):
forecast_lines = self.env['report.stock.report_product_product_replenishment']\
._get_report_lines(None, product_variant_ids, wh_location_ids)
for move in moves:
- found = [l for l in forecast_lines if l["move_out"] == move and l["replenishment_filled"] is True]
- if found:
- move_ins = list(filter(lambda report_line: report_line['move_in'], found))
- # If the move is linked to multiple ingoing moves, take only the last one to have the worst expected date.
- if len(move_ins):
- found = [move_ins[-1]]
- # The move is replenished but there's no expected date -> take from stock
- if found[0]["receipt_date_short"] is False:
- reserved_availability = float_repr(move.reserved_availability, precision)
- move.json_forecast = json.dumps({'reservedAvailability': reserved_availability})
- else:
- move.json_forecast = json.dumps({
- 'sortingDate': format_date(self.env, found[0]['move_in'].date, date_format='yyyy-MM-dd'),
- 'expectedDate': found[0]["receipt_date_short"],
- 'isLate': found[0]["is_late"],
- 'replenishmentFilled': found[0]["replenishment_filled"]
- })
- else:
- move.json_forecast = json.dumps({'expectedDate': None})
+ lines = [l for l in forecast_lines if l["move_out"] == move._origin and l["replenishment_filled"] is True]
+ if lines:
+ move.forecast_availability = sum(m['quantity'] for m in lines)
+ move_ins_lines = list(filter(lambda report_line: report_line['move_in'], lines))
+ if move_ins_lines:
+ expected_date = max(m['move_in'].date for m in move_ins_lines)
+ move.forecast_expected_date = expected_date
def _set_date_deadline(self, new_deadline):
# Handle the propagation of `date_deadline` fields (up and down stream - only update by up/downstream documents)
diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py
index cac496480fd..273857c1019 100644
--- a/addons/stock/models/stock_picking.py
+++ b/addons/stock/models/stock_picking.py
@@ -15,6 +15,7 @@ from odoo.exceptions import UserError
from odoo.osv import expression
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, format_datetime
from odoo.tools.float_utils import float_compare, float_is_zero, float_round
+from odoo.tools.misc import format_date
class PickingType(models.Model):
@@ -400,24 +401,20 @@ class Picking(models.Model):
for picking in self:
picking.delay_alert_date = delay_alert_date_data.get(picking.id, False)
- @api.depends('move_lines', 'state')
+ @api.depends('move_lines', 'state', 'picking_type_code', 'move_lines.forecast_availability', 'move_lines.forecast_expected_date')
def _compute_products_availability(self):
self.products_availability = False
self.products_availability_state = 'available'
pickings = self.filtered(lambda picking: picking.state not in ['cancel', 'draft', 'done'] and picking.picking_type_code == 'outgoing')
+ pickings.products_availability = _('Available')
for picking in pickings:
- forecast_data = []
- for move in picking.move_lines:
- if move.json_forecast:
- forecast_data.append(json.loads(move.json_forecast))
- picking.products_availability = 'Available'
- forecast_data.sort(key=lambda line: line.get('sortingDate', ''), reverse=True)
- if len(forecast_data) > 0 and forecast_data[0].get('sortingDate', False):
- picking.products_availability = _('Exp %s', forecast_data[0]['expectedDate'])
- picking.products_availability_state = 'late' if forecast_data[0]['isLate'] else 'expected'
- elif any(not data.get('reservedAvailability', False) for data in forecast_data):
- picking.products_availability = 'Not Available'
- picking.products_availability_state = 'late'
+ forecast_date = max(picking.move_lines.filtered('forecast_expected_date').mapped('forecast_expected_date'), default=False)
+ if any(float_compare(move.forecast_availability, move.product_qty, move.product_id.uom_id.rounding) == -1 for move in picking.move_lines):
+ picking.components_availability = _('Not Available')
+ picking.components_availability_state = 'late'
+ elif forecast_date:
+ picking.components_availability = _('Exp %s', format_date(self.env, forecast_date))
+ picking.components_availability_state = 'late' if picking.date_deadline < forecast_date else 'expected'
@api.depends('picking_type_id.show_operations')
def _compute_show_operations(self):
diff --git a/addons/stock/report/report_stock_forecasted.py b/addons/stock/report/report_stock_forecasted.py
index 1316f24858d..f9ffe29e46c 100644
--- a/addons/stock/report/report_stock_forecasted.py
+++ b/addons/stock/report/report_stock_forecasted.py
@@ -124,8 +124,6 @@ class ReplenishmentReport(models.AbstractModel):
'uom_id': product.uom_id,
'receipt_date': format_datetime(self.env, move_in.date, timezone, 'medium') if move_in else False,
'delivery_date': format_datetime(self.env, move_out.date, timezone, 'medium') if move_out else False,
- 'receipt_date_short': format_date(self.env, move_in.date) if move_in else False,
- 'delivery_date_short': format_date(self.env, move_out.date) if move_out else False,
'is_late': is_late,
'quantity': quantity,
'move_out': move_out,
@@ -168,10 +166,10 @@ class ReplenishmentReport(models.AbstractModel):
lines.append(self._prepare_report_line(taken_from_in, move_in=in_[1], move_out=out))
ins_per_product[out.product_id.id][index][0] -= taken_from_in
if ins_per_product[out.product_id.id][index][0] <= 0:
- index_to_remove.insert(0, index)
+ index_to_remove.append(index)
if float_is_zero(demand, precision_rounding=product.uom_id.rounding):
break
- for index in index_to_remove:
+ for index in index_to_remove[::-1]:
ins_per_product[out.product_id.id].pop(index)
# Not reconciled.
if not float_is_zero(demand, precision_rounding=product.uom_id.rounding):
diff --git a/addons/stock/static/src/js/forecast_widget.js b/addons/stock/static/src/js/forecast_widget.js
index 6cbf4986774..b3535121268 100644
--- a/addons/stock/static/src/js/forecast_widget.js
+++ b/addons/stock/static/src/js/forecast_widget.js
@@ -3,22 +3,42 @@ odoo.define('stock.forecast_widget', function (require) {
const AbstractField = require('web.AbstractField');
const fieldRegistry = require('web.field_registry');
+const field_utils = require('web.field_utils');
+const utils = require('web.utils');
const core = require('web.core');
const QWeb = core.qweb;
const ForecastWidgetField = AbstractField.extend({
- supportedFieldTypes: ['char'],
+ supportedFieldTypes: ['float'],
_render: function () {
- const forecastData = JSON.parse(this.value);
- if (!forecastData) {
- this.$el.html('');
- } else {
- // Don't display the graph button for non-storable products or if was explicited.
- forecastData.hideReportButton = this.recordData.product_type !== 'product';
- this.$el.html(QWeb.render('stock.forecastWidget', forecastData));
- this.$el.on('click', this._onOpenReport.bind(this));
+ var data = Object.assign({}, this.record.data, {
+ forecast_availability_str: field_utils.format.float(
+ this.record.data.forecast_availability,
+ this.record.fields.forecast_availability,
+ this.nodeOptions
+ ),
+ reserved_availability_str: field_utils.format.float(
+ this.record.data.reserved_availability,
+ this.record.fields.reserved_availability,
+ this.nodeOptions
+ ),
+ forecast_expected_date_str: field_utils.format.date(
+ this.record.data.forecast_expected_date,
+ this.record.fields.forecast_expected_date
+ ),
+ });
+ if (data.forecast_expected_date && data.date_deadline) {
+ data.forecast_is_late = data.forecast_expected_date > data.date_deadline;
}
+ data.will_be_fulfilled = utils.round_decimals(data.forecast_availability, this.record.fields.forecast_availability.digits[1]) >= utils.round_decimals(data.product_qty, this.record.fields.product_qty.digits[1]);
+
+ this.$el.html(QWeb.render('stock.forecastWidget', data));
+ this.$el.on('click', this._onOpenReport.bind(this));
+ },
+
+ isSet: function () {
+ return true;
},
//--------------------------------------------------------------------------
diff --git a/addons/stock/static/src/xml/forecast_widget.xml b/addons/stock/static/src/xml/forecast_widget.xml
index 2e53943803c..2f2f14b2ec5 100644
--- a/addons/stock/static/src/xml/forecast_widget.xml
+++ b/addons/stock/static/src/xml/forecast_widget.xml
@@ -1,11 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="stock.forecastWidget">
- <span t-if="reservedAvailability" t-esc="reservedAvailability"/>
- <span t-elif="!expectedDate" style="color: red;">Not Available</span>
- <span t-else="" t-att-style="'color: ' + (isLate ? 'red;' : 'orange;')">Exp <t t-esc="expectedDate"/></span>
- <button t-if="!hideReportButton" class="o_forecast_report_button btn btn-link o_icon_button ml-2" title="Forecasted Report">
- <i class="fa fa-fw fa-area-chart"/>
+ <span t-if="['draft', 'partially_available', 'assigned'].includes(state)" t-esc="reserved_availability_str"/>
+ <span t-elif="!forecast_expected_date_str and will_be_fulfilled" class="text-success">Available</span>
+ <span t-elif="forecast_expected_date_str" t-att-class="forecast_is_late ? 'text-danger' : 'text-warning'">Exp <t t-esc="forecast_expected_date_str"/></span>
+ <span t-else="" class="text-danger">Not Available</span>
+ <button t-if="product_type == 'product'" class="o_forecast_report_button btn btn-link o_icon_button ml-2" title="Forecasted Report">
+ <i t-attf-class="fa fa-fw fa-area-chart {{ state != 'draft' and (!will_be_fulfilled or forecast_is_late) ? 'text-danger' : '' }}"/>
</button>
</t>
</templates>
diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml
index cfdab213a2f..516d3f5f8a7 100644
--- a/addons/stock/views/stock_picking_views.xml
+++ b/addons/stock/views/stock_picking_views.xml
@@ -378,7 +378,9 @@
<field name="is_quantity_done_editable" invisible="1"/>
<field name="product_uom_qty" string="Demand" attrs="{'column_invisible': [('parent.immediate_transfer', '=', True)], 'readonly': ['|', ('is_initial_demand_editable', '=', False), '&amp;', '&amp;', ('show_operations', '=', True), ('is_locked', '=', True), ('is_initial_demand_editable', '=', False)]}"/>
<field name="reserved_availability" string="Reserved" attrs="{'column_invisible': (['|','|', ('parent.state','=', 'done'), ('parent.picking_type_code', 'in', ['incoming', 'outgoing']), ('parent.immediate_transfer', '=', True)])}"/>
- <field name="json_forecast" string="Reserved" attrs="{'column_invisible': ['|', ('parent.picking_type_code', '!=', 'outgoing'), ('parent.state','=', 'done')]}" widget="forecast_widget"/>
+ <field name="product_qty" invisible="1" readonly="1"/>
+ <field name="forecast_expected_date" invisible="1" />
+ <field name="forecast_availability" string="Reserved" attrs="{'column_invisible': ['|', ('parent.picking_type_code', '!=', 'outgoing'), ('parent.state','=', 'done')]}" widget="forecast_widget"/>
<field name="quantity_done" string="Done" attrs="{'readonly': [('is_quantity_done_editable', '=', False)]}"/>
<field name="product_uom" attrs="{'readonly': [('state', '!=', 'draft'), ('additional', '=', False)]}" options="{'no_open': True, 'no_create': True}" string="Unit of Measure" groups="uom.group_uom"/>
<field name="lot_ids" widget="many2many_tags"
@@ -411,7 +413,9 @@
<field name="is_quantity_done_editable" invisible="1"/>
<field name="product_uom_qty" attrs="{'invisible': [('parent.immediate_transfer', '=', True)], 'readonly': [('is_initial_demand_editable', '=', False)]}"/>
<field name="reserved_availability" string="Reserved" attrs="{'invisible': (['|','|', ('parent.state','=', 'done'), ('parent.picking_type_code', 'in', ['outgoing', 'incoming']), ('parent.immediate_transfer', '=', True)])}"/>
- <field name="json_forecast" string="Reserved" attrs="{'column_invisible': ['|', ('parent.picking_type_code', '!=', 'outgoing'), ('parent.state','=', 'done')]}" widget="forecast_widget"/>
+ <field name="product_qty" invisible="1" readonly="1"/>
+ <field name="forecast_expected_date" invisible="1"/>
+ <field name="forecast_availability" string="Reserved" attrs="{'invisible': ['|', ('parent.picking_type_code', '!=', 'outgoing'), ('parent.state','=', 'done')]}" widget="forecast_widget"/>
<field name="quantity_done" string="Done" attrs="{'readonly': [('is_quantity_done_editable', '=', False)]}"/>
<field name="product_uom" attrs="{'readonly': [('state', '!=', 'draft'), ('additional', '=', False)]}" options="{'no_open': True, 'no_create': True}" string="Unit of Measure" groups="uom.group_uom"/>
<field name="description_picking" string="Description"/>