# Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import fields, models from odoo.tools import SQL class MrpReport(models.Model): _name = 'mrp.report' _description = "Manufacturing Report" _rec_name = 'production_id' _auto = False _order = 'date_finished desc' id = fields.Id(string="") company_id = fields.Many2one('res.company', 'Company', readonly=True) currency_id = fields.Many2one('res.currency', 'Currency', readonly=True, required=True) production_id = fields.Many2one('mrp.production', "Manufacturing Order", readonly=True) date_finished = fields.Datetime('End Date', readonly=True) product_id = fields.Many2one('product.product', "Product", readonly=True) total_cost = fields.Monetary( "Total Cost", readonly=True, help="Total cost of manufacturing order (component + operation costs)") component_cost = fields.Monetary( "Total Component Cost", readonly=True, help="Total cost of components for manufacturing order") operation_cost = fields.Monetary( "Total Operation Cost", readonly=True, groups="mrp.group_mrp_routings", help="Total cost of operations for manufacturing order") duration = fields.Float( "Total Duration of Operations", readonly=True, groups="mrp.group_mrp_routings", help="Total duration (minutes) of operations for manufacturing order") qty_produced = fields.Float( "Quantity Produced", readonly=True, help="Total quantity produced in product's UoM") qty_demanded = fields.Float( "Quantity Demanded", readonly=True, help="Total quantity demanded in product's UoM") yield_rate = fields.Float( "Yield Percentage(%)", readonly=True, help="Ratio of quantity produced over quantity demanded") # note that unit costs take include subtraction of byproduct cost share unit_cost = fields.Monetary( "Cost / Unit", readonly=True, aggregator="avg", help="Cost per unit produced (in product UoM) of manufacturing order") unit_component_cost = fields.Monetary( "Component Cost / Unit", readonly=True, aggregator="avg", help="Component cost per unit produced (in product UoM) of manufacturing order") unit_operation_cost = fields.Monetary( "Total Operation Cost / Unit", readonly=True, aggregator="avg", groups="mrp.group_mrp_routings", help="Operation cost per unit produced (in product UoM) of manufacturing order") unit_duration = fields.Float( "Duration of Operations / Unit", readonly=True, aggregator="avg", groups="mrp.group_mrp_routings", help="Operation duration (minutes) per unit produced of manufacturing order") byproduct_cost = fields.Monetary( "By-Products Total Cost", readonly=True, groups="mrp.group_mrp_byproducts") expected_component_cost_unit = fields.Monetary( "Expected Component Cost / Unit", readonly=True, aggregator="avg", ) expected_employee_cost_unit = fields.Monetary( "Expected Employee Cost / Unit", readonly=True, aggregator="avg", groups="mrp.group_mrp_routings" ) expected_operation_cost_unit = fields.Monetary( "Expected Operation Cost / Unit", readonly=True, aggregator="avg", groups="mrp.group_mrp_routings" ) expected_total_cost_unit = fields.Monetary( "Expected Total Cost / Unit", readonly=True, aggregator="avg", groups="mrp.group_mrp_routings" ) @property def _table_query(self): ''' Report needs to be dynamic to take into account multi-company selected + multi-currency rates ''' return '%s %s %s %s' % (self._select(), self._from(), self._where(), self._group_by()) def _select_total_cost(self): return "comp_cost.total + op_cost.total" def _select(self): select_str = f""" SELECT min(mo.id) AS id, mo.id AS production_id, mo.company_id AS company_id, rc.currency_id AS currency_id, mo.date_finished AS date_finished, mo.product_id AS product_id, prod_qty.product_qty AS qty_produced, prod_qty.qty_demanded AS qty_demanded, prod_qty.product_qty / prod_qty.qty_demanded * 100 AS yield_rate, comp_cost.total * account_currency_table.rate AS component_cost, op_cost.total * account_currency_table.rate AS operation_cost, ({self._select_total_cost()}) * account_currency_table.rate AS total_cost, op_cost.total_duration AS duration, comp_cost.total * (1 - cost_share.byproduct_cost_share) / prod_qty.product_qty * account_currency_table.rate AS unit_component_cost, op_cost.total * (1 - cost_share.byproduct_cost_share) / prod_qty.product_qty * account_currency_table.rate AS unit_operation_cost, ({self._select_total_cost()}) * (1 - cost_share.byproduct_cost_share) / prod_qty.product_qty * account_currency_table.rate AS unit_cost, op_cost.total_duration / prod_qty.product_qty AS unit_duration, ({self._select_total_cost()}) * cost_share.byproduct_cost_share * account_currency_table.rate AS byproduct_cost {self._expected_component_cost()} {self._expected_employee_cost()} {self._expected_operation_cost()} {self._expected_total_cost()} """ return select_str def _expected_component_cost(self): return """ , 0 AS expected_component_cost_unit """ def _expected_employee_cost(self): return """ , 0 AS expected_employee_cost_unit """ def _expected_operation_cost(self): return """ , 0 AS expected_operation_cost_unit """ def _expected_total_cost(self): return """ , 0 AS expected_total_cost_unit """ def _from(self): """ MO costs are quite complicated so the table is built with the following subqueries (per MO): 1. total component cost (note we cover no components use case) 2. total operations cost (note we cover no operations use case) 3. total byproducts cost share 4. total qty produced based on the product's UoM Note subqueries 3 and 4 exist because 3 subqueries use the stock_move table and combining them would result in duplicated SVL values and subquery 2 (i.e. the nested subquery) exists to prevent duplication of operation costs (i.e. 2+ comp lines and 2+ operations at diff wc in the same MO results in op cost duplication if op cost isn't aggregated first). Subqueries will return 0.0 as value whenever value IS NULL to prevent SELECT calculations from being nulled (e.g. there is no cost then it is mathematically 0 anyways). """ currency_table = self.env['res.currency']._get_simple_currency_table(self.env.companies) from_str = """ FROM mrp_production AS mo JOIN res_company AS rc ON rc.id = {company_id} {comp_cost} {op_cost} {byproducts_cost} {total_produced} LEFT JOIN {currency_table} ON account_currency_table.company_id = mo.company_id {exp_comp_cost_unit} """.format( currency_table=self.env.cr.mogrify(currency_table).decode(self.env.cr.connection.encoding), company_id=int(self.env.company.id), comp_cost=self._join_component_cost(), op_cost=self._join_operations_cost(), byproducts_cost=self._join_byproducts_cost_share(), total_produced=self._join_total_qty_produced(), exp_comp_cost_unit=self._join_expected_component_cost_unit() ) return from_str def _join_expected_component_cost_unit(self): cr = self.env.cr standard_price_sql = self.env['product.product']._field_to_sql('product', 'standard_price') return f""" LEFT JOIN ( SELECT SUM( {cr.mogrify(standard_price_sql).decode(cr.connection.encoding)} * bom_line.product_qty ) / bom.product_qty AS value, MIN(mo.id) AS mo_id FROM mrp_production AS mo JOIN mrp_bom AS bom ON bom.id = mo.bom_id JOIN mrp_bom_line AS bom_line ON bom_line.bom_id = mo.bom_id JOIN product_product AS product ON product.id = bom_line.product_id WHERE mo.state = 'done' GROUP BY mo.id, bom.product_qty ) product_standard_price ON product_standard_price.mo_id = mo.id """ def _join_component_cost(self): return """ LEFT JOIN ( SELECT mo.id AS mo_id, SUM(sm.value) AS total FROM mrp_production AS mo LEFT JOIN stock_move AS sm on sm.raw_material_production_id = mo.id LEFT JOIN stock_location AS dest_loc ON sm.location_dest_id = dest_loc.id WHERE mo.state = 'done' AND (sm.state = 'done' or sm.state IS NULL) AND dest_loc.usage != 'inventory' GROUP BY mo.id ) comp_cost ON comp_cost.mo_id = mo.id """ def _join_operations_cost(self): return """ LEFT JOIN ( SELECT mo_id AS mo_id, SUM(op_costs_hour / 60. * op_duration) AS total, SUM(op_duration) AS total_duration FROM ( SELECT mo.id AS mo_id, CASE WHEN wo.costs_hour != 0.0 AND wo.costs_hour IS NOT NULL THEN wo.costs_hour ELSE COALESCE(wc.costs_hour, 0.0) END AS op_costs_hour, CASE WHEN wo.duration_expected != 0.0 AND wo.duration_expected IS NOT NULL AND wo.cost_mode = 'estimated' THEN wo.duration_expected ELSE COALESCE(SUM(t.duration), 0.0) END AS op_duration FROM mrp_production AS mo LEFT JOIN mrp_workorder wo ON wo.production_id = mo.id LEFT JOIN mrp_workcenter_productivity t ON t.workorder_id = wo.id LEFT JOIN mrp_workcenter wc ON wc.id = t.workcenter_id WHERE mo.state = 'done' GROUP BY mo.id, wc.costs_hour, wo.id ) AS op_cost_vars GROUP BY mo_id ) op_cost ON op_cost.mo_id = mo.id """ def _join_byproducts_cost_share(self): return """ LEFT JOIN ( SELECT mo.id AS mo_id, COALESCE(SUM(sm.cost_share), 0.0) / 100.0 AS byproduct_cost_share FROM stock_move AS sm LEFT JOIN mrp_production AS mo ON sm.production_id = mo.id LEFT JOIN stock_location AS dest_loc ON sm.location_dest_id = dest_loc.id WHERE mo.state = 'done' AND sm.state = 'done' AND sm.quantity != 0 AND dest_loc.usage != 'inventory' GROUP BY mo.id ) cost_share ON cost_share.mo_id = mo.id """ def _join_total_qty_produced(self): return """ LEFT JOIN ( SELECT mo.id AS mo_id, mo.name, SUM(sm.quantity * uom.factor / uom_prod.factor) AS product_qty, SUM(sm.product_uom_qty * uom.factor / uom_prod.factor) AS qty_demanded FROM stock_move AS sm JOIN mrp_production AS mo ON sm.production_id = mo.id JOIN uom_uom AS uom ON uom.id = sm.product_uom JOIN product_product AS product ON product.id = sm.product_id JOIN product_template AS template ON template.id = product.product_tmpl_id JOIN uom_uom AS uom_prod ON uom_prod.id = template.uom_id LEFT JOIN stock_location AS dest_loc ON sm.location_dest_id = dest_loc.id WHERE mo.state = 'done' AND sm.state = 'done' AND sm.quantity != 0 AND mo.product_id = sm.product_id AND dest_loc.usage != 'inventory' GROUP BY mo.id ) prod_qty ON prod_qty.mo_id = mo.id """ def _where(self): where_str = """ WHERE mo.state = 'done' """ return where_str def _group_by(self): group_by_str = """ GROUP BY mo.id, rc.currency_id, cost_share.byproduct_cost_share, comp_cost.total, op_cost.total, op_cost.total_duration, prod_qty.product_qty, prod_qty.qty_demanded, account_currency_table.rate """ return group_by_str def _read_group_select(self, aggregate_spec, query): if aggregate_spec in ('unit_cost:avg', 'unit_component_cost:avg', 'unit_operation_cost:avg', 'unit_duration:avg'): # Make a weigthed average instead of simple average for these fields fname, *__ = models.parse_read_group_spec(aggregate_spec) sql_field = self._field_to_sql(self._table, fname, query) sql_qty_produced = self._field_to_sql(self._table, 'qty_produced', query) return SQL("SUM(%s * %s) / SUM(%s)", sql_field, sql_qty_produced, sql_qty_produced) if aggregate_spec == 'yield_rate:sum': sql_qty_produced = self._field_to_sql(self._table, 'qty_produced', query) sql_qty_demanded = self._field_to_sql(self._table, 'qty_demanded', query) return SQL("SUM(%s) / SUM(%s) * 100", sql_qty_produced, sql_qty_demanded) return super()._read_group_select(aggregate_spec, query)