[IMP] {purchase_,}mrp: Add MO Overview

Adds in a new report that allows the user to monitor the entire
production of a product, including the resupply of the components (i.e.
subassemblies, purchases, ...) in a single view.

Task-3059467

Part-of: odoo/odoo#113394
This commit is contained in:
clesgow
2023-03-03 18:10:57 +01:00
committed by William Henrotin
parent c823f477aa
commit 1ef03b92a0
19 changed files with 1463 additions and 0 deletions
+1
View File
@@ -41,6 +41,7 @@
'report/report_deliveryslip.xml',
'report/mrp_report_views_main.xml',
'report/mrp_report_bom_structure.xml',
'report/mrp_report_mo_overview.xml',
'report/mrp_production_templates.xml',
'report/report_stock_reception.xml',
'report/report_stock_rule.xml',
+1
View File
@@ -5,3 +5,4 @@ from . import mrp_report_bom_structure
from . import stock_forecasted
from . import report_stock_reception
from . import report_stock_rule
from . import mrp_report_mo_overview
+468
View File
@@ -0,0 +1,468 @@
# -*- coding: utf-8 -*-
import copy
import json
from collections import defaultdict
from odoo import _, api, fields, models
from odoo.tools import float_compare, format_date, float_is_zero
from datetime import timedelta
from math import log10
class ReportMoOverview(models.AbstractModel):
_name = 'report.mrp.report_mo_overview'
_description = 'MO Overview Report'
@api.model
def get_report_values(self, production_id):
""" Endpoint for HTML display. """
return {
'data': self._get_report_data(production_id),
'context': self._get_display_context(),
}
@api.model
def _get_report_values(self, docids, data=None):
""" Endpoint for PDF display. """
if not data:
data = {}
docs = []
for prod_id in docids:
doc = self._get_report_data(prod_id)
doc['show_replenishments'] = data.get('replenishments') == '1'
doc['show_availabilities'] = data.get('availabilities') == '1'
doc['show_receipts'] = data.get('receipts') == '1'
doc['show_mo_costs'] = data.get('moCosts') == '1'
doc['show_product_costs'] = data.get('productCosts') == '1'
doc['show_uom'] = self.env.user.user_has_groups('uom.group_uom')
doc['data_mo_unit_cost'] = doc['summary'].get('mo_cost', 0) / doc['summary'].get('quantity', 1)
doc['data_product_unit_cost'] = doc['summary'].get('product_cost', 0) / doc['summary'].get('quantity', 1)
doc['unfolded_ids'] = set(json.loads(data.get('unfoldedIds', '[]')))
docs.append(doc)
return {
'doc_ids': docids,
'doc_model': 'mrp.production',
'docs': docs,
}
def _get_display_context(self):
return {
'show_uom': self.env.user.user_has_groups('uom.group_uom'),
}
def _get_report_data(self, production_id):
production = self.env['mrp.production'].browse(production_id)
components = self._get_components_data(production, level=1, current_index='')
operations = self._get_operations_data(production, level=1, current_index='')
summary = self._get_mo_summary(production, components, operations)
return {
'id': production.id,
'summary': summary,
'components': components,
'operations': operations,
'extras': self._get_report_extra_lines(summary),
}
def _get_report_extra_lines(self, summary):
currency = summary.get('currency', self.env.company)
unit_mo_cost = currency.round(summary.get('mo_cost', 0) / summary.get('quantity', 1))
unit_product_cost = currency.round(summary.get('product_cost', 0) / summary.get('quantity', 1))
return {
'unit_mo_cost': unit_mo_cost,
'unit_mo_cost_decorator': self._get_comparison_decorator(unit_product_cost, unit_mo_cost, currency.rounding),
'unit_product_cost': unit_product_cost,
}
def _get_mo_summary(self, production, components, operations):
product = production.product_id
company = production.company_id or self.env.company
mo_cost = sum(component.get('summary', {}).get('mo_cost', 0.0) for component in components)
return {
'level': 0,
'model': production._name,
'id': production.id,
'name': production.product_id.display_name,
'product_model': production.product_id._name,
'product_id': production.product_id.id,
'state': production.state,
'formatted_state': self._format_state(production),
'quantity': production.product_uom_qty,
'uom_name': production.product_uom_id.display_name,
'uom_precision': self._get_uom_precision(production.product_uom_id.rounding),
'quantity_free': product.uom_id._compute_quantity(product.free_qty, production.product_uom_id) if product.type == 'product' else False,
'quantity_on_hand': product.uom_id._compute_quantity(product.qty_available, production.product_uom_id) if product.type == 'product' else False,
'quantity_reserved': 0.0,
'receipt': self._check_planned_start(production.date_planned_start, self._get_replenishment_receipt(production, components)),
'mo_cost': company.currency_id.round(mo_cost + operations.get('summary', {}).get('mo_cost', 0.0)),
'product_cost': company.currency_id.round(product.standard_price * production.product_uom_qty),
'currency_id': company.currency_id.id,
'currency': company.currency_id,
}
def _format_state(self, record):
return dict(record._fields['state']._description_selection(self.env)).get(record.state)
def _get_uom_precision(self, uom_rounding):
return max(0, int(-(log10(uom_rounding))))
def _get_comparison_decorator(self, expected, current, rounding):
compare = float_compare(current, expected, precision_rounding=rounding)
if float_is_zero(current, precision_rounding=rounding) or compare == 0:
return False
elif compare > 0:
return 'danger'
else:
return 'success'
def _get_operations_data(self, production, level=0, current_index=False):
currency = (production.company_id or self.env.company).currency_id
operation_uom = _("Minutes")
operations = []
total_expected_time = 0.0
total_current_time = 0.0
total_expected_cost = 0.0
total_current_cost = 0.0
for index, workorder in enumerate(production.workorder_ids):
wo_duration = workorder.duration + workorder.get_working_duration() # Add duration of ongoing work sessions.
expected_cost = (workorder.duration_expected / 60.0) * workorder.workcenter_id.costs_hour
current_cost = (wo_duration / 60.0) * workorder.workcenter_id.costs_hour
operations.append({
'level': level,
'index': f"{current_index}W{index}",
'model': workorder._name,
'id': workorder.id,
'name': workorder.name,
'state': workorder.state,
'formatted_state': self._format_state(workorder),
'quantity': workorder.duration_expected if float_is_zero(wo_duration, precision_digits=2) else wo_duration,
'quantity_decorator': self._get_comparison_decorator(workorder.duration_expected, wo_duration, 0.01),
'uom_name': operation_uom,
'production_id': production.id,
'mo_cost': expected_cost if float_is_zero(current_cost, precision_rounding=currency.rounding) else current_cost,
'mo_cost_decorator': self._get_comparison_decorator(expected_cost, current_cost, currency.rounding),
'currency_id': currency.id,
'currency': currency,
})
total_expected_time += workorder.duration_expected
total_current_time += workorder.duration_expected if float_is_zero(wo_duration, precision_digits=2) else wo_duration
total_expected_cost += expected_cost
total_current_cost += expected_cost if float_is_zero(current_cost, precision_rounding=currency.rounding) else current_cost
return {
'summary': {
'index': f"{current_index}W",
'quantity': total_current_time,
'quantity_decorator': self._get_comparison_decorator(total_expected_time, total_current_time, 0.01),
'mo_cost': total_current_cost,
'mo_cost_decorator': self._get_comparison_decorator(total_expected_cost, total_current_cost, currency.rounding),
'uom_name': operation_uom,
'currency_id': currency.id,
'currency': currency,
},
'details': operations,
}
def _get_components_data(self, production, replenish_data=False, level=0, current_index=False):
if not replenish_data:
replenish_data = {
'products': {},
'warehouses': {},
'qty_already_reserved': defaultdict(float),
'qty_reserved': {},
}
components = []
company = production.company_id or self.env.company
replenish_data = self._get_replenishments_from_forecast(production, replenish_data)
for count, move_raw in enumerate(production.move_raw_ids):
component_index = f"{current_index}{count}"
replenishments = self._get_replenishment_lines(production, move_raw, replenish_data, level, component_index)
# If not enough replenishment -> To Order / Might get "non-available" in summary since all component won't be there in time
components.append({
'summary': self._format_component_move(production, move_raw, replenishments, company, replenish_data, level, component_index),
'replenishments': replenishments
})
return components
def _format_component_move(self, production, move_raw, replenishments, company, replenish_data, level, index):
product = move_raw.product_id
mo_cost = sum(rep.get('summary', {}).get('mo_cost', 0.0) for rep in replenishments)
product_cost = product.standard_price * move_raw.product_qty
component = {
'level': level,
'index': index,
'id': product.id,
'model': product._name,
'name': product.display_name,
'product_model': product._name,
'product_id': product.id,
'quantity': move_raw.product_uom_qty,
'uom_name': move_raw.product_uom.display_name,
'uom_precision': self._get_uom_precision(move_raw.product_uom.rounding),
'quantity_free': product.uom_id._compute_quantity(product.free_qty, move_raw.product_uom) if product.type == 'product' else False,
'quantity_on_hand': product.uom_id._compute_quantity(product.qty_available, move_raw.product_uom) if product.type == 'product' else False,
'quantity_reserved': self._get_reserved_qty(move_raw, production.warehouse_id, replenish_data),
'receipt': self._check_planned_start(production.date_planned_start, self._get_component_receipt(product, move_raw, production.warehouse_id, replenishments, replenish_data)),
'mo_cost': company.currency_id.round(mo_cost if replenishments else product_cost),
'product_cost': company.currency_id.round(product_cost),
'currency_id': company.currency_id.id,
'currency': company.currency_id,
}
if product.type != 'product':
return component
if any(rep.get('summary', {}).get('model') == 'to_order' for rep in replenishments):
# Means that there's an extra "To Order" line summing up what's left to order.
component['formatted_state'] = _("To Order")
component['state'] = 'to_order'
return component
def _check_planned_start(self, mo_planned_start, receipt):
if receipt.get('date', False) and receipt['date'] > mo_planned_start:
receipt['decorator'] = 'danger'
return receipt
def _get_component_receipt(self, product, move, warehouse, replenishments, replenish_data):
def get(replenishment, key, check_in_receipt=False):
fetch = replenishment.get('summary', {})
if check_in_receipt:
fetch = fetch.get('receipt', {})
return fetch.get(key, False)
if any(get(rep, 'type', True) == 'unavailable' for rep in replenishments):
return self._format_receipt_date('unavailable')
if product.type != 'product':
return self._format_receipt_date('available')
has_to_order_line = any(rep.get('summary', {}).get('model') == 'to_order' for rep in replenishments)
reserved_quantity = self._get_reserved_qty(move, warehouse, replenish_data)
missing_quantity = move.product_uom_qty - reserved_quantity
free_qty = product.uom_id._compute_quantity(product.free_qty, move.product_uom)
if not has_to_order_line and float_compare(missing_quantity, free_qty, precision_rounding=move.product_uom.rounding) <= 0:
return self._format_receipt_date('available')
max_date = max(map(lambda rep: get(rep, 'date', True), replenishments))
if has_to_order_line or any(get(rep, 'type', True) == 'estimated' for rep in replenishments):
return self._format_receipt_date('estimated', max_date)
else:
return self._format_receipt_date('expected', max_date)
def _get_replenishment_lines(self, production, move_raw, replenish_data, level, current_index):
product = move_raw.product_id
currency = (production.company_id or self.env.company).currency_id
forecast = replenish_data['products'][product.id].get('forecast', [])
current_lines = filter(lambda line: line.get('document_in', False) and line.get('document_out', False) and line['document_out'].get('id', False) == production.id, forecast)
total_ordered = 0
replenishments = []
for count, forecast_line in enumerate(current_lines):
doc_in = self.env[forecast_line['document_in']['_name']].browse(forecast_line['document_in']['id'])
replenishment_index = f"{current_index}{count}"
replenishment = {}
uom_id = self.env['uom.uom'].browse(forecast_line['uom_id']['id'])
replenishment['summary'] = {
'level': level + 1,
'index': replenishment_index,
'id': doc_in.id,
'model': doc_in._name,
'name': doc_in.display_name,
'product_model': product._name,
'product_id': product.id,
'state': doc_in.state,
'formatted_state': self._format_state(doc_in),
'quantity': forecast_line['quantity'],
'uom_name': forecast_line['uom_id']['display_name'],
'uom_precision': self._get_uom_precision(forecast_line['uom_id']['rounding']),
'mo_cost': self._get_replenishment_cost(product, forecast_line['quantity'], uom_id, currency, forecast_line['move_in']),
'product_cost': currency.round(uom_id._compute_quantity(forecast_line['quantity'], product.uom_id) * product.standard_price),
'currency_id': currency.id,
'currency': currency,
}
if doc_in._name == 'mrp.production':
replenishment['components'] = self._get_components_data(doc_in, replenish_data, level + 2, replenishment_index)
replenishment['operations'] = self._get_operations_data(doc_in, level + 2, replenishment_index)
replenishment['summary']['mo_cost'] = currency.round(sum(component.get('summary', {}).get('mo_cost', 0.0) for component in replenishment['components']) + replenishment['operations'].get('summary', {}).get('mo_cost', 0.0))
replenishment['summary']['receipt'] = self._check_planned_start(production.date_planned_start, self._get_replenishment_receipt(doc_in, replenishment.get('components', [])))
replenishments.append(replenishment)
total_ordered += replenishment['summary']['quantity']
reserved_quantity = self._get_reserved_qty(move_raw, production.warehouse_id, replenish_data)
missing_quantity = move_raw.product_uom_qty - (reserved_quantity + total_ordered)
# Avoid creating a "to_order" line to compensate for missing stock (i.e. negative free_qty).
free_qty = max(0, product.uom_id._compute_quantity(product.free_qty, move_raw.product_uom))
if product.type == 'product' and float_compare(missing_quantity, free_qty, precision_rounding=move_raw.product_uom.rounding) > 0:
# Need to order more products to fulfill the need
resupply_rules = replenish_data['products'][product.id].get('resupply_rules', [])
rules_delay = sum(rule.delay for rule in resupply_rules)
resupply_data = self._get_resupply_data(resupply_rules, rules_delay, missing_quantity, move_raw.product_uom, product, production.warehouse_id)
if resupply_data:
receipt = self._check_planned_start(production.date_planned_start, self._format_receipt_date('estimated', fields.datetime.today() + timedelta(days=resupply_data['delay'])))
else:
receipt = self._format_receipt_date('unavailable')
to_order_line = {'summary': {
'level': level + 1,
'index': f"{current_index}{len(replenishments) + 1}",
'name': _("To Order"),
'model': "to_order",
'product_model': product._name,
'product_id': product.id,
'quantity': missing_quantity,
'uom_name': move_raw.product_uom.display_name,
'uom_precision': self._get_uom_precision(move_raw.product_uom.rounding),
'receipt': receipt,
'mo_cost': currency.round(resupply_data['cost'] if resupply_data else product.standard_price * move_raw.product_uom._compute_quantity(missing_quantity, product.uom_id)),
'product_cost': currency.round(product.standard_price * move_raw.product_uom._compute_quantity(missing_quantity, product.uom_id)),
'currency_id': currency.id,
'currency': currency,
}}
if resupply_data:
to_order_line['mo_cost'] = currency.round(resupply_data['cost'] * move_raw.product_uom._compute_quantity(missing_quantity, product.uom_id))
to_order_line['receipt'] = self._check_planned_start(production.date_planned_start, self._format_receipt_date('estimated', fields.datetime.today() + timedelta(days=resupply_data['delay']))),
else:
to_order_line['mo_cost'] = currency.round(product.standard_price * move_raw.product_uom._compute_quantity(missing_quantity, product.uom_id))
to_order_line['receipt'] = self._format_receipt_date('unavailable')
replenishments.append(to_order_line)
return replenishments
def _get_replenishment_cost(self, product, quantity, uom_id, currency, move_in=False):
return currency.round(product.standard_price * uom_id._compute_quantity(quantity, product.uom_id))
def _get_replenishment_receipt(self, doc_in, components):
if doc_in._name == 'mrp.production':
max_date_start = doc_in.date_planned_start
all_available = True
some_unavailable = False
some_estimated = False
for component in components:
if component['summary']['receipt']['date']:
max_date_start = max(max_date_start, component['summary']['receipt']['date'])
all_available = all_available and component['summary']['receipt']['type'] == 'available'
some_unavailable = some_unavailable or component['summary']['receipt']['type'] == 'unavailable'
some_estimated = some_estimated or component['summary']['receipt']['type'] == 'estimated'
if some_unavailable:
return self._format_receipt_date('unavailable')
if all_available:
return self._format_receipt_date('expected', doc_in.date_planned_finished)
new_date = max_date_start + timedelta(days=doc_in.product_id.produce_delay)
receipt_state = 'estimated' if some_estimated else 'expected'
return self._format_receipt_date(receipt_state, new_date)
return self._format_receipt_date('unavailable')
def _format_receipt_date(self, state, date=False):
if state == 'available':
return {'display': _("Available"), 'type': 'available', 'decorator': 'success', 'date': False}
elif state == 'estimated':
return {'display': _("Estimated %s", format_date(self.env, date)), 'type': 'estimated', 'decorator': False, 'date': date}
elif state == 'expected':
return {'display': _("Expected %s", format_date(self.env, date)), 'type': 'expected', 'decorator': 'warning', 'date': date}
else:
return {'display': _("Not Available"), 'type': 'unavailable', 'decorator': 'danger', 'date': False}
def _get_replenishments_from_forecast(self, production, replenish_data):
products = production.move_raw_ids.product_id
unknown_products = products.filtered(lambda product: product.id not in replenish_data)
if unknown_products:
warehouse = production.warehouse_id
wh_location_ids = self._get_warehouse_locations(warehouse, replenish_data)
forecast_lines = self.env['stock.forecasted_product_product']._get_report_lines(False, unknown_products.ids, wh_location_ids, warehouse.lot_stock_id, read=False)
for product in unknown_products:
extra_docs = self._get_extra_replenishments(product)
# Sorting the extra documents so that the ones flagged with an explicit production_id are on top of the list.
extra_docs.sort(key=lambda ex: ex.get('production_id', False), reverse=True)
product_forecast_lines = list(filter(lambda line: line.get('product', {}).get('id') == product.id, forecast_lines))
replenish_data['products'][product.id] = {
'forecast': self._add_extra_in_forecast(product_forecast_lines, extra_docs, product.uom_id.rounding),
'resupply_rules': product._get_rules_from_location(production.warehouse_id.lot_stock_id),
}
return replenish_data
def _add_extra_in_forecast(self, forecast_lines, extras, product_rounding):
if not extras:
return forecast_lines
lines_with_extras = []
for forecast_line in forecast_lines:
if forecast_line.get('document_in', False) or forecast_line.get('replenishment_filled'):
lines_with_extras.append(forecast_line)
continue
line_qty = forecast_line['quantity']
if forecast_line.get('document_out', False) and forecast_line['document_out']['_name'] == 'mrp.production':
production_id = forecast_line['document_out']['id']
else:
production_id = False
index_to_remove = []
for index, extra in enumerate(extras):
if float_is_zero(extra['quantity'], precision_rounding=product_rounding):
index_to_remove.append(index)
continue
if production_id and extra.get('production_id', False) and extra['production_id'] != production_id:
continue
taken_from_extra = min(line_qty, extra['quantity'])
line_qty -= taken_from_extra
# Create copy of the current forecast line to add a possible replenishment.
# Needs to be a copy since it might take multiple replenishment to fulfill a single "out" line.
new_extra_line = copy.copy(forecast_line)
new_extra_line['quantity'] = taken_from_extra
new_extra_line['document_in'] = {
'_name': extra['_name'],
'id': extra['id'],
}
lines_with_extras.append(new_extra_line)
extra['quantity'] -= taken_from_extra
if float_compare(extra['quantity'], 0, precision_rounding=product_rounding) <= 0:
index_to_remove.append(index)
if float_is_zero(line_qty, precision_rounding=product_rounding):
break
for index in reversed(index_to_remove):
del extras[index]
return lines_with_extras
def _get_extra_replenishments(self, product):
return []
def _get_resupply_data(self, rules, rules_delay, quantity, uom_id, product, warehouse):
manufacture_rules = [rule for rule in rules if rule.action == 'manufacture']
if manufacture_rules:
# Need to get rules from Production location to get delays before production
wh_manufacture_rules = product._get_rules_from_location(product.property_stock_production, route_ids=warehouse.route_ids)
wh_manufacture_rules -= rules
rules_delay += sum(rule.delay for rule in wh_manufacture_rules)
return {
'delay': product.produce_delay + rules_delay,
'cost': product.standard_price * uom_id._compute_quantity(quantity, product.uom_id),
}
return False
def _get_warehouse_locations(self, warehouse, replenish_data):
if not replenish_data['warehouses'].get(warehouse.id):
replenish_data['warehouses'][warehouse.id] = [loc['id'] for loc in self.env['stock.location'].search_read(
[('id', 'child_of', warehouse.view_location_id.id)],
['id']
)]
return replenish_data['warehouses'][warehouse.id]
def _get_reserved_qty(self, move_raw, warehouse, replenish_data):
if not replenish_data['qty_reserved'].get(move_raw):
total_reserved = 0
wh_location_ids = self._get_warehouse_locations(warehouse, replenish_data)
linked_moves = self.env['stock.move'].browse(move_raw._rollup_move_origs()).filtered(lambda m: m.location_id.id in wh_location_ids or m.location_dest_id.id not in wh_location_ids)
for move in linked_moves:
if move.state not in ('partially_available', 'assigned'):
continue
# count reserved stock.
reserved = move.product_uom._compute_quantity(move.reserved_availability, move.product_id.uom_id)
# check if the move reserved qty was counted before (happens if multiple outs share pick/pack)
reserved = min(reserved - replenish_data['qty_already_reserved'][move], move_raw.product_qty)
total_reserved += reserved
replenish_data['qty_already_reserved'][move] += reserved
if float_compare(total_reserved, move_raw.product_qty, precision_rounding=move.product_id.uom_id.rounding) >= 0:
break
replenish_data['qty_reserved'][move_raw] = total_reserved
return replenish_data['qty_reserved'][move_raw]
@@ -0,0 +1,140 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<template id="report_mo_overview">
<t t-set="data_report_landscape" t-value="True"/>
<t t-call="web.basic_layout">
<t t-foreach="docs" t-as="data">
<div class="page">
<t t-call="mrp.mo_overview_content"/>
</div>
<p style="page-break-before:always;"> </p>
</t>
</t>
</template>
<template id="mo_overview_content">
<t t-set="currency" t-value="data['summary']['currency']"/>
<div class="container bg-view">
<table class="table">
<thead>
<tr>
<th/>
<th t-if="data['show_replenishments']" class="text-center">Status</th>
<th t-attf-class="{{ 'text-center' if data['show_uom'] else 'text-end' }}" t-attf-colspan="{{ 2 if data['show_uom'] else 1 }}">Quantity</th>
<th t-if="data['show_availabilities']" class="text-end">Free to use / On Hand</th>
<th t-if="data['show_availabilities']" class="text-end">Reserved</th>
<th t-if="data['show_receipts']" class="text-end">Receipt</th>
<th t-if="data['show_mo_costs']" class="text-end">MO Cost</th>
<th t-if="data['show_product_costs']" class="text-end">Product Cost</th>
</tr>
</thead>
<tbody>
<t t-call="mrp.mo_overview_line">
<t t-set="line" t-value="data['summary']"/>
</t>
<t t-call="mrp.mo_overview_components">
<t t-set="components" t-value="data['components']"/>
<t t-set="operations" t-value="data['operations']"/>
</t>
</tbody>
<tfoot>
<tr>
<td class="text-start">Unit Cost</td>
<td t-if="data['show_replenishments']"/>
<td/>
<td t-if="data['show_uom']"/>
<td t-if="data['show_availabilities']"/>
<td t-if="data['show_availabilities']"/>
<td t-if="data['show_receipts']"/>
<td t-if="data['show_mo_costs']" t-attf-class="text-end {{ 'text-' + data['extras']['unit_mo_cost_decorator'] if data.get('extras', {}).get('unit_mo_cost_decorator') else '' }}" t-out="data['extras']['unit_mo_cost']" t-options="{'widget': 'monetary', 'display_currency': currency}"/>
<td t-if="data['show_product_costs']" class="text-end" t-out="data['extras']['unit_product_cost']" t-options="{'widget': 'monetary', 'display_currency': currency}"/>
</tr>
</tfoot>
</table>
</div>
</template>
<template id="mo_overview_line">
<tr>
<td class="text-start">
<div t-attf-style="margin-left: {{ line['level'] * 20 }}px" class="d-inline-block" t-out="line['name']"/>
</td>
<td t-if="data['show_replenishments']" class="text-center" t-out="line.get('formatted_state', '')"/>
<td t-attf-class="text-end {{ 'text-' + line['quantity_decorator'] if line.get('quantity_decorator') else '' }}">
<t t-if="line.get('model') == 'mrp.workorder'" t-out="line['quantity']" t-options="{'widget': 'float_time'}"/>
<t t-else="" t-out="line['quantity']" t-options="{'widget': 'float', 'decimal_precision': 'Product Unit of Measure'}"/>
</td>
<td t-if="data['show_uom']" class="text-start" t-out="line['uom_name']"/>
<td t-if="data['show_availabilities']" class="text-end">
<t t-if="line.get('quantity_on_hand', False) is not False">
<t t-out="line['quantity_free']" t-options="{'widget': 'float', 'precision': line['uom_precision']}"/> /
<t t-out="line['quantity_on_hand']" t-options="{'widget': 'float', 'precision': line['uom_precision']}"/>
</t>
</td>
<td t-if="data['show_availabilities']" class="text-end">
<t t-if="line.get('quantity_reserved', False) is not False" t-out="line['quantity_reserved']" t-options="{'widget': 'float', 'precision': line['uom_precision']}"/>
</td>
<td t-if="data['show_receipts']" t-attf-class="text-end {{ 'text-' + line['receipt']['decorator'] if line.get('receipt', {}).get('decorator') else '' }}">
<span t-if="line.get('receipt')" t-attf-class="{{ 'text-' + line['receipt']['decorator'] }}" t-out="line['receipt']['display']"/>
</td>
<td t-if="data['show_mo_costs']" t-attf-class="text-end {{ 'text-' + line['mo_cost_decorator'] if line.get('mo_cost_decorator') else '' }}">
<t t-out="line['mo_cost']" t-options="{'widget': 'monetary', 'display_currency': line['currency']}"/>
</td>
<td t-if="data['show_product_costs']" class="text-end">
<t t-if="line.get('product_cost', False) is not False" t-out="line['product_cost']" t-options="{'widget': 'monetary', 'display_currency': line['currency']}"/>
</td>
</tr>
</template>
<template id="mo_overview_components">
<t t-foreach="components" t-as="component">
<t t-call="mrp.mo_overview_line">
<t t-set="line" t-value="component['summary']"/>
</t>
<t t-if="component['summary']['index'] in data['unfolded_ids'] and component.get('replenishments', [])" t-foreach="component['replenishments']" t-as="replenishment">
<t t-call="mrp.mo_overview_line">
<t t-set="line" t-value="replenishment['summary']"/>
</t>
<t t-if="replenishment['summary']['index'] in data['unfolded_ids'] and (replenishment.get('components', []) or replenishment.get('operations', {}).get('details', []))">
<t t-call="mrp.mo_overview_components">
<t t-set="components" t-value="replenishment.get('components', [])"/>
<t t-set="operations" t-value="replenishment.get('operations', {})"/>
</t>
</t>
</t>
</t>
<t t-call="mrp.mo_overview_operations">
<t t-set="summary" t-value="operations.get('summary', {})"/>
<t t-set="operations" t-value="operations.get('details', [])"/>
</t>
</template>
<template id="mo_overview_operations">
<t t-if="operations">
<tr>
<td class="text-start">
<span t-attf-style="margin-left: {{ (operations[0]['level'] - 1 if len(operations) else 0) * 20 }}px"/>
Operations
</td>
<td t-if="data['show_replenishments']" class="text-center"/>
<td t-attf-class="text-end {{ 'text-' + summary['quantity_decorator'] if summary.get('quantity_decorator') else '' }}">
<t t-out="summary['quantity']" t-options="{'widget': 'float_time'}"/>
</td>
<td t-if="data['show_uom']" class="text-start" t-out="summary['uom_name']"/>
<td t-if="data['show_availabilities']" class="text-end"/>
<td t-if="data['show_availabilities']" class="text-end"/>
<td t-if="data['show_receipts']" class="text-end"/>
<td t-if="data['show_mo_costs']" t-attf-class="text-end {{ 'text-' + summary['mo_cost_decorator'] if summary.get('mo_cost_decorator') else '' }}">
<t t-out="summary['mo_cost']" t-options="{'widget': 'monetary', 'display_currency': summary['currency']}"/>
</td>
<td t-if="data['show_product_costs']" class="text-end"/>
</tr>
</t>
<t t-if="summary['index'] in data['unfolded_ids']" t-foreach="operations" t-as="operation">
<t t-call="mrp.mo_overview_line">
<t t-set="line" t-value="operation"/>
</t>
</t>
</template>
</odoo>
@@ -21,6 +21,15 @@
<field name="binding_model_id" ref="model_mrp_bom"/>
<field name="binding_type">report</field>
</record>
<record id="action_report_mrp_mo_overview" model="ir.actions.report">
<field name="name">MO Overview</field>
<field name="model">mrp.production</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">mrp.report_mo_overview</field>
<field name="report_file">mrp.report_mo_overview</field>
<field name="print_report_name">'MO Overview - %s' % object.display_name</field>
<field name="binding_type">report</field>
</record>
<record id="label_manufacture_template" model="ir.actions.report">
<field name="name">Finished Product Label (ZPL)</field>
<field name="model">mrp.production</field>
@@ -0,0 +1,147 @@
/** @odoo-module **/
import { Component, EventBus, onWillStart, useSubEnv, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useBus, useService } from "@web/core/utils/hooks";
import { Layout } from "@web/search/layout";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
import { MoOverviewLine } from "../mo_overview_line/mrp_mo_overview_line";
import { MoOverviewDisplayFilter } from "../mo_overview_display_filter/mrp_mo_overview_display_filter";
import { MoOverviewComponentsBlock } from "../mo_overview_components_block/mrp_mo_overview_components_block";
import { formatMonetary } from "@web/views/fields/formatters";
export class MoOverview extends Component {
static template = "mrp.MoOverview";
setup() {
this.actionService = useService("action");
this.ormService = useService("orm");
this.unfoldedIds = new Set();
this.context = {};
this.state = useState({
data: {},
showOptions: this.getDefaultConfig(),
});
useSubEnv({ overviewBus: new EventBus() });
onWillStart(async () => {
await this.getManufacturingData();
});
useBus(this.env.overviewBus, "update-folded", (ev) => this.onChangeFolded(ev.detail));
useBus(this.env.overviewBus, "reload", () => this.getManufacturingData());
}
async getManufacturingData() {
const reportValues = await this.ormService.call(
"report.mrp.report_mo_overview",
"get_report_values",
[this.activeId],
);
this.state.data = reportValues.data;
this.state.showOptions.uom = reportValues.context.show_uom;
this.context = reportValues.context;
if (reportValues.data?.operations?.summary?.index) {
// Main MO's operations are always unfolded by default.
this.unfoldedIds.add(reportValues.data.operations.summary.index);
}
}
//---- Handlers ----
onChangeDisplay(displayInfo) {
this.state.showOptions[displayInfo] = !this.state.showOptions[displayInfo];
}
onChangeFolded(foldInfo) {
const { indexes, isFolded } = foldInfo;
const operation = isFolded ? "delete" : "add";
indexes.forEach(index => this.unfoldedIds[operation](index));
}
async onPrint() {
const reportName = `mrp.report_mo_overview?docids=${this.activeId}`
+ `&replenishments=${+this.state.showOptions.replenishments}`
+ `&availabilities=${+this.state.showOptions.availabilities}`
+ `&receipts=${+this.state.showOptions.receipts}`
+ `&moCosts=${+this.state.showOptions.moCosts}`
+ `&productCosts=${+this.state.showOptions.productCosts}`
+ `&unfoldedIds=${JSON.stringify(Array.from(this.unfoldedIds))}`;
return this.actionService.doAction({
type: "ir.actions.report",
report_type: "qweb-pdf",
report_name: reportName,
report_file: "mrp.report_mo_overview",
});
}
onUnfold() {
this.env.overviewBus.trigger("unfold-all")
}
//---- Helpers ----
getDefaultConfig() {
return {
uom: false,
replenishments: true,
availabilities: true,
receipts: true,
moCosts: true,
productCosts: true,
};
}
getColorClass(decorator) {
return decorator ? `text-${decorator}` : "";
}
formatCost(cost) {
return formatMonetary(cost, { currencyId: this.state.data.summary.currency_id });
}
//---- Getters ----
get activeId() {
return this.props.action.context.active_id;
}
get showUom() {
return this.state.showOptions.uom;
}
get showReplenishments() {
return this.state.showOptions.replenishments;
}
get showAvailabilities() {
return this.state.showOptions.availabilities;
}
get showReceipts() {
return this.state.showOptions.receipts;
}
get showMoCosts() {
return this.state.showOptions.moCosts;
}
get showProductCosts() {
return this.state.showOptions.productCosts;
}
}
MoOverview.components = {
Layout,
MoOverviewLine,
MoOverviewDisplayFilter,
MoOverviewComponentsBlock,
};
MoOverview.props = {
...standardActionServiceProps,
globalState: { type: Object, optional: true }, // Appears when coming back from the breadcrumb
};
registry.category("actions").add("mrp_mo_overview", MoOverview);
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<div t-name="mrp.MoOverview" class="o_action" owl="1">
<Layout display="{ controlPanel: { 'top-right': false } }">
<t t-set-slot="control-panel-bottom-right">
<MoOverviewDisplayFilter showOptions="state.showOptions" changeDisplay.bind="onChangeDisplay"/>
</t>
<t t-set-slot="control-panel-bottom-left">
<button class="btn btn-primary" t-on-click="onPrint">Print</button>
<button class="btn btn-primary" t-on-click="onUnfold">Unfold</button>
</t>
<div class="py-3 py-lg-5 overflow-auto border-bottom bg-view">
<div class="container-fluid">
<table class="table">
<thead>
<tr>
<th class="text-start"/>
<th class="text-center" t-if="showReplenishments">Status</th>
<th t-attf-class="{{ showUom ? 'text-center' : 'text-end' }}" t-attf-colspan="{{ showUom ? 2 : 1 }}">Quantity</th>
<th class="text-end" t-if="showAvailabilities">Free to use / On Hand</th>
<th class="text-end" t-if="showAvailabilities">Reserved</th>
<th class="text-end" t-if="showReceipts">Receipt</th>
<th class="text-end" t-if="showMoCosts">MO Cost</th>
<th class="text-end" t-if="showProductCosts">Product Cost</th>
</tr>
</thead>
<tbody>
<MoOverviewLine data="state.data.summary" showOptions="state.showOptions"/>
<MoOverviewComponentsBlock
components="state.data.components"
operations="state.data.operations"
showOptions="state.showOptions"/>
</tbody>
<tfoot>
<tr>
<td class="text-start">Unit Cost</td>
<td t-if="showReplenishments"/>
<td/>
<td t-if="showUom"/>
<td t-if="showAvailabilities"/>
<td t-if="showAvailabilities"/>
<td t-if="showReceipts"/>
<td class="text-end" t-att-class="getColorClass(state.data.extras.unit_mo_cost_decorator)" t-if="showMoCosts" t-out="formatCost(state.data.extras.unit_mo_cost)"/>
<td class="text-end" t-if="showProductCosts" t-out="formatCost(state.data.extras.unit_product_cost)"/>
</tr>
</tfoot>
</table>
</div>
</div>
</Layout>
</div>
</templates>
@@ -0,0 +1,106 @@
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { useBus } from "@web/core/utils/hooks";
import { MoOverviewLine } from "../mo_overview_line/mrp_mo_overview_line";
import { MoOverviewOperationsBlock } from "../mo_overview_operations_block/mrp_mo_overview_operations_block";
export class MoOverviewComponentsBlock extends Component {
static template = "mrp.MoOverviewComponentsBlock";
setup() {
this.state = useState({
fold: this.getIndexStates(),
unfoldAll: this.props.unfoldAll || false,
});
if (this.props.unfoldAll) {
this.env.overviewBus.trigger("update-folded", { indexes: Object.keys(this.state.fold), isFolded: false });
}
useBus(this.env.overviewBus, "unfold-all", () => this.unfoldAll());
}
//---- Handlers ----
onToggleFolded(foldIndex) {
this.state.unfoldAll = false;
const newState = !this.state.fold[foldIndex];
if (newState) {
// If a line is folded, its children lines must be folded as well
Object.keys(this.state.fold).filter(key => key.startsWith(foldIndex)).forEach(index => {
this.state.fold[index] = newState;
});
}
this.state.fold[foldIndex] = newState;
this.env.overviewBus.trigger("update-folded", { indexes: [foldIndex], isFolded: newState });
}
unfoldAll() {
this.state.unfoldAll = true;
const foldIndexes = Object.keys(this.state.fold);
foldIndexes.forEach(index => this.state.fold[index] = false);
this.env.overviewBus.trigger("update-folded", { indexes: foldIndexes, isFolded: false });
}
//---- Helpers ----
getIndexStates() {
const indexStates = {};
(this.props?.components ?? []).forEach(component => {
indexStates[component?.summary.index] = !this.props.unfoldAll;
(component?.replenishments ?? []).forEach(replenishment => {
indexStates[replenishment?.summary.index] = !this.props.unfoldAll;
});
});
return indexStates;
}
hasReplenishments(component) {
return component?.replenishments?.length > 0;
}
hasReplenishmentsBlock(component) {
return this.hasReplenishments(component) && !this.state.fold[component?.summary.index];
}
hasComponents(replenishment) {
return replenishment?.components?.length > 0 || replenishment?.operations?.details?.length > 0;
}
hasComponentsBlock(replenishment) {
return this.hasComponents(replenishment) && !this.state.fold[replenishment?.summary.index];
}
}
MoOverviewComponentsBlock.components = {
MoOverviewLine,
MoOverviewOperationsBlock,
MoOverviewComponentsBlock,
};
MoOverviewComponentsBlock.props = {
unfoldAll: { type: Boolean, optional: true },
components: { type: Array, optional: true },
operations: {
type: Object,
shape: {
summary: Object,
details: Array,
},
optional: true,
},
showOptions: {
type: Object,
shape: {
uom: Boolean,
replenishments: Boolean,
availabilities: Boolean,
receipts: Boolean,
moCosts: Boolean,
productCosts: Boolean,
},
},
};
MoOverviewComponentsBlock.defaultProps = {
unfoldAll: false,
};
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp.MoOverviewComponentsBlock" owl="1">
<t t-foreach="props.components" t-as="component" t-key="component.summary.index">
<MoOverviewLine
data="component.summary"
hasFoldButton="hasReplenishments(component)"
isFolded="state.fold[component.summary.index]"
showOptions="props.showOptions"
toggleFolded.bind="onToggleFolded"/>
<!-- Display Replenishment block -->
<t t-if="hasReplenishmentsBlock(component)" t-foreach="component.replenishments" t-as="replenishment" t-key="replenishment.summary.index">
<MoOverviewLine
data="replenishment.summary"
hasFoldButton="hasComponents(replenishment)"
isFolded="state.fold[replenishment.summary.index]"
showOptions="props.showOptions"
toggleFolded.bind="onToggleFolded"/>
<t t-if="hasComponentsBlock(replenishment)">
<MoOverviewComponentsBlock
unfoldAll="state.unfoldAll"
components="replenishment.components"
operations="replenishment.operations"
showOptions="props.showOptions"/>
</t>
</t>
</t>
<MoOverviewOperationsBlock
unfoldAll="state.unfoldAll"
summary="props.operations.summary"
operations="props.operations.details"
showOptions="props.showOptions"/>
</t>
</templates>
@@ -0,0 +1,29 @@
/** @odoo-module **/
import { BomOverviewDisplayFilter } from "../bom_overview_display_filter/mrp_bom_overview_display_filter";
export class MoOverviewDisplayFilter extends BomOverviewDisplayFilter {
setup() {
this.displayOptions = {
replenishments: this.env._t("Replenishments"),
availabilities: this.env._t("Availabilities"),
receipts: this.env._t("Receipts"),
moCosts: this.env._t("MO Costs"),
productCosts: this.env._t("Product Costs"),
};
}
}
MoOverviewDisplayFilter.props = {
showOptions: {
type: Object,
shape: {
uom: Boolean,
replenishments: Boolean,
availabilities: Boolean,
receipts: Boolean,
moCosts: Boolean,
productCosts: Boolean,
}
},
changeDisplay: Function,
};
@@ -0,0 +1,51 @@
/** @odoo-module **/
const PRODUCTION_DECORATORS = {
draft: "muted",
confirmed: "info",
progress: "warning",
done: "success",
to_close: "success",
cancel: "danger",
};
const PURCHASE_DECORATORS = {
draft: "info",
sent: "info",
['to approve']: "info",
purchase: "info",
done: "info",
cancel: "muted",
};
const OPERATION_DECORATORS = {
pending: "info",
waiting: "info",
ready: "info",
progress: "warning",
done: "success",
cancel: "muted",
};
const PRODUCT_DECORATORS = {
to_order: "danger",
};
export function getStateDecorator(model, state) {
let decorators = null;
switch (model) {
case "mrp.production":
decorators = PRODUCTION_DECORATORS;
break;
case "mrp.workorder":
decorators = OPERATION_DECORATORS;
break;
case "purchase.order":
decorators = PURCHASE_DECORATORS;
break;
case "product.product":
decorators = PRODUCT_DECORATORS;
break;
}
return decorators ? `text-bg-${decorators[state]}` : "";
}
@@ -0,0 +1,179 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { formatFloat, formatFloatTime, formatMonetary } from "@web/views/fields/formatters";
import { getStateDecorator } from "./mo_overview_colors";
export class MoOverviewLine extends Component {
static template = "mrp.MoOverviewLine";
setup() {
this.actionService = useService("action");
this.ormService = useService("orm");
this.formatFloat = (val) => formatFloat(val, { digits: [false, this.data.uom_precision || undefined] });
this.formatFloatTime = formatFloatTime;
this.formatMonetary = (val) => formatMonetary(val, { currencyId: this.data.currency_id });
}
//---- Handlers ----
async openForm() {
const model = this.data.level === 0 ? this.data.product_model : this.data.model;
const id = this.data.level === 0 ? this.data.product_id : this.data.id;
return this.actionService.doAction({
type: "ir.actions.act_window",
res_model: model,
res_id: id,
views: [[false, "form"]],
target: "current",
context: {
active_id: id,
},
});
}
async openForecast() {
const action = await this.ormService.call(
this.data.product_model,
this.forecastAction,
[[this.data.product_id]],
);
action.context = {
active_model: this.data.product_model,
active_id: this.data.product_id,
};
return this.actionService.doAction(action);
}
async openReplenish() {
return this.actionService.doAction("stock.action_product_replenish", {
additionalContext: { default_product_id: this.data.product_id, default_quantity: this.data.quantity },
onClose: (closeInfo) => {
if (closeInfo?.done) {
// Trigger the reload only if a replenishment was done.
this.env.overviewBus.trigger("reload");
}
},
});
}
async openWorkorder() {
return this.actionService.doAction({
name: this.data.name,
type: "ir.actions.act_window",
res_model: "mrp.workorder",
views: [[false, "list"]],
context: {
search_default_ready: true,
search_default_waiting: true,
search_default_progress: true,
search_default_pending: true,
search_default_name: this.data.name,
search_default_production_id: this.data.production_id,
},
});
}
//---- Helpers ----
getColorClass(decorator) {
return decorator ? `text-${decorator}` : "";
}
hasQuantity(keyName) {
return this.data.hasOwnProperty(keyName) && this.data[keyName] !== false;
}
//---- Getters ----
get data() {
return this.props.data;
}
get stateDecorator() {
return getStateDecorator(this.data.model, this.data.state);
}
get formattedQuantity() {
if (this.data.model === "mrp.workorder") {
return this.formatFloatTime(this.data.quantity);
}
return this.formatFloat(this.data.quantity);
}
get hasFoldButton() {
return false;
}
get marginMultiplicator() {
return this.data.level - (this.props.hasFoldButton ? 1 : 0);
}
get foldButtonTitle() {
return this.props.isFolded ? this.env._t("Unfold") : this.env._t("Fold");
}
get forecastAction() {
switch (this.data.product_model) {
case "product.product":
return "action_product_forecast_report";
case "product.template":
return "action_product_tmpl_forecast_report";
}
}
}
MoOverviewLine.props = {
data: {
type: Object,
shape: {
level: Number,
index: { type: String, optional: true },
id: { type: Number, optional: true },
model: { type: String, optional: true },
name: String,
product_model: { type: String, optional: true },
product_id: { type: Number, optional: true },
state: { type: String, optional: true },
formatted_state: { type: String, optional: true },
quantity: Number,
quantity_decorator: { type: [String, Boolean], optional: true },
uom_name: { type: String, optional: true },
uom_precision: { type: Number, optional: true },
quantity_free: { type: [Number, Boolean], optional: true },
quantity_on_hand: { type: [Number, Boolean], optional: true },
quantity_reserved: { type: Number, optional: true },
receipt: {
type: Object,
shape: {
display: String,
type: String,
decorator: [String, Boolean],
date: [String, Boolean],
},
optional: true,
},
mo_cost: Number,
mo_cost_decorator: { type: [String, Boolean], optional: true },
product_cost: { type: Number, optional: true },
currency_id: Number,
currency: { type: String, optional: true },
production_id: { type: Number, optional: true },
},
},
showOptions: {
type: Object,
shape: {
uom: Boolean,
replenishments: Boolean,
availabilities: Boolean,
receipts: Boolean,
moCosts: Boolean,
productCosts: Boolean,
}
},
hasFoldButton: { type: Boolean, optional: true },
isFolded: { type: Boolean, optional: true },
toggleFolded: { type: Function, optional: true },
};
@@ -0,0 +1,3 @@
.o_mrp_overview_badge {
font-size: $badge-font-size * 1.1;
}
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<tr t-name="mrp.MoOverviewLine" owl="1">
<td class="text-start">
<div t-attf-style="margin-left: {{ marginMultiplicator * 20 }}px" class="d-inline-block">
<button t-if="props.hasFoldButton" t-on-click="() => this.props.toggleFolded(data.index)" class="btn btn-light p-0" t-attf-aria-label="{{ foldButtonTitle }}" t-attf-title="{{ foldButtonTitle }}" style="margin-right: 1px">
<i t-attf-class="fa fa-fw fa-caret-{{ props.isFolded ? 'right' : 'down' }}" role="img"/>
</button>
<a t-if="data.id and data.model === 'mrp.workorder'" href="#" t-on-click.prevent="openWorkorder" t-out="data.name"/>
<a t-elif="data.id and data.model" href="#" t-on-click.prevent="openForm" t-out="data.name"/>
<t t-else="" t-out="data.name"/>
<button t-if="data.model === 'to_order'" class="ms-2 py-0 btn btn-secondary" t-on-click="openReplenish">Replenish</button>
</div>
</td>
<td class="text-center" t-if="props.showOptions.replenishments">
<div class="o_field_badge">
<span t-attf-class="badge rounded-pill o_mrp_overview_badge {{ stateDecorator }}" t-out="data.formatted_state"/>
</div>
</td>
<td t-attf-class="text-end {{ getColorClass(data.quantity_decorator) }}" t-out="formattedQuantity"/>
<td t-if="props.showOptions.uom" class="text-start" t-out="data.uom_name"/>
<td class="text-end" t-if="props.showOptions.availabilities">
<t t-if="hasQuantity('quantity_on_hand')">
<t t-out="formatFloat(data.quantity_free)"/> /
<t t-out="formatFloat(data.quantity_on_hand)"/>
</t>
</td>
<td class="text-end" t-if="props.showOptions.availabilities">
<t t-if="hasQuantity('quantity_reserved')" t-out="formatFloat(data.quantity_reserved)"/>
</td>
<td class="text-end" t-if="props.showOptions.receipts">
<t t-if="data.receipt">
<span t-attf-class="{{ getColorClass(data.receipt.decorator) }}" t-out="data.receipt.display"/>
<a href="#" role="button" t-on-click.prevent="openForecast" title="Forecast Report" t-attf-class="fa fa-fw fa-area-chart ms-1 {{getColorClass(data.receipt.decorator)}}"/>
</t>
</td>
<td t-attf-class="text-end {{ getColorClass(data.mo_cost_decorator) }}" t-if="props.showOptions.moCosts" t-out="formatMonetary(data.mo_cost)"/>
<td class="text-end" t-if="props.showOptions.productCosts">
<t t-if="hasQuantity('product_cost')" t-out="formatMonetary(data.product_cost)"/>
</td>
</tr>
</templates>
@@ -0,0 +1,94 @@
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { useBus } from "@web/core/utils/hooks";
import { formatFloatTime, formatMonetary } from "@web/views/fields/formatters";
import { MoOverviewLine } from "../mo_overview_line/mrp_mo_overview_line";
export class MoOverviewOperationsBlock extends Component {
static template = "mrp.MoOverviewOperationsBlock";
setup() {
this.formatFloatTime = formatFloatTime;
this.state = useState({
// Unfold the main MO's operations by default
isFolded: this.level > 0 && !this.props.unfoldAll,
});
if (this.props.unfoldAll) {
this.env.overviewBus.trigger("update-folded", { indexes: [this.index], isFolded: false });
}
useBus(this.env.overviewBus, "unfold-all", () => this.unfold());
}
//---- Handlers ----
toggleFolded() {
this.state.isFolded = !this.state.isFolded;
this.env.overviewBus.trigger("update-folded", { indexes: [this.index], isFolded: this.state.isFolded });
}
unfold() {
this.state.isFolded = false;
this.env.overviewBus.trigger("update-folded", { indexes: [this.index], isFolded: false });
}
//---- Helpers ----
formatMonetary(val) {
return formatMonetary(val, { currencyId: this.props.summary.currency_id });
}
getColorClass(decorator) {
return decorator ? `text-${decorator}` : "";
}
//---- Getters ----
get hasOperations() {
return this.props?.operations?.length > 0;
}
get level() {
return this.hasOperations ? this.props.operations[0].level - 1 : 0;
}
get index() {
return this.props.summary.index;
}
}
MoOverviewOperationsBlock.components = {
MoOverviewLine,
};
MoOverviewOperationsBlock.props = {
unfoldAll: { type: Boolean, optional: true },
operations: Array,
summary: {
type: Object,
shape: {
index: String,
quantity: { type: Number, optional: true },
quantity_decorator: { type: [String, Boolean], optional: true },
mo_cost: { type: Number, optional: true },
mo_cost_decorator: { type: [String, Boolean], optional: true },
uom_name: { type: String, optional: true },
currency_id: { type: Number, optional: true },
currency: { type: String, optional: true },
},
},
showOptions: {
type: Object,
shape: {
uom: Boolean,
replenishments: Boolean,
availabilities: Boolean,
receipts: Boolean,
moCosts: Boolean,
productCosts: Boolean,
},
},
};
MoOverviewOperationsBlock.defaultProps = {
unfoldAll: false,
};
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp.MoOverviewOperationsBlock" owl="1">
<t t-if="hasOperations">
<tr>
<td class="text-start">
<span t-attf-style="margin-left: {{ level * 20 }}px"/>
<button t-on-click="toggleFolded" class="btn btn-light ps-0 py-0" t-attf-aria-label="{{ state.isFolded ? 'Unfold' : 'Fold' }}" t-attf-title="{{ state.isFolded ? 'Unfold' : 'Fold' }}">
<i t-attf-class="fa fa-fw fa-caret-{{ state.isFolded ? 'right' : 'down' }}" role="img"/>
Operations
</button>
</td>
<td class="text-center" t-if="props.showOptions.replenishments"/>
<td t-attf-class="text-end {{ getColorClass(props.summary.quantity_decorator) }}" t-out="formatFloatTime(props.summary.quantity)"/>
<td class="text-start" t-if="props.showOptions.uom" t-out="props.summary.uom_name"/>
<td class="text-end" t-if="props.showOptions.availabilities"/>
<td class="text-end" t-if="props.showOptions.availabilities"/>
<td class="text-end" t-if="props.showOptions.receipts"/>
<td t-attf-class="text-end {{ getColorClass(props.summary.mo_cost_decorator) }}" t-if="props.showOptions.moCosts" t-out="formatMonetary(props.summary.mo_cost)"/>
<td class="text-end" t-if="props.showOptions.productCosts"/>
</tr>
<t t-if="!state.isFolded" t-foreach="props.operations" t-as="operation" t-key="operation.index">
<MoOverviewLine data="operation" showOptions="props.showOptions"/>
</t>
</t>
</t>
</templates>
@@ -2,6 +2,12 @@
<odoo>
<data>
<!-- Manufacturing Order -->
<record id="action_report_mo_overview" model="ir.actions.client">
<field name="name">MO Overview</field>
<field name="tag">mrp_mo_overview</field>
<field name="context" eval="{'model': 'report.mrp.report_mo_overview'}" />
</record>
<record id="mrp_production_tree_view" model="ir.ui.view">
<field name="name">mrp.production.tree</field>
<field name="model">mrp.production</field>
@@ -181,6 +187,7 @@
</button>
<button name="%(stock.action_stock_report)d" icon="fa-arrow-up" class="oe_stat_button" string="Traceability" type="action" states="done" groups="stock.group_production_lot"/>
<button name="%(action_mrp_production_moves)d" type="action" string="Product Moves" class="oe_stat_button" icon="fa-exchange" attrs="{'invisible': [('state', 'not in', ('progress', 'done'))]}"/>
<button name="%(action_report_mo_overview)d" type="action" class="oe_stat_button" icon="fa-bars" string="Overview"/>
</div>
<div class="oe_title">
<label class="o_form_label" for="name" string="MO Reference"/>
+1
View File
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import mrp_report_bom_structure
from . import mrp_report_mo_overview
@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.tools import float_compare
class ReportMoOverview(models.AbstractModel):
_inherit = 'report.mrp.report_mo_overview'
def _get_extra_replenishments(self, product):
res = super()._get_extra_replenishments(product)
domain = [('state', 'in', ['draft', 'sent', 'to approve']), ('product_id', '=', product.id)]
warehouse_id = self.env.context.get('warehouse', False)
if warehouse_id:
domain += [('order_id.picking_type_id.warehouse_id', '=', warehouse_id)]
po_lines = self.env['purchase.order.line'].search(domain, order='date_planned, id')
for po_line in po_lines:
line_qty = po_line.product_uom_qty
if po_line.move_dest_ids.raw_material_production_id:
for move in po_line.move_dest_ids:
if float_compare(line_qty, move.product_uom_qty, precision_rounding=po_line.product_uom.rounding) >= 0:
prod_qty = move.product_uom_qty
else:
prod_qty = line_qty
res.append(self._format_extra_replenishment(po_line, prod_qty, move.raw_material_production_id.id))
line_qty -= prod_qty
else:
res.append(self._format_extra_replenishment(po_line, po_line.product_uom_qty))
return res
def _format_extra_replenishment(self, po_line, quantity, production_id=False):
return {
'_name': 'purchase.order',
'id': po_line.order_id.id,
'cost': po_line.price_total,
'quantity': quantity,
'production_id': production_id
}
def _get_replenishment_receipt(self, doc_in, components):
res = super()._get_replenishment_receipt(doc_in, components)
if doc_in._name == 'purchase.order':
receipt_state = 'expected' if doc_in.state == 'purchase' else 'estimated'
return self._format_receipt_date(receipt_state, doc_in.date_planned)
return res
def _get_resupply_data(self, rules, rules_delay, quantity, product, warehouse):
res = super()._get_resupply_data(rules, rules_delay, quantity, product, warehouse)
if any(rule for rule in rules if rule.action == 'buy' and product.seller_ids):
supplier = product._select_seller(quantity=quantity, uom_id=product.uom_id)
return {
'delay': supplier.delay + rules_delay,
'cost': supplier.price * quantity,
}
return res
def _get_replenishment_cost(self, product, quantity, currency, move_in=False):
if move_in and move_in.purchase_line_id:
return currency.round(move_in.purchase_line_id.price_unit * quantity)
return super()._get_replenishment_cost(product, quantity, currency, move_in)