diff options
| -rw-r--r-- | addons/mrp/models/mrp_production.py | 22 | ||||
| -rw-r--r-- | addons/mrp/views/mrp_production_views.xml | 6 | ||||
| -rw-r--r-- | addons/sale_stock/models/sale_order.py | 72 | ||||
| -rw-r--r-- | addons/sale_stock/static/src/js/qty_at_date_widget.js | 65 | ||||
| -rw-r--r-- | addons/sale_stock/static/src/xml/sale_stock.xml | 43 | ||||
| -rw-r--r-- | addons/sale_stock/views/sale_order_views.xml | 9 | ||||
| -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 |
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': ['&', ('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 < 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 < 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 < 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), '&', '&', ('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"/> |
