From ba239e4cacf468705b012c68fb3b96ba400a0030 Mon Sep 17 00:00:00 2001 From: Arnold Moyaux Date: Tue, 30 Oct 2018 17:15:18 +0100 Subject: [PATCH] [FIX] mrp: subproduct price + rounding On the BoM structure report the price on line with a child BoM is inchoerent. Also the sum of all lines in the report is not always equals to the total of the parent line. For the first issue, it happens due to commit 17b625695521b9a1af75e0c10337d0d3076c8fce that use the number of operation cycle as quantity. The second issue happens because sometimes the rounding is not done at the smallest level and thus the rounding of the sum is different than the sum of the rouding. --- addons/mrp/report/mrp_report_bom_structure.py | 19 +- addons/mrp/tests/test_bom.py | 189 ++++++++++++++++++ 2 files changed, 203 insertions(+), 5 deletions(-) diff --git a/addons/mrp/report/mrp_report_bom_structure.py b/addons/mrp/report/mrp_report_bom_structure.py index 5734ce2a6b4..f7e8a0ea6a5 100644 --- a/addons/mrp/report/mrp_report_bom_structure.py +++ b/addons/mrp/report/mrp_report_bom_structure.py @@ -119,16 +119,17 @@ class ReportBomStructure(models.AbstractModel): continue price = line.product_id.uom_id._compute_price(line.product_id.standard_price, line.product_uom_id) * line_quantity if line.child_bom_id: - factor = float_round(line.product_uom_id._compute_quantity(line_quantity, line.child_bom_id.product_uom_id) / line.child_bom_id.product_qty, precision_rounding=1, rounding_method='UP') + factor = line.product_uom_id._compute_quantity(line_quantity, line.child_bom_id.product_uom_id) / line.child_bom_id.product_qty sub_total = self._get_price(line.child_bom_id, factor) else: sub_total = price + sub_total = self.env.user.company_id.currency_id.round(sub_total) components.append({ 'prod_id': line.product_id.id, 'prod_name': line.product_id.display_name, 'prod_qty': line_quantity, 'prod_uom': line.product_uom_id.name, - 'prod_cost': price, + 'prod_cost': self.env.user.company_id.currency_id.round(price), 'parent_id': bom.id, 'line_id': line.id, 'level': level or 0, @@ -151,14 +152,21 @@ class ReportBomStructure(models.AbstractModel): 'operation': operation, 'name': operation.name + ' - ' + operation.workcenter_id.name, 'duration_expected': duration_expected, - 'total': float_round(total, precision_rounding=self.env.user.company_id.currency_id.rounding), + 'total': self.env.user.company_id.currency_id.round(total), }) return operations def _get_price(self, bom, factor): price = 0 if bom.routing_id: - operations = self._get_operation_line(bom.routing_id, factor, 0) + # routing are defined on a BoM and don't have a concept of quantity. + # It means that the operation time are defined for the quantity on + # the BoM (the user produces a batch of products). E.g the user + # product a batch of 10 units with a 5 minutes operation, the time + # will be the 5 for a quantity between 1-10, then doubled for + # 11-20,... + operation_cycle = float_round(factor, precision_rounding=1, rounding_method='UP') + operations = self._get_operation_line(bom.routing_id, operation_cycle, 0) price += sum([op['total'] for op in operations]) for line in bom.bom_line_ids: @@ -168,7 +176,8 @@ class ReportBomStructure(models.AbstractModel): price += sub_price else: prod_qty = line.product_qty * factor - price += (line.product_id.uom_id._compute_price(line.product_id.standard_price, line.product_uom_id) * prod_qty) + not_rounded_price = line.product_id.uom_id._compute_price(line.product_id.standard_price, line.product_uom_id) * prod_qty + price += self.env.user.company_id.currency_id.round(not_rounded_price) return price def _get_pdf_line(self, bom_id, product_id=False, qty=1, child_bom_ids=[], unfolded=False): diff --git a/addons/mrp/tests/test_bom.py b/addons/mrp/tests/test_bom.py index 71421216411..8dcfb0cfb13 100644 --- a/addons/mrp/tests/test_bom.py +++ b/addons/mrp/tests/test_bom.py @@ -3,6 +3,8 @@ from odoo import exceptions from odoo.addons.mrp.tests.common import TestMrpCommon +from odoo.tests import Form +from odoo.tools import float_compare, float_round class TestBoM(TestMrpCommon): @@ -158,3 +160,190 @@ class TestBoM(TestMrpCommon): }) with self.assertRaises(exceptions.UserError): test_bom_3.explode(self.product_9, 1) + + def test_bom_report(self): + """ Simulate a crumble receipt with mrp and open the bom structure + report and check that data insde are correct. + """ + uom_kg = self.env.ref('uom.product_uom_kgm') + uom_litre = self.env.ref('uom.product_uom_litre') + crumble = self.env['product.product'].create({ + 'name': 'Crumble', + 'type': 'product', + 'uom_id': uom_kg.id, + 'uom_po_id': uom_kg.id, + }) + butter = self.env['product.product'].create({ + 'name': 'Butter', + 'type': 'product', + 'uom_id': uom_kg.id, + 'uom_po_id': uom_kg.id, + 'standard_price': 7.01 + }) + biscuit = self.env['product.product'].create({ + 'name': 'Biscuit', + 'type': 'product', + 'uom_id': uom_kg.id, + 'uom_po_id': uom_kg.id, + 'standard_price': 1.5 + }) + bom_form_crumble = Form(self.env['mrp.bom']) + bom_form_crumble.product_tmpl_id = crumble.product_tmpl_id + bom_form_crumble.product_qty = 11 + bom_form_crumble.product_uom_id = uom_kg + bom_crumble = bom_form_crumble.save() + + with Form(bom_crumble) as bom: + with bom.bom_line_ids.new() as line: + line.product_id = butter + line.product_uom_id = uom_kg + line.product_qty = 5 + with bom.bom_line_ids.new() as line: + line.product_id = biscuit + line.product_uom_id = uom_kg + line.product_qty = 6 + + workcenter = self.env['mrp.workcenter'].create({ + 'costs_hour': 10, + 'name': 'Deserts Table' + }) + + routing_form = Form(self.env['mrp.routing']) + routing_form.name = "Crumble process" + routing_crumble = routing_form.save() + + with Form(routing_crumble) as routing: + with routing.operation_ids.new() as operation: + operation.workcenter_id = workcenter + operation.name = 'Prepare biscuits' + operation.time_cycle_manual = 5 + with routing.operation_ids.new() as operation: + operation.workcenter_id = workcenter + operation.name = 'Prepare butter' + operation.time_cycle_manual = 3 + with routing.operation_ids.new() as operation: + operation.workcenter_id = workcenter + operation.name = 'Mix manually' + operation.time_cycle_manual = 5 + + bom_crumble.routing_id = routing_crumble.id + + # TEST BOM STRUCTURE VALUE WITH BOM QUANTITY + report_values = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_crumble.id, searchQty=11, searchVariant=False) + # 5 min 'Prepare biscuits' + 3 min 'Prepare butter' + 5 min 'Mix manually' = 13 minutes + self.assertEqual(report_values['lines']['operations_time'], 13.0, 'Operation time should be the same for 1 unit or for the batch') + # Operation cost is the sum of operation line. + operation_cost = float_round(5 / 60 * 10, precision_digits=2) * 2 + float_round(3 / 60 * 10, precision_digits=2) + self.assertEqual(float_compare(report_values['lines']['operations_cost'], operation_cost, precision_digits=2), 0, '13 minute for 10$/hours -> 2.16') + + for component_line in report_values['lines']['components']: + # standard price * bom line quantity * current quantity / bom finished product quantity + if component_line['prod_id'] == butter.id: + # 5 kg of butter at 7.01$ for 11kg of crumble -> 35.05$ + self.assertEqual(float_compare(component_line['total'], (7.01 * 5), precision_digits=2), 0) + if component_line['prod_id'] == biscuit.id: + # 6 kg of biscuits at 1.50$ for 11kg of crumble -> 9$ + self.assertEqual(float_compare(component_line['total'], (1.5 * 6), precision_digits=2), 0) + # total price = 35.05 + 9 + operation_cost(0.83 + 0.83 + 0.5 = 2.16) = 46,21 + self.assertEqual(float_compare(report_values['lines']['total'], 46.21, precision_digits=2), 0, 'Product Bom Price is not correct') + self.assertEqual(float_compare(report_values['lines']['total'] / 11.0, 4.20, precision_digits=2), 0, 'Product Unit Bom Price is not correct') + + # TEST BOM STRUCTURE VALUE BY UNIT + report_values = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_crumble.id, searchQty=1, searchVariant=False) + # 5 min 'Prepare biscuits' + 3 min 'Prepare butter' + 5 min 'Mix manually' = 13 minutes + self.assertEqual(report_values['lines']['operations_time'], 13.0, 'Operation time should be the same for 1 unit or for the batch') + # Operation cost is the sum of operation line. + operation_cost = float_round(5 / 60 * 10, precision_digits=2) * 2 + float_round(3 / 60 * 10, precision_digits=2) + self.assertEqual(float_compare(report_values['lines']['operations_cost'], operation_cost, precision_digits=2), 0, '13 minute for 10$/hours -> 2.16') + + for component_line in report_values['lines']['components']: + # standard price * bom line quantity * current quantity / bom finished product quantity + if component_line['prod_id'] == butter.id: + # 5 kg of butter at 7.01$ for 11kg of crumble -> / 11 for price per unit (3.19) + self.assertEqual(float_compare(component_line['total'], (7.01 * 5) * (1 / 11), precision_digits=2), 0) + if component_line['prod_id'] == biscuit.id: + # 6 kg of biscuits at 1.50$ for 11kg of crumble -> / 11 for price per unit (0.82) + self.assertEqual(float_compare(component_line['total'], (1.5 * 6) * (1 / 11), precision_digits=2), 0) + # total price = 3.19 + 0.82 + operation_cost(0.83 + 0.83 + 0.5 = 2.16) = 6,17 + self.assertEqual(float_compare(report_values['lines']['total'], 6.17, precision_digits=2), 0, 'Product Unit Bom Price is not correct') + + # TEST OPERATION COST WHEN PRODUCED QTY > BOM QUANTITY + report_values_12 = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_crumble.id, searchQty=12, searchVariant=False) + report_values_22 = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_crumble.id, searchQty=22, searchVariant=False) + operation_cost = float_round(10 / 60 * 10, precision_digits=2) * 2 + float_round(6 / 60 * 10, precision_digits=2) + # Both needs 2 operation cycle + self.assertEqual(report_values_12['lines']['operations_cost'], report_values_22['lines']['operations_cost']) + self.assertEqual(report_values_22['lines']['operations_cost'], operation_cost) + report_values_23 = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_crumble.id, searchQty=23, searchVariant=False) + operation_cost = float_round(15 / 60 * 10, precision_digits=2) * 2 + float_round(9 / 60 * 10, precision_digits=2) + self.assertEqual(report_values_23['lines']['operations_cost'], operation_cost) + + # Create a more complex BoM with a sub product + cheese_cake = self.env['product.product'].create({ + 'name': 'Cheese Cake 300g', + 'type': 'product', + }) + cream = self.env['product.product'].create({ + 'name': 'cream', + 'type': 'product', + 'uom_id': uom_litre.id, + 'uom_po_id': uom_litre.id, + 'standard_price': 5.17, + }) + bom_form_cheese_cake = Form(self.env['mrp.bom']) + bom_form_cheese_cake.product_tmpl_id = cheese_cake.product_tmpl_id + bom_form_cheese_cake.product_qty = 60 + bom_form_cheese_cake.product_uom_id = self.uom_unit + bom_cheese_cake = bom_form_cheese_cake.save() + + with Form(bom_cheese_cake) as bom: + with bom.bom_line_ids.new() as line: + line.product_id = cream + line.product_uom_id = uom_litre + line.product_qty = 3 + with bom.bom_line_ids.new() as line: + line.product_id = crumble + line.product_uom_id = uom_kg + line.product_qty = 5.4 + + workcenter_2 = self.env['mrp.workcenter'].create({ + 'name': 'cake mounting', + 'costs_hour': 20, + 'time_start': 10, + 'time_stop': 15 + }) + + routing_form = Form(self.env['mrp.routing']) + routing_form.name = "Cheese cake process" + routing_cheese = routing_form.save() + + with Form(routing_cheese) as routing: + with routing.operation_ids.new() as operation: + operation.workcenter_id = workcenter + operation.name = 'Mix cheese and crumble' + operation.time_cycle_manual = 10 + with routing.operation_ids.new() as operation: + operation.workcenter_id = workcenter_2 + operation.name = 'Cake mounting' + operation.time_cycle_manual = 5 + + bom_cheese_cake.routing_id = routing_cheese.id + + # TEST CHEESE BOM STRUCTURE VALUE WITH BOM QUANTITY + report_values = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_cheese_cake.id, searchQty=60, searchVariant=False) + self.assertEqual(report_values['lines']['operations_time'], 40.0, 'Operation time should be the same for 1 unit or for the batch') + # Operation cost is the sum of operation line. + operation_cost = float_round(10 / 60 * 10, precision_digits=2) + float_round(30 / 60 * 20, precision_digits=2) + self.assertEqual(float_compare(report_values['lines']['operations_cost'], operation_cost, precision_digits=2), 0) + + for component_line in report_values['lines']['components']: + # standard price * bom line quantity * current quantity / bom finished product quantity + if component_line['prod_id'] == cream.id: + # 3 liter of cream at 5.17$ for 60 unit of cheese cake -> 15.51$ + self.assertEqual(float_compare(component_line['total'], (3 * 5.17), precision_digits=2), 0) + if component_line['prod_id'] == crumble.id: + # 5.4 kg of crumble at the cost of a batch. + crumble_cost = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_crumble.id, searchQty=5.4, searchVariant=False)['lines']['total'] + self.assertEqual(float_compare(component_line['total'], crumble_cost, precision_digits=2), 0) + # total price = 15.51 + crumble_cost + operation_cost(10 + 1.67 = 11.67) = 27.18 + crumble_cost + self.assertEqual(float_compare(report_values['lines']['total'], 27.18 + crumble_cost, precision_digits=2), 0, 'Product Bom Price is not correct')