summaryrefslogtreecommitdiff
path: root/addons/stock
diff options
context:
space:
mode:
authorRémy Voet (ryv) <ryv@odoo.com>2020-08-24 06:45:20 +0000
committerWilliam Henrotin <whe@odoo.com>2020-09-11 09:18:09 +0000
commit5e5de2b2b64fd8cb259e72fc1a97dc754da871ad (patch)
tree7fffe71214294e334cae3736556d461ae8e49a03 /addons/stock
parent1f988ae9d2665beed42a32738728606478a3129d (diff)
[IMP] (sale_)stock: review forecasted information
Remove the json_forecast of the sale order line, it was a duplicate of information with the qty_at_date_widget. But improve this one: - Use the forecasted move information in case of confirmed SO. - Change the icon (i) to chart. - Link to the forcasted change to target new forcasted report (Unfortunately we loose the feature to see components product forcasted of a kit) - Change information print in the popover in case of confirmed SO. Also refactor the json_forcasted of `stock.move` into multiple fields to avoid to dump/loads for other model who needed the forcasted data. task-2324039 closes odoo/odoo#56425 Related: odoo/enterprise#13063 Signed-off-by: Arnold Moyaux <amoyaux@users.noreply.github.com>
Diffstat (limited to 'addons/stock')
-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
6 files changed, 75 insertions, 70 deletions
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"/>