diff --git a/addons/mrp/__manifest__.py b/addons/mrp/__manifest__.py index 449bfd63000..5b397118d0f 100644 --- a/addons/mrp/__manifest__.py +++ b/addons/mrp/__manifest__.py @@ -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', diff --git a/addons/mrp/report/__init__.py b/addons/mrp/report/__init__.py index 51b261348ed..2f870988d17 100644 --- a/addons/mrp/report/__init__.py +++ b/addons/mrp/report/__init__.py @@ -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 diff --git a/addons/mrp/report/mrp_report_mo_overview.py b/addons/mrp/report/mrp_report_mo_overview.py new file mode 100644 index 00000000000..4d9798bdcd5 --- /dev/null +++ b/addons/mrp/report/mrp_report_mo_overview.py @@ -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] diff --git a/addons/mrp/report/mrp_report_mo_overview.xml b/addons/mrp/report/mrp_report_mo_overview.xml new file mode 100644 index 00000000000..b086b07e8e2 --- /dev/null +++ b/addons/mrp/report/mrp_report_mo_overview.xml @@ -0,0 +1,140 @@ + + + + + + + + + + + + diff --git a/addons/mrp/report/mrp_report_views_main.xml b/addons/mrp/report/mrp_report_views_main.xml index ccdd89637ed..1815883848c 100644 --- a/addons/mrp/report/mrp_report_views_main.xml +++ b/addons/mrp/report/mrp_report_views_main.xml @@ -21,6 +21,15 @@ report + + MO Overview + mrp.production + qweb-pdf + mrp.report_mo_overview + mrp.report_mo_overview + 'MO Overview - %s' % object.display_name + report + Finished Product Label (ZPL) mrp.production diff --git a/addons/mrp/static/src/components/mo_overview/mrp_mo_overview.js b/addons/mrp/static/src/components/mo_overview/mrp_mo_overview.js new file mode 100644 index 00000000000..537eaa09d9c --- /dev/null +++ b/addons/mrp/static/src/components/mo_overview/mrp_mo_overview.js @@ -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); diff --git a/addons/mrp/static/src/components/mo_overview/mrp_mo_overview.xml b/addons/mrp/static/src/components/mo_overview/mrp_mo_overview.xml new file mode 100644 index 00000000000..150764ddcd6 --- /dev/null +++ b/addons/mrp/static/src/components/mo_overview/mrp_mo_overview.xml @@ -0,0 +1,56 @@ + + + +
+ + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + +
+ StatusQuantityFree to use / On HandReservedReceiptMO CostProduct Cost
Unit Cost + + + + + + + +
+
+
+
+
+ +
diff --git a/addons/mrp/static/src/components/mo_overview_components_block/mrp_mo_overview_components_block.js b/addons/mrp/static/src/components/mo_overview_components_block/mrp_mo_overview_components_block.js new file mode 100644 index 00000000000..3cecbe9471b --- /dev/null +++ b/addons/mrp/static/src/components/mo_overview_components_block/mrp_mo_overview_components_block.js @@ -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, +}; diff --git a/addons/mrp/static/src/components/mo_overview_components_block/mrp_mo_overview_components_block.xml b/addons/mrp/static/src/components/mo_overview_components_block/mrp_mo_overview_components_block.xml new file mode 100644 index 00000000000..27a6f8cbdcb --- /dev/null +++ b/addons/mrp/static/src/components/mo_overview_components_block/mrp_mo_overview_components_block.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + diff --git a/addons/mrp/static/src/components/mo_overview_display_filter/mrp_mo_overview_display_filter.js b/addons/mrp/static/src/components/mo_overview_display_filter/mrp_mo_overview_display_filter.js new file mode 100644 index 00000000000..4a7ee18b186 --- /dev/null +++ b/addons/mrp/static/src/components/mo_overview_display_filter/mrp_mo_overview_display_filter.js @@ -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, +}; diff --git a/addons/mrp/static/src/components/mo_overview_line/mo_overview_colors.js b/addons/mrp/static/src/components/mo_overview_line/mo_overview_colors.js new file mode 100644 index 00000000000..2cd2bf6464f --- /dev/null +++ b/addons/mrp/static/src/components/mo_overview_line/mo_overview_colors.js @@ -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]}` : ""; +} diff --git a/addons/mrp/static/src/components/mo_overview_line/mrp_mo_overview_line.js b/addons/mrp/static/src/components/mo_overview_line/mrp_mo_overview_line.js new file mode 100644 index 00000000000..d4dd519e52c --- /dev/null +++ b/addons/mrp/static/src/components/mo_overview_line/mrp_mo_overview_line.js @@ -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 }, +}; diff --git a/addons/mrp/static/src/components/mo_overview_line/mrp_mo_overview_line.scss b/addons/mrp/static/src/components/mo_overview_line/mrp_mo_overview_line.scss new file mode 100644 index 00000000000..846c6dd9879 --- /dev/null +++ b/addons/mrp/static/src/components/mo_overview_line/mrp_mo_overview_line.scss @@ -0,0 +1,3 @@ +.o_mrp_overview_badge { + font-size: $badge-font-size * 1.1; +} diff --git a/addons/mrp/static/src/components/mo_overview_line/mrp_mo_overview_line.xml b/addons/mrp/static/src/components/mo_overview_line/mrp_mo_overview_line.xml new file mode 100644 index 00000000000..a1324574133 --- /dev/null +++ b/addons/mrp/static/src/components/mo_overview_line/mrp_mo_overview_line.xml @@ -0,0 +1,44 @@ + + + + + + + + +
+ +
+ + + + + + / + + + + + + + + + +
+ + + + + + + + +
diff --git a/addons/mrp/static/src/components/mo_overview_operations_block/mrp_mo_overview_operations_block.js b/addons/mrp/static/src/components/mo_overview_operations_block/mrp_mo_overview_operations_block.js new file mode 100644 index 00000000000..c6cdd86fadd --- /dev/null +++ b/addons/mrp/static/src/components/mo_overview_operations_block/mrp_mo_overview_operations_block.js @@ -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, +}; diff --git a/addons/mrp/static/src/components/mo_overview_operations_block/mrp_mo_overview_operations_block.xml b/addons/mrp/static/src/components/mo_overview_operations_block/mrp_mo_overview_operations_block.xml new file mode 100644 index 00000000000..a90bd26f294 --- /dev/null +++ b/addons/mrp/static/src/components/mo_overview_operations_block/mrp_mo_overview_operations_block.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml index 29985fce59f..c478cdf1ad7 100644 --- a/addons/mrp/views/mrp_production_views.xml +++ b/addons/mrp/views/mrp_production_views.xml @@ -2,6 +2,12 @@ + + MO Overview + mrp_mo_overview + + + mrp.production.tree mrp.production @@ -181,6 +187,7 @@