diff options
| author | Rémy Voet (ryv) <ryv@odoo.com> | 2020-08-24 06:45:20 +0000 |
|---|---|---|
| committer | William Henrotin <whe@odoo.com> | 2020-09-11 09:18:09 +0000 |
| commit | 5e5de2b2b64fd8cb259e72fc1a97dc754da871ad (patch) | |
| tree | 7fffe71214294e334cae3736556d461ae8e49a03 /addons/stock | |
| parent | 1f988ae9d2665beed42a32738728606478a3129d (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.py | 59 | ||||
| -rw-r--r-- | addons/stock/models/stock_picking.py | 23 | ||||
| -rw-r--r-- | addons/stock/report/report_stock_forecasted.py | 6 | ||||
| -rw-r--r-- | addons/stock/static/src/js/forecast_widget.js | 38 | ||||
| -rw-r--r-- | addons/stock/static/src/xml/forecast_widget.xml | 11 | ||||
| -rw-r--r-- | addons/stock/views/stock_picking_views.xml | 8 |
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), '&', '&', ('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"/> |
