diff --git a/addons/mrp/report/mrp_report_mo_overview.py b/addons/mrp/report/mrp_report_mo_overview.py index 27343f4c6af..5a9051ef62d 100644 --- a/addons/mrp/report/mrp_report_mo_overview.py +++ b/addons/mrp/report/mrp_report_mo_overview.py @@ -49,6 +49,9 @@ class ReportMoOverview(models.AbstractModel): doc['show_receipts'] = data.get('receipts') == '1' if doc['show_receipts']: footer_colspan += 1 + doc['show_unit_costs'] = data.get('unitCosts') == '1' + if doc['show_unit_costs']: + footer_colspan += 1 doc['show_mo_costs'] = data.get('moCosts') == '1' doc['show_real_costs'] = data.get('realCosts') == '1' doc['show_uom'] = self.env.user.user_has_groups('uom.group_uom') @@ -76,6 +79,7 @@ class ReportMoOverview(models.AbstractModel): initial_mo_cost, initial_real_cost = self._compute_cost_sums(components, operations) remaining_cost_share, byproducts = self._get_byproducts_data(production, initial_mo_cost, initial_real_cost, level=1, current_index='') summary = self._get_mo_summary(production, components, initial_mo_cost, initial_real_cost, remaining_cost_share) + extra_lines = self._get_report_extra_lines(summary, components, operations, production.state == 'done') return { 'id': production.id, 'name': production.display_name, @@ -83,18 +87,77 @@ class ReportMoOverview(models.AbstractModel): 'components': components, 'operations': operations, 'byproducts': byproducts, - 'extras': self._get_report_extra_lines(summary), + 'extras': extra_lines, + 'cost_breakdown': self._get_cost_breakdown_data(production, extra_lines, remaining_cost_share), } - def _get_report_extra_lines(self, summary): - currency = summary.get('currency', self.env.company) + def _get_report_extra_lines(self, summary, components, operations, production_done=False): + currency = summary.get('currency', self.env.company.currency_id) unit_mo_cost = currency.round(summary.get('mo_cost', 0) / summary.get('quantity', 1)) unit_real_cost = currency.round(summary.get('real_cost', 0) / summary.get('quantity', 1)) - return { + extras = { 'unit_mo_cost': unit_mo_cost, 'unit_mo_cost_decorator': self._get_comparison_decorator(unit_real_cost, unit_mo_cost, currency.rounding), 'unit_real_cost': unit_real_cost, } + if production_done: + production_qty = summary.get('quantity', 1.0) + extras['total_mo_cost_components'] = sum(compo.get('summary', {}).get('mo_cost', 0.0) for compo in components) + extras['total_real_cost_components'] = sum(compo.get('summary', {}).get('real_cost', 0.0) for compo in components) + extras['total_mo_cost_components_decorator'] = self._get_comparison_decorator(extras['total_real_cost_components'], extras['total_mo_cost_components'], currency.rounding) + extras['unit_mo_cost_components'] = extras['total_mo_cost_components'] / production_qty + extras['unit_real_cost_components'] = extras['total_real_cost_components'] / production_qty + extras['total_mo_cost_operations'] = operations.get('summary', {}).get('mo_cost', 0.0) + extras['total_real_cost_operations'] = operations.get('summary', {}).get('real_cost', 0.0) + extras['total_mo_cost_operations_decorator'] = self._get_comparison_decorator(extras['total_real_cost_operations'], extras['total_mo_cost_operations'], currency.rounding) + extras['unit_mo_cost_operations'] = extras['total_mo_cost_operations'] / production_qty + extras['unit_real_cost_operations'] = extras['total_real_cost_operations'] / production_qty + extras['total_mo_cost'] = extras['total_mo_cost_components'] + extras['total_mo_cost_operations'] + extras['total_real_cost'] = extras['total_real_cost_components'] + extras['total_real_cost_operations'] + extras['total_mo_cost_decorator'] = self._get_comparison_decorator(extras['total_real_cost'], extras['total_mo_cost'], currency.rounding) + return extras + + def _get_cost_breakdown_data(self, production, extras, remaining_cost_share): + if production.state != 'done' or not production.move_byproduct_ids: + return [] + + # Prepare byproducts data + quantities_by_product = defaultdict(float) + total_cost_by_product = defaultdict(float) + component_cost_by_product = defaultdict(float) + operation_cost_by_product = defaultdict(float) + for bp_move in production.move_byproduct_ids: + # Byproducts without cost share are irrelevant in a cost breakdrown. + if bp_move.state == 'cancel' or float_is_zero(bp_move.cost_share, precision_digits=2): + continue + # As UoMs can vary, we use the default UoM of each product + quantities_by_product[bp_move.product_id] += bp_move.product_qty + cost_share = bp_move.cost_share / 100 + total_cost_by_product[bp_move.product_id] += extras['total_real_cost'] * cost_share + component_cost_by_product[bp_move.product_id] += extras['total_real_cost_components'] * cost_share + operation_cost_by_product[bp_move.product_id] += extras['total_real_cost_operations'] * cost_share + + # Add finished product to its own default UoM (not the production UoM) + breakdown_lines = [self._format_cost_breakdown_lines(0, production.product_id.display_name, production.product_id.uom_id.display_name, + (extras['total_real_cost_components'] * remaining_cost_share) / production.product_uom_qty, + (extras['total_real_cost_operations'] * remaining_cost_share) / production.product_uom_qty, + (extras['total_real_cost'] * remaining_cost_share) / production.product_uom_qty)] + for index, product in enumerate(quantities_by_product.keys()): + breakdown_lines.append(self._format_cost_breakdown_lines(index + 1, product.display_name, product.uom_id.display_name, + component_cost_by_product[product] / quantities_by_product[product], + operation_cost_by_product[product] / quantities_by_product[product], + total_cost_by_product[product] / quantities_by_product[product])) + return breakdown_lines + + def _format_cost_breakdown_lines(self, index, product_name, uom_name, component_cost, operation_cost, total_cost): + return { + 'index': f"BR{index}", + 'name': product_name, + 'unit_avg_cost_component': component_cost, + 'unit_avg_cost_operation': operation_cost, + 'unit_avg_total_cost': total_cost, + 'uom_name': uom_name, + } def _get_mo_summary(self, production, components, current_mo_cost, current_real_cost, remaining_cost_share): currency = (production.company_id or self.env.company).currency_id @@ -117,6 +180,7 @@ class ReportMoOverview(models.AbstractModel): '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_deadline, self._get_replenishment_receipt(production, components)), + 'unit_cost': self._get_unit_cost(production.move_finished_ids.filtered(lambda m: m.product_id == production.product_id)), 'mo_cost': currency.round(mo_cost), 'mo_cost_decorator': self._get_comparison_decorator(real_cost, mo_cost, currency.rounding), 'real_cost': currency.round(real_cost), @@ -124,6 +188,9 @@ class ReportMoOverview(models.AbstractModel): 'currency': currency, } + def _get_unit_cost(self, move): + return move.product_id.uom_id._compute_price(move.product_id.standard_price, move.product_uom) + def _format_state(self, record): if record._name != 'mrp.production' or record.state not in ('draft', 'confirmed') or not record.move_raw_ids: return dict(record._fields['state']._description_selection(self.env)).get(record.state) @@ -165,6 +232,8 @@ class ReportMoOverview(models.AbstractModel): return 'success' def _get_operations_data(self, production, level=0, current_index=False): + if production.state == "done": + return self._get_finished_operation_data(production, level, current_index) currency = (production.company_id or self.env.company).currency_id operation_uom = _("Minutes") operations = [] @@ -202,6 +271,7 @@ class ReportMoOverview(models.AbstractModel): 'quantity_decorator': self._get_comparison_decorator(workorder.duration_expected, wo_duration, 0.01), 'uom_name': operation_uom, 'production_id': production.id, + 'unit_cost': expected_cost / workorder.duration_expected, 'mo_cost': mo_cost, 'real_cost': real_cost, 'currency_id': currency.id, @@ -226,6 +296,46 @@ class ReportMoOverview(models.AbstractModel): 'details': operations, } + def _get_finished_operation_data(self, production, level=0, current_index=False): + currency = (production.company_id or self.env.company).currency_id + done_operation_uom = _("Hours") + operations = [] + total_duration = total_duration_expected = total_cost = 0 + for index, workorder in enumerate(production.workorder_ids): + hourly_cost = workorder.costs_hour or workorder.workcenter_id.costs_hour + duration = workorder.get_duration() / 60 + total_duration += duration + total_duration_expected += workorder.duration_expected + operation_cost = duration * hourly_cost + total_cost += operation_cost + operations.append({ + 'level': level, + 'index': f"{current_index}W{index}", + 'name': f"{workorder.workcenter_id.display_name}: {workorder.display_name}", + 'quantity': duration, + 'uom_name': done_operation_uom, + 'uom_precision': 4, + 'unit_cost': hourly_cost, + 'mo_cost': currency.round(operation_cost), + 'real_cost': currency.round(operation_cost), + 'currency_id': currency.id, + 'currency': currency, + }) + return { + 'summary': { + 'index': f"{current_index}W", + 'done': True, + 'quantity': total_duration, + 'quantity_decorator': self._get_comparison_decorator(total_duration_expected, total_duration, 0.01), + 'mo_cost': total_cost, + 'real_cost': total_cost, + 'uom_name': done_operation_uom, + 'currency_id': currency.id, + 'currency': currency, + }, + 'details': operations, + } + def _get_byproducts_data(self, production, current_mo_cost, current_real_cost, level=0, current_index=False): currency = (production.company_id or self.env.company).currency_id byproducts = [] @@ -249,6 +359,7 @@ class ReportMoOverview(models.AbstractModel): 'quantity': move_bp.product_uom_qty, 'uom_name': move_bp.product_uom.display_name, 'uom_precision': self._get_uom_precision(move_bp.product_uom.rounding), + 'unit_cost': self._get_unit_cost(move_bp), 'mo_cost': currency.round(mo_cost), 'mo_cost_decorator': self._get_comparison_decorator(real_cost, mo_cost, currency.rounding), 'real_cost': currency.round(real_cost), @@ -328,6 +439,7 @@ class ReportMoOverview(models.AbstractModel): '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_start, self._get_component_receipt(product, move_raw, production.warehouse_id, replenishments, replenish_data)), + 'unit_cost': self._get_unit_cost(move_raw), 'mo_cost': currency.round(replenish_mo_cost + missing_quantity_cost), 'real_cost': currency.round(self._get_component_real_cost(move_raw, move_raw.product_uom_qty)), 'currency_id': currency.id, @@ -346,8 +458,7 @@ class ReportMoOverview(models.AbstractModel): def _get_component_real_cost(self, move_raw, quantity): if float_is_zero(quantity, precision_rounding=move_raw.product_uom.rounding): return 0 - product = move_raw.product_id - return product.standard_price * move_raw.product_uom._compute_quantity(quantity, product.uom_id) + return self._get_unit_cost(move_raw) * quantity def _check_planned_start(self, mo_planned_start, receipt): if mo_planned_start and receipt.get('date', False) and receipt['date'] > mo_planned_start: @@ -413,8 +524,9 @@ class ReportMoOverview(models.AbstractModel): 'quantity': line_quantity, 'uom_name': move_raw.product_uom.display_name, 'uom_precision': self._get_uom_precision(forecast_line['uom_id']['rounding']), + 'unit_cost': self._get_unit_cost(move_raw), 'mo_cost': forecast_line.get('cost', self._get_replenishment_mo_cost(product, line_quantity, move_raw.product_uom, currency, forecast_line.get('move_in'))), - 'real_cost': currency.round(move_raw.product_uom._compute_quantity(line_quantity, product.uom_id) * product.standard_price), + 'real_cost': currency.round(self._get_component_real_cost(move_raw, line_quantity)), 'currency_id': currency.id, 'currency': currency, } @@ -474,6 +586,7 @@ class ReportMoOverview(models.AbstractModel): else: to_order_line['summary']['mo_cost'] = currency.round(product.standard_price * move_raw.product_uom._compute_quantity(missing_quantity, product.uom_id)) to_order_line['summary']['receipt'] = self._format_receipt_date('unavailable') + to_order_line['summary']['unit_cost'] = currency.round(to_order_line['summary']['mo_cost'] / missing_quantity) to_order_line['summary']['mo_cost_decorator'] = self._get_comparison_decorator(to_order_line['summary']['real_cost'], to_order_line['summary']['mo_cost'], currency.rounding) replenishments.append(to_order_line) diff --git a/addons/mrp/report/mrp_report_mo_overview.xml b/addons/mrp/report/mrp_report_mo_overview.xml index 28e8fdc8755..92155369a74 100644 --- a/addons/mrp/report/mrp_report_mo_overview.xml +++ b/addons/mrp/report/mrp_report_mo_overview.xml @@ -25,6 +25,7 @@ Free to use / On Hand Reserved Receipt + Unit Cost MO Cost Real Cost @@ -46,7 +47,83 @@ + + + Total Cost of Components + + + + + + + + + + Cost of Components per unit + (in ) + + + + + + + + + + Total Cost of Operations + + + + + + + + + + Cost of Operations per unit + (in ) + + + + + + + + + + Total Cost of Production + + + + + + + + + +

Cost Breakdown of Products

+ + + + + + + + + + + + + + +
ProductAvg Cost of Components per UnitAvg Cost of Operations per UnitAvg Total Cost per UnitUnit of Measure
+ + + + +
+
@@ -73,6 +150,9 @@ + + + @@ -119,13 +199,14 @@ - - - + + + + @@ -154,6 +235,7 @@ + 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 index a05f94fe37b..0184f0192dc 100644 --- a/addons/mrp/static/src/components/mo_overview/mrp_mo_overview.js +++ b/addons/mrp/static/src/components/mo_overview/mrp_mo_overview.js @@ -21,7 +21,6 @@ export class MoOverview extends Component { this.state = useState({ data: {}, - isDone: false, showOptions: this.getDefaultConfig(), }); @@ -41,12 +40,12 @@ export class MoOverview extends Component { [this.activeId], ); this.state.data = reportValues.data; - this.state.isDone = ['done', 'cancel'].some(doneState => reportValues.data?.summary?.state === doneState); - if (this.state.isDone) { + if (this.isProductionDone) { // Hide Availabilities / Receipts / Status columns when the MO is done. this.state.showOptions.availabilities = false; this.state.showOptions.receipts = false; this.state.showOptions.replenishments = false; + this.state.showOptions.unitCosts = true; } this.state.showOptions.uom = reportValues.context.show_uom; this.context = reportValues.context; @@ -92,6 +91,7 @@ export class MoOverview extends Component { replenishments: true, availabilities: true, receipts: true, + unitCosts: false, moCosts: true, realCosts: true, }; @@ -127,6 +127,10 @@ export class MoOverview extends Component { return this.state.showOptions.receipts; } + get showUnitCosts() { + return this.state.showOptions.unitCosts; + } + get showMoCosts() { return this.state.showOptions.moCosts; } @@ -139,12 +143,21 @@ export class MoOverview extends Component { return this.state.data?.summary?.state === "done"; } + get hasOperations() { + return this.state.data?.operations?.details?.length > 0; + } + + get hasBreakdown() { + return this.state.data?.cost_breakdown?.length > 0; + } + get totalColspan() { let colspan = 2; // Name & Quantity if (this.showReplenishments) colspan++; if (this.showAvailabilities) colspan += 2; // Free to use / On Hand & Reserved if (this.showUom) colspan++; if (this.showReceipts) colspan++; + if (this.showUnitCosts) colspan++; return colspan; } @@ -153,6 +166,7 @@ export class MoOverview extends Component { + `&replenishments=${+this.state.showOptions.replenishments}` + `&availabilities=${+this.state.showOptions.availabilities}` + `&receipts=${+this.state.showOptions.receipts}` + + `&unitCosts=${+this.state.showOptions.unitCosts}` + `&moCosts=${+this.state.showOptions.moCosts}` + `&realCosts=${+this.state.showOptions.realCosts}` + `&unfoldedIds=${JSON.stringify(Array.from(this.unfoldedIds))}`; 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 index 64a29168660..366d1ad3580 100644 --- a/addons/mrp/static/src/components/mo_overview/mrp_mo_overview.xml +++ b/addons/mrp/static/src/components/mo_overview/mrp_mo_overview.xml @@ -4,7 +4,7 @@
- + @@ -24,6 +24,7 @@ Free to use / On Hand Reserved Receipt + Unit Cost MO Cost Real Cost @@ -42,8 +43,65 @@ + + + Total Cost of Components + + + + + + Cost of Components per unit + (in ) + + + + + + Total Cost of Operations + + + + + + Cost of Operations per unit + (in ) + + + + + + Total Cost of Production + + + + + +
+

Cost Breakdown of Products

+ + + + + + + + + + + + + + +
ProductAvg Cost of Components per UnitAvg Cost of Operations per UnitAvg Total Cost per UnitUnit of Measure
+ + + + +
+
diff --git a/addons/mrp/static/src/components/mo_overview_byproducts_block/mrp_mo_overview_byproducts_block.xml b/addons/mrp/static/src/components/mo_overview_byproducts_block/mrp_mo_overview_byproducts_block.xml index a1c52d59f5c..987730c60d1 100644 --- a/addons/mrp/static/src/components/mo_overview_byproducts_block/mrp_mo_overview_byproducts_block.xml +++ b/addons/mrp/static/src/components/mo_overview_byproducts_block/mrp_mo_overview_byproducts_block.xml @@ -17,6 +17,7 @@ + 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 index a41d2fe9350..dff4e2e0527 100644 --- 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 @@ -10,6 +10,7 @@ export const SHOW_OPTIONS = { replenishments: Boolean, availabilities: Boolean, receipts: Boolean, + unitCosts: Boolean, moCosts: Boolean, realCosts: Boolean, }, @@ -26,6 +27,7 @@ export class MoOverviewDisplayFilter extends BomOverviewDisplayFilter { } this.displayOptions = { ...(this.displayOptions || {}), + unitCosts: _t("Unit Costs"), moCosts: _t("MO Costs"), realCosts: _t("Real Costs"), }; 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 index 60756986a4c..942ac3fe96e 100644 --- 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 @@ -158,6 +158,7 @@ MoOverviewLine.props = { }, optional: true, }, + unit_cost: { type: Number, optional: true }, mo_cost: Number, mo_cost_decorator: { type: [String, Boolean], optional: true }, real_cost: Number, 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 index 722db97f07b..8713e590a53 100644 --- 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 @@ -35,6 +35,7 @@
+ 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 index 278200fb5e1..085497fbebc 100644 --- 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 @@ -2,7 +2,7 @@ import { Component, useState } from "@odoo/owl"; import { useBus } from "@web/core/utils/hooks"; -import { formatFloatTime, formatMonetary } from "@web/views/fields/formatters"; +import { formatFloat, formatFloatTime, formatMonetary } from "@web/views/fields/formatters"; import { MoOverviewLine } from "../mo_overview_line/mrp_mo_overview_line"; import { SHOW_OPTIONS } from "../mo_overview_display_filter/mrp_mo_overview_display_filter"; @@ -57,6 +57,13 @@ export class MoOverviewOperationsBlock extends Component { get index() { return this.props.summary.index; } + + get totalQuantity() { + // Float for Hours when displaying done productions, FloatTime for Minutes otherwise. + return this.props.summary?.done ? + formatFloat(this.props.summary.quantity, { digits: [false, this.props.operations[0].uom_precision || undefined] }) : + formatFloatTime(this.props.summary.quantity) + } } MoOverviewOperationsBlock.components = { @@ -77,6 +84,7 @@ MoOverviewOperationsBlock.props = { uom_name: { type: String, optional: true }, currency_id: { type: Number, optional: true }, currency: { type: String, optional: true }, + done: { type: Boolean, optional: true }, }, }, showOptions: SHOW_OPTIONS, 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 index 2ab7d406a90..cde612b3a0e 100644 --- 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 @@ -12,11 +12,12 @@ - + + diff --git a/addons/mrp_account/__init__.py b/addons/mrp_account/__init__.py index 274ebd30be4..77019e2f005 100644 --- a/addons/mrp_account/__init__.py +++ b/addons/mrp_account/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import models +from . import report def _configure_journals(env): diff --git a/addons/mrp_account/report/__init__.py b/addons/mrp_account/report/__init__.py new file mode 100644 index 00000000000..37b544e72ce --- /dev/null +++ b/addons/mrp_account/report/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import mrp_report_mo_overview diff --git a/addons/mrp_account/report/mrp_report_mo_overview.py b/addons/mrp_account/report/mrp_report_mo_overview.py new file mode 100644 index 00000000000..6008f8caf4f --- /dev/null +++ b/addons/mrp_account/report/mrp_report_mo_overview.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models +from odoo.tools import float_is_zero + +class ReportMoOverview(models.AbstractModel): + _inherit = 'report.mrp.report_mo_overview' + + def _get_unit_cost(self, move): + valuation_layers = move.sudo().stock_valuation_layer_ids + layers_quantity = sum(valuation_layers.mapped('quantity')) + if valuation_layers and not float_is_zero(layers_quantity, precision_rounding=valuation_layers.uom_id.rounding): + unit_price = sum(valuation_layers.mapped('value')) / layers_quantity + return move.product_id.uom_id._compute_price(unit_price, move.product_uom) + return super()._get_unit_cost(move)