[IMP] mrp_subcontracting: improve mrp bom report

- The "BoM Structure & Cost" takes now in account
the subcontracting cost.
The subcontracting cost depends of the best supplier info in product
and subcontractor register in the BoM.
- Also add this subcontracting cost (to the cost field) when we click on
"Compute Price from BoM" in the product view.

task-2486811

Part-of: odoo/odoo#75041
This commit is contained in:
Rémy Voet (ryv)
2021-09-03 10:37:46 +00:00
parent 10c6f4e837
commit 6aa8b7dd67
13 changed files with 338 additions and 54 deletions
+49 -47
View File
@@ -19,7 +19,7 @@ class ReportBomStructure(models.AbstractModel):
quantity = float(data.get('quantity', 1))
for product_variant_id in candidates.ids:
if data and data.get('childs'):
doc = self._get_pdf_line(bom_id, product_id=product_variant_id, qty=quantity, child_bom_ids=json.loads(data.get('childs')))
doc = self._get_pdf_line(bom_id, product_id=product_variant_id, qty=quantity, child_bom_ids=set(json.loads(data.get('childs'))))
else:
doc = self._get_pdf_line(bom_id, product_id=product_variant_id, qty=quantity, unfolded=True)
doc['report_type'] = 'pdf'
@@ -27,7 +27,7 @@ class ReportBomStructure(models.AbstractModel):
docs.append(doc)
if not candidates:
if data and data.get('childs'):
doc = self._get_pdf_line(bom_id, qty=quantity, child_bom_ids=json.loads(data.get('childs')))
doc = self._get_pdf_line(bom_id, qty=quantity, child_bom_ids=set(json.loads(data.get('childs'))))
else:
doc = self._get_pdf_line(bom_id, qty=quantity, unfolded=True)
doc['report_type'] = 'pdf'
@@ -260,48 +260,46 @@ class ReportBomStructure(models.AbstractModel):
price += company.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):
def get_sub_lines(bom, product_id, line_qty, line_id, level):
data = self._get_bom(bom_id=bom.id, product_id=product_id, line_qty=line_qty, line_id=line_id, level=level)
bom_lines = data['components']
lines = []
for bom_line in bom_lines:
lines.append({
'name': bom_line['prod_name'],
'type': 'bom',
'quantity': bom_line['prod_qty'],
'uom': bom_line['prod_uom'],
'prod_cost': bom_line['prod_cost'],
'bom_cost': bom_line['total'],
'level': bom_line['level'],
'code': bom_line['code'],
'child_bom': bom_line['child_bom'],
'prod_id': bom_line['prod_id']
})
if bom_line['child_bom'] and (unfolded or bom_line['child_bom'] in child_bom_ids):
line = self.env['mrp.bom.line'].browse(bom_line['line_id'])
lines += (get_sub_lines(line.child_bom_id, line.product_id.id, bom_line['prod_qty'], line, level + 1))
if data['operations']:
lines.append({
'name': _('Operations'),
'type': 'operation',
'quantity': data['operations_time'],
'uom': _('minutes'),
'bom_cost': data['operations_cost'],
'level': level,
})
for operation in data['operations']:
if unfolded or 'operation-' + str(bom.id) in child_bom_ids:
lines.append({
'name': operation['name'],
'type': 'operation',
'quantity': operation['duration_expected'],
'uom': _('minutes'),
'bom_cost': operation['total'],
'level': level + 1,
})
if data['byproducts']:
def _get_sub_lines(self, bom, product_id, line_qty, line_id, level, child_bom_ids, unfolded):
data = self._get_bom(bom_id=bom.id, product_id=product_id, line_qty=line_qty, line_id=line_id, level=level)
bom_lines = data['components']
lines = []
for bom_line in bom_lines:
lines.append({
'name': bom_line['prod_name'],
'type': 'bom',
'quantity': bom_line['prod_qty'],
'uom': bom_line['prod_uom'],
'prod_cost': bom_line['prod_cost'],
'bom_cost': bom_line['total'],
'level': bom_line['level'],
'code': bom_line['code'],
'child_bom': bom_line['child_bom'],
'prod_id': bom_line['prod_id']
})
if bom_line['child_bom'] and (unfolded or bom_line['child_bom'] in child_bom_ids):
line = self.env['mrp.bom.line'].browse(bom_line['line_id'])
lines += (self._get_sub_lines(line.child_bom_id, line.product_id.id, bom_line['prod_qty'], line, level + 1, child_bom_ids, unfolded))
if data['operations']:
lines.append({
'name': _('Operations'),
'type': 'operation',
'quantity': data['operations_time'],
'uom': _('minutes'),
'bom_cost': data['operations_cost'],
'level': level,
})
for operation in data['operations']:
if unfolded or 'operation-' + str(bom.id) in child_bom_ids:
lines.append({
'name': operation['name'],
'type': 'operation',
'quantity': operation['duration_expected'],
'uom': _('minutes'),
'bom_cost': operation['total'],
'level': level + 1,
})
if data['byproducts']:
lines.append({
'name': _('Byproducts'),
'type': 'byproduct',
@@ -321,12 +319,16 @@ class ReportBomStructure(models.AbstractModel):
'bom_cost': byproduct['bom_cost'],
'level': level + 1,
})
return lines
return lines
def _get_pdf_line(self, bom_id, product_id=False, qty=1, child_bom_ids=None, unfolded=False):
if child_bom_ids is None:
child_bom_ids = set()
bom = self.env['mrp.bom'].browse(bom_id)
product_id = product_id or bom.product_id.id or bom.product_tmpl_id.product_variant_id.id
data = self._get_bom(bom_id=bom_id, product_id=product_id, line_qty=qty)
pdf_lines = get_sub_lines(bom, product_id, qty, False, 1)
data = self._get_bom(bom_id=bom_id, product_id=product_id, line_qty=qty, )
pdf_lines = self._get_sub_lines(bom, product_id, qty, False, 1, child_bom_ids, unfolded)
data['components'] = []
data['lines'] = pdf_lines
data['extra_column_count'] = self._get_extra_column_count()
@@ -148,7 +148,7 @@
</td>
</tr>
</t>
<t t-if="data['operations']">
<t t-if="data['operations']" name="operations">
<t t-set="space_td" t-value="'margin-left: '+ str(data['level'] * 20) + 'px;'"/>
<tr class="o_mrp_bom_report_line o_mrp_bom_cost" t-att-data-product_id="data['product'].id" t-att-data-id="'operation-' + str(data['bom'].id)" t-att-data-bom-id="data['bom'].id" t-att-parent_id="data['bom'].id" t-att-data-qty="data['bom_qty']" t-att-data-level="data['level']">
<td name="td_opr">
+2 -4
View File
@@ -6,7 +6,7 @@ from odoo.tests import common, Form
from odoo.tools.float_utils import float_round, float_compare
class TestBom(common.TransactionCase):
class TestBomPrice(common.TransactionCase):
def _create_product(self, name, price):
return self.Product.create({
@@ -16,11 +16,9 @@ class TestBom(common.TransactionCase):
})
def setUp(self):
super(TestBom, self).setUp()
super(TestBomPrice, self).setUp()
self.Product = self.env['product.product']
self.Bom = self.env['mrp.bom']
#self.Routing = self.env['mrp.routing']
self.operation = self.env['mrp.routing.workcenter']
# Products.
self.dining_table = self._create_product('Dining Table', 1000)
+1
View File
@@ -4,6 +4,7 @@ from odoo import SUPERUSER_ID, api
from . import models
from . import wizard
from . import report
def uninstall_hook(cr, registry):
@@ -21,6 +21,7 @@
'views/product_views.xml',
'views/mrp_production_views.xml',
'wizard/stock_picking_return_views.xml',
'report/mrp_report_bom_structure.xml',
],
'demo': [
'data/mrp_subcontracting_demo.xml',
+10 -1
View File
@@ -13,5 +13,14 @@ class SupplierInfo(models.Model):
def _compute_is_subcontractor(self):
for supplier in self:
boms = supplier.product_id.variant_bom_ids
boms |= supplier.product_tmpl_id.bom_ids.filtered(lambda b: not b.product_id)
boms |= supplier.product_tmpl_id.bom_ids.filtered(lambda b: not b.product_id or b.product_id in (supplier.product_id or supplier.product_tmpl_id.product_variant_ids))
supplier.is_subcontractor = supplier.name in boms.subcontractor_ids
class ProductProduct(models.Model):
_inherit = 'product.product'
def _prepare_sellers(self, params=False):
if params and params.get('subcontractor_ids'):
return super()._prepare_sellers(params=params).filtered(lambda s: s.name in params.get('subcontractor_ids'))
return super()._prepare_sellers(params=params)
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import mrp_report_bom_structure
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, _
class ReportBomStructure(models.AbstractModel):
_inherit = 'report.mrp.report_bom_structure'
def _get_subcontracting_line(self, bom, seller, level, bom_quantity):
ratio_uom_seller = seller.product_uom.ratio / bom.product_uom_id.ratio
return {
'name': seller.name.display_name,
'partner_id': seller.name.id,
'quantity': bom_quantity,
'uom': bom.product_uom_id.name,
'prod_cost': seller.price / ratio_uom_seller,
'bom_cost': seller.price / ratio_uom_seller * bom_quantity,
'level': level or 0
}
def _get_price(self, bom, factor, product):
price = super()._get_price(bom, factor, product)
if bom and bom.type == 'subcontract':
bom_quantity = bom.product_qty * factor
seller = product._select_seller(quantity=bom_quantity, uom_id=bom.product_uom_id, params={'subcontractor_ids': bom.subcontractor_ids})
if seller:
price += seller.product_uom._compute_price(seller.price, product.uom_id)
return price
def _get_bom(self, bom_id=False, product_id=False, line_qty=False, line_id=False, level=False):
res = super(ReportBomStructure, self)._get_bom(bom_id, product_id, line_qty, line_id, level)
bom = res['bom']
if bom and bom.type == 'subcontract':
bom_quantity = line_qty
if line_id:
current_line = self.env['mrp.bom.line'].browse(int(line_id))
bom_quantity = current_line.product_uom_id._compute_quantity(line_qty, bom.product_uom_id)
seller = res['product']._select_seller(quantity=bom_quantity, uom_id=bom.product_uom_id, params={'subcontractor_ids': bom.subcontractor_ids})
if seller:
res['subcontracting'] = self._get_subcontracting_line(bom, seller, level, bom_quantity)
res['total'] += res['subcontracting']['bom_cost']
return res
def _get_sub_lines(self, bom, product_id, line_qty, line_id, level, child_bom_ids, unfolded):
res = super()._get_sub_lines(bom, product_id, line_qty, line_id, level, child_bom_ids, unfolded)
if bom and bom.type == 'subcontract':
product = self.env['product.product'].browse(product_id)
bom_quantity = line_qty
if line_id:
current_line = self.env['mrp.bom.line'].browse(int(line_id))
bom_quantity = current_line.product_uom_id._compute_quantity(line_qty, bom.product_uom_id)
seller = product._select_seller(quantity=bom_quantity, uom_id=bom.product_uom_id, params={'subcontractor_ids': bom.subcontractor_ids})
if seller:
values_sub = self._get_subcontracting_line(bom, seller, level, bom_quantity)
values_sub['type'] = 'bom'
values_sub['name'] = _("Subcontracting: ") + values_sub['name']
res.append(values_sub)
return res
@@ -0,0 +1,31 @@
<odoo>
<template id="report_mrp_bom_line_inherit_mrp_subcontracting" inherit_id="mrp.report_mrp_bom_line">
<xpath expr="//t[@name='operations']" position="after">
<t t-if="data.get('subcontracting')">
<t t-set="space_td" t-value="'margin-left: '+ str(data['subcontracting']['level'] * 20) + 'px;'"/>
<td name="td_subcontracting">
<div t-att-style="space_td">
<div class="o_mrp_bom_no_fold">
Subcontracting: <a href="#" class="o_mrp_bom_action" t-att-data-res-id="data['subcontracting']['partner_id']" t-att-data-model="'res.partner'"><t t-esc="data['subcontracting']['name']"/></a>
</div>
</div>
</td>
<td/>
<t t-foreach="range(data.get('extra_column_count', 0))" t-as="index">
<td/>
</t>
<td class="text-right">
<span t-esc="data['subcontracting']['quantity']" t-options='{"widget": "float", "decimal_precision": "Product Unit of Measure"}'/>
</td>
<td groups="uom.group_uom"><span t-esc="data['subcontracting']['uom']"/></td>
<td class="o_mrp_prod_cost text-right">
<span t-esc="data['subcontracting']['prod_cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
<td class="o_mrp_bom_cost text-right">
<span t-esc="data['subcontracting']['bom_cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
<td/>
</t>
</xpath>
</template>
</odoo>
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from odoo.tests import Form
from odoo.tests.common import TransactionCase
from odoo.addons.mrp_subcontracting.tests.common import TestMrpSubcontractingCommon
@@ -492,6 +493,103 @@ class TestSubcontractingFlows(TestMrpSubcontractingCommon):
mo = self.env['mrp.production'].search([('bom_id', '=', self.bom.id)])
self.assertEqual(len(mo), 1)
def test_flow_flexible_bom_1(self):
""" Record Component for a bom subcontracted with a flexible and flexible + warning consumption """
self.bom.consumption = 'flexible'
# Create a receipt picking from the subcontractor
picking_form = Form(self.env['stock.picking'])
picking_form.picking_type_id = self.env.ref('stock.picking_type_in')
picking_form.partner_id = self.subcontractor_partner1
with picking_form.move_ids_without_package.new() as move:
move.product_id = self.finished
move.product_uom_qty = 1
picking_receipt = picking_form.save()
picking_receipt.action_confirm()
self.assertEqual(picking_receipt.display_action_record_components, 'facultative')
action = picking_receipt.action_record_components()
mo = self.env['mrp.production'].browse(action['res_id'])
mo_form = Form(mo.with_context(**action['context']), view=action['view_id'])
mo_form.qty_producing = 1
with mo_form.move_line_raw_ids.edit(0) as ml:
self.assertEqual(ml.product_id, self.comp1)
self.assertEqual(ml.qty_done, 1)
ml.qty_done = 2
mo = mo_form.save()
mo.subcontracting_record_component()
self.assertEqual(mo.move_raw_ids[0].move_line_ids.qty_done, 2)
# We should not be able to call the 'record_components' button
self.assertEqual(picking_receipt.display_action_record_components, 'hide')
picking_receipt.button_validate()
self.assertEqual(mo.state, 'done')
avail_qty_comp1 = self.env['stock.quant']._get_available_quantity(self.comp1, self.subcontractor_partner1.property_stock_subcontractor, allow_negative=True)
self.assertEqual(avail_qty_comp1, -2)
def test_flow_warning_bom_1(self):
""" Record Component for a bom subcontracted with a flexible and flexible + warning consumption """
self.bom.consumption = 'warning'
# Create a receipt picking from the subcontractor
picking_form = Form(self.env['stock.picking'])
picking_form.picking_type_id = self.env.ref('stock.picking_type_in')
picking_form.partner_id = self.subcontractor_partner1
with picking_form.move_ids_without_package.new() as move:
move.product_id = self.finished
move.product_uom_qty = 1
picking_receipt = picking_form.save()
picking_receipt.action_confirm()
self.assertEqual(picking_receipt.display_action_record_components, 'facultative')
action = picking_receipt.action_record_components()
mo = self.env['mrp.production'].browse(action['res_id'])
mo_form = Form(mo.with_context(**action['context']), view=action['view_id'])
mo_form.qty_producing = 1
with mo_form.move_line_raw_ids.edit(0) as ml:
self.assertEqual(ml.product_id, self.comp1)
self.assertEqual(ml.qty_done, 1)
ml.qty_done = 2
mo = mo_form.save()
action_warning = mo.subcontracting_record_component()
warning = Form(self.env['mrp.consumption.warning'].with_context(**action_warning['context']))
warning = warning.save()
warning.action_cancel()
action_warning = mo.subcontracting_record_component()
warning = Form(self.env['mrp.consumption.warning'].with_context(**action_warning['context']))
warning = warning.save()
warning.action_confirm()
self.assertEqual(mo.move_raw_ids[0].move_line_ids.qty_done, 2)
# We should not be able to call the 'record_components' button
self.assertEqual(picking_receipt.display_action_record_components, 'hide')
picking_receipt.button_validate()
self.assertEqual(mo.state, 'done')
avail_qty_comp1 = self.env['stock.quant']._get_available_quantity(self.comp1, self.subcontractor_partner1.property_stock_subcontractor, allow_negative=True)
self.assertEqual(avail_qty_comp1, -2)
def test_mrp_report_bom_structure_subcontracting(self):
self.comp2_bom.write({'type': 'subcontract', 'subcontractor_ids': [Command.link(self.subcontractor_partner1.id)]})
self.env['product.supplierinfo'].create({
'product_tmpl_id': self.finished.product_tmpl_id.id,
'name': self.subcontractor_partner1.id,
'price': 10,
})
supplier = self.env['product.supplierinfo'].create({
'product_tmpl_id': self.comp2.product_tmpl_id.id,
'name': self.subcontractor_partner1.id,
'price': 5,
})
self.assertTrue(supplier.is_subcontractor)
self.comp1.standard_price = 5
report_values = self.env['report.mrp.report_bom_structure']._get_report_data(self.bom.id, searchQty=1, searchVariant=False)
subcontracting_values = report_values['lines']['subcontracting']
self.assertEqual(subcontracting_values['name'], self.subcontractor_partner1.display_name)
self.assertEqual(subcontracting_values['bom_cost'], 10)
self.assertEqual(report_values['lines']['total'], 20) # 10 For subcontracting + 5 for comp1 + 5 for subcontracting of comp2_bom
class TestSubcontractingTracking(TransactionCase):
def setUp(self):
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import stock_picking
from . import product_product
@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ProductProduct(models.Model):
_inherit = 'product.product'
def _compute_bom_price(self, bom, boms_to_recompute=False, byproduct_bom=False):
""" Add the price of the subcontracting supplier if it exists with the bom configuration.
"""
price = super()._compute_bom_price(bom, boms_to_recompute, byproduct_bom)
if bom and bom.type == 'subcontract':
seller = self._select_seller(quantity=bom.product_qty, uom_id=bom.product_uom_id, params={'subcontractor_ids': bom.subcontractor_ids})
if seller:
price += seller.product_uom._compute_price(seller.price, self.uom_id)
return price
@@ -1,8 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.mrp_subcontracting.tests.common import TestMrpSubcontractingCommon
from odoo import Command
from odoo.tests.common import Form
from odoo.tools.float_utils import float_round, float_compare
from odoo.addons.mrp_subcontracting.tests.common import TestMrpSubcontractingCommon
from odoo.addons.mrp_account.tests.test_bom_price import TestBomPrice
class TestAccountSubcontractingFlows(TestMrpSubcontractingCommon):
def test_subcontracting_account_flow_1(self):
@@ -132,3 +136,58 @@ class TestAccountSubcontractingFlows(TestMrpSubcontractingCommon):
self.assertEqual(len(f_layers), 4)
for layer in f_layers:
self.assertEqual(layer.value, 100 + 50)
class TestBomPriceSubcontracting(TestBomPrice):
def test_01_compute_price_subcontracting_cost(self):
"""Test calculation of bom cost with subcontracting."""
self.table_head.uom_po_id = self.dozen
partner = self.env['res.partner'].create({
'name': 'A name can be a Many2one...'
})
(self.bom_1 | self.bom_2).write({
'type': 'subcontract',
'subcontractor_ids': [Command.link(partner.id)]
})
suppliers = self.env['product.supplierinfo'].create([
{
'name': partner.id,
'product_tmpl_id': self.dining_table.product_tmpl_id.id,
'price': 150.0,
}, {
'name': partner.id,
'product_tmpl_id': self.table_head.product_tmpl_id.id,
'price': 120.0, # 10 by Unit because uom_po_id is in dozen
}
])
self.assertEqual(suppliers.mapped('is_subcontractor'), [True, True])
# -----------------------------------------------------------------
# Cost of BoM (Dining Table 1 Unit)
# -----------------------------------------------------------------
# Component Cost = Table Head 1 Unit * 300 = 300 (478.75 from it's components)
# Screw 5 Unit * 10 = 50
# Leg 4 Unit * 25 = 100
# Glass 1 Unit * 100 = 100
# Subcontracting 1 Unit * 150 = 150
# Total = 700 [878.75 if components of Table Head considered] (for 1 Unit)
# -----------------------------------------------------------------
self.assertEqual(self.dining_table.standard_price, 1000, "Initial price of the Product should be 1000")
self.dining_table.button_bom_cost()
self.assertEqual(float_round(self.dining_table.standard_price, precision_digits=2), 700.0, "After computing price from BoM price should be 700")
# Cost of BoM (Table Head 1 Dozen)
# -----------------------------------------------------------------
# Component Cost = Plywood Sheet 12 Unit * 200 = 2400
# Bolt 60 Unit * 10 = 600
# Colour 12 Unit * 100 = 1200
# Corner Slide 57 Unit * 25 = 1425
# Subcontracting 1 Dozen * 120 = 120
# Total = 5745
# 1 Unit price (5745/12) = 478.75
# -----------------------------------------------------------------
self.assertEqual(self.table_head.standard_price, 300, "Initial price of the Product should be 300")
self.Product.browse([self.dining_table.id, self.table_head.id]).action_bom_cost()
self.assertEqual(float_compare(self.table_head.standard_price, 478.75, precision_digits=2), 0, "After computing price from BoM price should be 878.75")
self.assertEqual(float_compare(self.dining_table.standard_price, 878.75, precision_digits=2), 0, "After computing price from BoM price should be 878.75")