[IMP] stock(_*), mrp: highlight PO/SO links on forecast report

Replenishment is great to replace MTO but misses the direct link between SO and PO (smart buttons on both SO and PO to link to the corresponding PO and SO).
The info is actually there in the forecasted report but it is not accessible directly from the PO and in the case of the SO does not highlight the corresponding line of the SO.

This adds a graph icon in the PO, MO and receipt form to directly access the forecasted report (it already exists for SO and delivery).
The information linked to the SO/PO/MO/receipt/delivery is also sent to the forecasted report to highlight the corresponding line.
Note that in the case of the SO, the information needs to go first through the qty_at_date widget (which contains the link to the forecast report).

The graph icon for the PO, receipts and MO turns red when the forecasted quantity at expected date is negative. This is done using the new 'forecasted_issue' field.

closes odoo/odoo#62264

Signed-off-by: Arnold Moyaux <amoyaux@users.noreply.github.com>
This commit is contained in:
Nicolas Pierre
2021-02-09 13:41:27 +00:00
parent 4c91e895a9
commit b03e900a40
19 changed files with 226 additions and 7 deletions
+26
View File
@@ -255,6 +255,7 @@ class MrpProduction(models.Model):
('expected', 'Expected'),
('late', 'Late')], compute='_compute_components_availability')
show_lot_ids = fields.Boolean('Display the serial number shortcut on the moves', compute='_compute_show_lot_ids')
forecasted_issue = fields.Boolean(compute='_compute_forecasted_issue')
@api.depends('product_id', 'bom_id', 'company_id')
def _compute_allowed_product_ids(self):
@@ -516,6 +517,18 @@ class MrpProduction(models.Model):
('qty_positive', 'check (product_qty > 0)', 'The quantity to produce must be positive!'),
]
@api.depends('product_uom_qty', 'date_planned_start')
def _compute_forecasted_issue(self):
for order in self:
warehouse = order.location_dest_id.get_warehouse()
order.forecasted_issue = False
if order.product_id:
virtual_available = order.product_id.with_context(warehouse=warehouse.id, to_date=order.date_planned_start).virtual_available
if order.state == 'draft':
virtual_available += order.product_uom_qty
if virtual_available < 0:
order.forecasted_issue = True
@api.model
def _search_delay_alert_date(self, operator, value):
late_stock_moves = self.env['stock.move'].search([('delay_alert_date', operator, value)])
@@ -777,6 +790,19 @@ class MrpProduction(models.Model):
self.is_locked = not self.is_locked
return True
def action_product_forecast_report(self):
self.ensure_one()
action = self.product_id.action_product_forecast_report()
action['context'] = {
'active_id': self.product_id.id,
'active_model': 'product.product',
'move_to_match_ids': self.move_finished_ids.filtered(lambda m: m.product_id == self.product_id).ids
}
warehouse = self.picking_type_id.warehouse_id
if warehouse:
action['context']['warehouse'] = warehouse.id
return action
def _create_workorder(self):
for production in self:
if not production.bom_id:
+27
View File
@@ -151,3 +151,30 @@ class TestSaleStockReports(TestReportsCommon):
# Checks the forecast report.
report_values, docs, lines = self.get_report_forecast(product_template_ids=product_apple_pie.product_tmpl_id.ids)
self.assertEqual(len(lines), 0, "Must have no line")
def test_report_forecast_3_report_line_corresponding_to_mo_highlighted(self):
""" When accessing the report from a MO, checks if the correct MO is highlighted in the report
"""
product_banana = self.env['product.product'].create({
'name': 'Banana',
'type': 'product',
})
# We create 2 identical MO
mo_form = Form(self.env['mrp.production'])
mo_form.product_id = product_banana
mo_form.product_qty = 10
mo_1 = mo_form.save()
mo_1.button_plan()
mo_2 = mo_1.copy()
mo_2.button_plan()
# Check for both MO if the highlight (is_matched) corresponds to the correct MO
for mo in [mo_1, mo_2]:
context = mo.action_product_forecast_report()['context']
_, _, lines = self.get_report_forecast(product_template_ids=product_banana.product_tmpl_id.ids, context=context)
for line in lines:
if line['document_in'] == mo:
self.assertTrue(line['is_matched'], "The corresponding MO line should be matched in the forecast report.")
else:
self.assertFalse(line['is_matched'], "A line of the forecast report not linked to the MO shoud not be matched.")
@@ -143,6 +143,7 @@
<field name="product_tracking" invisible="1"/>
<field name="product_id" context="{'default_type': 'product'}" attrs="{'readonly': [('state', '!=', 'draft')]}"/>
<field name="product_tmpl_id" invisible="1"/>
<field name="forecasted_issue" invisible="1"/>
<field name="product_description_variants" attrs="{'invisible': [('product_description_variants', 'in', (False, ''))], 'readonly': [('state', '!=', 'draft')]}"/>
<label for="product_qty" string="Quantity"/>
<div class="o_row no-gutters d-flex">
@@ -159,6 +160,8 @@
<field name="product_uom_category_id" invisible="1"/>
<field name="product_uom_id" options="{'no_open': True, 'no_create': True}" force_save="1" groups="uom.group_uom" attrs="{'readonly': [('state', '!=', 'draft')]}"/>
<span class='text-bf'>To Produce</span>
<button type="object" name="action_product_forecast_report" icon="fa-area-chart" attrs="{'invisible': [('forecasted_issue', '=', True)]}"/>
<button type="object" name="action_product_forecast_report" icon="fa-area-chart" attrs="{'invisible': [('forecasted_issue', '=', False)]}" class="text-danger"/>
</div>
<label for="lot_producing_id" attrs="{'invisible': ['|', ('state', '=', 'draft'), ('product_tracking', 'in', ('none', False))]}"/>
<div class="o_row" attrs="{'invisible': ['|', ('state', '=', 'draft'), ('product_tracking', 'in', ('none', False))]}">
+27
View File
@@ -276,6 +276,7 @@ class PurchaseOrderLine(models.Model):
move_dest_ids = fields.One2many('stock.move', 'created_purchase_line_id', 'Downstream Moves')
product_description_variants = fields.Char('Custom Description')
propagate_cancel = fields.Boolean('Propagate cancellation', default=True)
forecasted_issue = fields.Boolean(compute='_compute_forecasted_issue')
def _compute_qty_received_method(self):
super(PurchaseOrderLine, self)._compute_qty_received_method()
@@ -316,6 +317,18 @@ class PurchaseOrderLine(models.Model):
line._track_qty_received(total)
line.qty_received = total
@api.depends('product_uom_qty', 'date_planned')
def _compute_forecasted_issue(self):
for line in self:
warehouse = line.order_id.picking_type_id.warehouse_id
line.forecasted_issue = False
if line.product_id:
virtual_available = line.product_id.with_context(warehouse=warehouse.id, to_date=line.date_planned).virtual_available
if line.state == 'draft':
virtual_available += line.product_uom_qty
if virtual_available < 0:
line.forecasted_issue = True
@api.model_create_multi
def create(self, vals_list):
lines = super(PurchaseOrderLine, self).create(vals_list)
@@ -333,6 +346,20 @@ class PurchaseOrderLine(models.Model):
self.filtered(lambda l: l.order_id.state == 'purchase')._create_or_update_picking()
return result
def action_product_forecast_report(self):
self.ensure_one()
action = self.product_id.action_product_forecast_report()
action['context'] = {
'active_id': self.product_id.id,
'active_model': 'product.product',
'move_to_match_ids': self.move_ids.filtered(lambda m: m.product_id == self.product_id).ids,
'purchase_line_to_match_id': self.id,
}
warehouse = self.order_id.picking_type_id.warehouse_id
if warehouse:
action['context']['warehouse'] = warehouse.id
return action
# --------------------------------------------------
# Business methods
# --------------------------------------------------
@@ -22,6 +22,7 @@ class ReplenishmentReport(models.AbstractModel):
in_sum = sum(quantities)
res['draft_purchase_qty'] = in_sum
res['draft_purchase_orders'] = po_lines.mapped("order_id").sorted(key=lambda po: po.name)
res['draft_purchase_orders_matched'] = self.env.context.get('purchase_line_to_match_id') in po_lines.ids
res['qty']['in'] += in_sum
return res
@@ -2,7 +2,7 @@
<odoo>
<template id="purchase_report_product_product_replenishment" inherit_id="stock.report_product_product_replenishment">
<xpath expr="//tr[@name='draft_picking_in']" position="after">
<tr t-if="docs['draft_purchase_qty']" name="draft_po_in">
<tr t-if="docs['draft_purchase_qty']" name="draft_po_in" t-attf-class="#{docs['draft_purchase_orders_matched'] and 'o_grid_match'}">
<td colspan="2">Requests for quotation</td>
<td t-esc="docs['draft_purchase_qty']" t-options="{'widget': 'float', 'precision': precision}" class="text-right"/>
<td>
@@ -145,3 +145,27 @@ class TestPurchaseStockReports(TestReportsCommon):
self.assertEqual(draft_picking_qty_in, 0)
self.assertEqual(draft_purchase_qty, 0)
self.assertEqual(pending_qty_in, 0)
def test_report_forecast_3_report_line_corresponding_to_po_line_highlighted(self):
""" When accessing the report from a PO line, checks if the correct PO line is highlighted in the report
"""
# We create 2 identical PO
po_form = Form(self.env['purchase.order'])
po_form.partner_id = self.partner
with po_form.order_line.new() as line:
line.product_id = self.product
line.product_qty = 5
po1 = po_form.save()
po1.button_confirm()
po2 = po1.copy()
po2.button_confirm()
# Check for both PO if the highlight (is_matched) corresponds to the correct PO
for po in [po1, po2]:
context = po.order_line[0].action_product_forecast_report()['context']
_, _, lines = self.get_report_forecast(product_template_ids=self.product_template.ids, context=context)
for line in lines:
if line['document_in'] == po:
self.assertTrue(line['is_matched'], "The corresponding PO line should be matched in the forecast report.")
else:
self.assertFalse(line['is_matched'], "A line of the forecast report not linked to the PO shoud not be matched.")
@@ -27,6 +27,11 @@
<xpath expr="//field[@name='order_line']/tree//field[@name='date_planned']" position="after">
<field name="move_dest_ids" invisible="1"/>
</xpath>
<xpath expr="//page/field[@name='order_line']/tree/field[@name='product_qty']" position="after">
<field name="forecasted_issue" invisible="1"/>
<button type="object" name="action_product_forecast_report" icon="fa-area-chart" attrs="{'invisible': ['|', ('forecasted_issue', '=', False), ('product_type', '!=', 'product')]}" class="text-danger"/>
<button type="object" name="action_product_forecast_report" icon="fa-area-chart" attrs="{'invisible': ['|', ('forecasted_issue', '=', True), ('product_type', '!=', 'product')]}"/>
</xpath>
<xpath expr="//div[@name='date_planned_div']" position="inside">
<button name="%(action_purchase_vendor_delay_report)d" class="oe_link" type="action" context="{'search_default_partner_id': partner_id}" attrs="{'invisible': ['|', ('state', 'in', ['purchase', 'done']), ('partner_id', '=', False)]}">
<span attrs="{'invisible': [('on_time_rate', '&lt;', 0)]}"><field name="on_time_rate" widget="integer" class="oe_inline"/>% On-Time Delivery</span>
@@ -18,6 +18,7 @@ class ReplenishmentReport(models.AbstractModel):
out_sum = sum(quantities)
res['draft_sale_qty'] = out_sum
res['draft_sale_orders'] = so_lines.mapped("order_id").sorted(key=lambda so: so.name)
res['draft_sale_orders_matched'] = self.env.context.get('sale_line_to_match_id') in so_lines.ids
res['qty']['out'] += out_sum
return res
@@ -2,7 +2,7 @@
<odoo>
<template id="sale_report_product_product_replenishment" inherit_id="stock.report_product_product_replenishment">
<xpath expr="//tr[@name='draft_picking_out']" position="after">
<tr t-if="docs['draft_sale_qty']" name="draft_so_out">
<tr t-if="docs['draft_sale_qty']" name="draft_so_out" t-attf-class="#{docs['draft_sale_orders_matched'] and 'o_grid_match'}">
<td colspan="2">Quotations</td>
<td t-esc="-docs['draft_sale_qty']" t-options="{'widget': 'float', 'precision': precision}" class="text-right"/>
<td>
@@ -80,7 +80,9 @@ var QtyAtDateWidget = Widget.extend({
action.context = {
active_model: 'product.product',
active_id: this.data.product_id.data.id,
warehouse: this.data.warehouse_id && this.data.warehouse_id.res_id
warehouse: this.data.warehouse_id && this.data.warehouse_id.res_id,
move_to_match_ids: this.data.move_ids.res_ids,
sale_line_to_match_id: this.data.id,
};
return this.do_action(action);
},
@@ -58,3 +58,27 @@ class TestSaleStockReports(TestReportsCommon):
self.assertEqual(line_2['quantity'], 5)
self.assertEqual(line_2['replenishment_filled'], False)
self.assertEqual(line_2['document_out'].id, so_1.id)
def test_report_forecast_2_report_line_corresponding_to_so_line_highlighted(self):
""" When accessing the report from a SO line, checks if the correct SO line is highlighted in the report
"""
# We create 2 identical SO
so_form = Form(self.env['sale.order'])
so_form.partner_id = self.partner
with so_form.order_line.new() as line:
line.product_id = self.product
line.product_uom_qty = 5
so1 = so_form.save()
so1.action_confirm()
so2 = so1.copy()
so2.action_confirm()
# Check for both SO if the highlight (is_matched) corresponds to the correct SO
for so in [so1, so2]:
context = {"move_to_match_ids": so.order_line.move_ids.ids}
_, _, lines = self.get_report_forecast(product_template_ids=self.product_template.ids, context=context)
for line in lines:
if line['document_out'] == so:
self.assertTrue(line['is_matched'], "The corresponding SO line should be matched in the forecast report.")
else:
self.assertFalse(line['is_matched'], "A line of the forecast report not linked to the SO shoud not be matched.")
@@ -117,6 +117,7 @@
<field name="scheduled_date" invisible="1"/>
<field name="forecast_expected_date" invisible="1"/>
<field name="warehouse_id" invisible="1"/>
<field name="move_ids" invisible="1"/>
<field name="qty_to_deliver" invisible="1"/>
<field name="is_mto" invisible="1"/>
<field name="display_qty_widget" invisible="1"/>
+21 -2
View File
@@ -417,6 +417,8 @@ class StockMove(models.Model):
outgoing_unreserved_moves_per_warehouse[warehouse_by_location[move.location_id]] |= move
elif move.picking_type_id.code in self._consuming_picking_types():
move.forecast_availability = move.reserved_availability
elif move.picking_type_id.code == 'incoming':
move._get_forecast_availability_incoming()
for warehouse, moves in outgoing_unreserved_moves_per_warehouse.items():
if not warehouse: # No prediction possible if no warehouse.
@@ -660,8 +662,18 @@ class StockMove(models.Model):
def action_product_forecast_report(self):
self.ensure_one()
action = self.product_id.action_product_forecast_report()
warehouse = self.location_id.get_warehouse()
action['context'] = {'warehouse': warehouse.id, } if warehouse else {}
action['context'] = {
'active_id': self.product_id.id,
'active_model': 'product.product',
'move_to_match_ids': self.ids,
}
if self.picking_type_id.code == 'outgoing':
warehouse = self.location_id.get_warehouse()
else:
warehouse = self.location_dest_id.get_warehouse()
if warehouse:
action['context']['warehouse'] = warehouse.id
return action
def _do_unreserve(self):
@@ -864,6 +876,13 @@ class StockMove(models.Model):
else:
return moves_todo[-1:].state or 'draft'
def _get_forecast_availability_incoming(self):
self.ensure_one()
warehouse = self.location_dest_id.get_warehouse()
self.forecast_availability = self.product_id.with_context(warehouse=warehouse.id, to_date=self.date).virtual_available
if self.state == 'draft':
self.forecast_availability += self.product_uom_qty
@api.onchange('product_id', 'picking_type_id')
def _onchange_product_id(self):
product = self.product_id.with_context(lang=self._get_lang())
@@ -110,6 +110,11 @@ class ReplenishmentReport(models.AbstractModel):
timezone = self._context.get('tz')
product = product or (move_out.product_id if move_out else move_in.product_id)
is_late = move_out.date < move_in.date if (move_out and move_in) else False
move_to_match_ids = self.env.context.get('move_to_match_ids') or []
move_in_id = move_in.id if move_in else None
move_out_id = move_out.id if move_out else None
return {
'document_in': move_in._get_source_document() if move_in else False,
'document_out': move_out._get_source_document() if move_out else False,
@@ -126,6 +131,7 @@ class ReplenishmentReport(models.AbstractModel):
'move_out': move_out,
'move_in': move_in,
'reservation': reservation,
'is_matched': any(move_id in [move_in_id, move_out_id] for move_id in move_to_match_ids),
}
def _get_report_lines(self, product_template_ids, product_variant_ids, wh_location_ids):
@@ -110,7 +110,7 @@
<td/>
<td/>
</tr>
<tr t-foreach="docs['lines']" t-as="line">
<tr t-foreach="docs['lines']" t-as="line" t-attf-class="#{line['is_matched'] and 'o_grid_match'}">
<td t-attf-class="#{line['is_late'] and 'o_grid_warning'}">
<a t-if="line['document_in']"
t-attf-href="#" t-esc="line['document_in'].name"
@@ -3,6 +3,9 @@
.o_grid_warning {
background-color: #f4cccc;
}
.o_grid_match {
background-color: #f0f0f0;
}
}
.o_forecasted_row {
+50
View File
@@ -1041,3 +1041,53 @@ class TestReports(TestReportsCommon):
delivery2_line = [l for l in lines if l['document_out'].id == delivery2.id][0]
self.assertTrue(delivery2_line, 'No line for delivery 2')
self.assertTrue(delivery2_line['replenishment_filled'])
def test_report_forecast_10_report_line_corresponding_to_picking_highlighted(self):
""" When accessing the report from a stock move, checks if the correct picking is highlighted in the report
and if the forecasted availability for incoming moves is correct
"""
# Creation of one delivery with date 'today'
delivery_form = Form(self.env['stock.picking'])
delivery_form.partner_id = self.partner
delivery_form.picking_type_id = self.picking_type_out
delivery_form.scheduled_date = date.today()
with delivery_form.move_ids_without_package.new() as move:
move.product_id = self.product
move.product_uom_qty = 200
delivery1 = delivery_form.save()
delivery1.action_confirm()
# Creation of one receipt with date 'today + 1' and smaller qty than the delivery
receipt_form = Form(self.env['stock.picking'])
receipt_form.partner_id = self.partner
receipt_form.picking_type_id = self.picking_type_in
receipt_form.scheduled_date = date.today() + timedelta(days=1)
with receipt_form.move_ids_without_package.new() as move:
move.product_id = self.product
move.product_uom_qty = 150
receipt1 = receipt_form.save()
receipt1.action_confirm()
self.assertEqual(receipt1.move_lines.forecast_availability, -50.0)
# Creation of an identical receipt which should lead to a positive forecast availability
receipt2 = receipt1.copy()
receipt2.action_confirm()
for move in receipt2.move_lines:
move.quantity_done = 150
receipt2.button_validate()
self.assertEqual(receipt1.move_lines.forecast_availability, 100.0)
delivery2 = delivery1.copy()
delivery2.action_confirm()
receipt1.move_lines._compute_forecast_information()
self.assertEqual(receipt1.move_lines.forecast_availability, -100.0)
# Check for both deliveries and receipts if the highlight (is_matched) corresponds to the correct picking
for picking in [delivery1, delivery2, receipt1, receipt2]:
context = picking.move_lines[0].action_product_forecast_report()['context']
_, _, lines = self.get_report_forecast(product_template_ids=self.product_template.ids, context=context)
for line in lines:
if picking in [line['document_in'], line['document_out']]:
self.assertTrue(line['is_matched'], "The corresponding picking should be matched in the forecast report.")
else:
self.assertFalse(line['is_matched'], "A line of the forecast report not linked to the picking shoud not be matched.")
+1 -1
View File
@@ -368,7 +368,7 @@
<field name="date_deadline" optional="hide"/>
<field name="is_initial_demand_editable" invisible="1"/>
<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="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)]}" widget="forecast_widget"/>
<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="product_qty" invisible="1" readonly="1"/>
<field name="forecast_expected_date" invisible="1" />