Skip to content

Commit

Permalink
[IMP] mrp(_account): include byproduct cost share in BoM/MO
Browse files Browse the repository at this point in the history
For improved cost analysis, byproducts can now have a "cost share" (out
of 100%) indicated. This cost share will by multiplied by the total BoM
cost (i.e. components + operations costs) and reflected in the stock
valuation. The final product will also have the byproducts cost
subtracted from its final stock valuation. This is of course only
applies when costing methods are appropriate (i.e. non-standard price).

Additionally, we can now "Compute Price from BoM" for byproducts (via
its product form) and the BoMs that it is a byproduct of are now counted
towards its # BoMs smart button (we will now also see these BoMs when
clicking on the smart button). Of course when there is no cost share for
byproducts then clicking on "Compute Price for BoM" will not change the
a byproduct's cost and if there are multiple BoMs the prodcut is a
byproduct of, the calculation will only be based on the first BoM it
finds (i.e. same as current logic for manufactured products).

BoM Cost report has been updated to include byproducts with cost shares
(byproducts with cost share = 0 will not show up in report).
Additionally we update the report to display operations only costs now
that BoMs are allowed to have no components in them.

Part 1 of Task: 2440068
Upgrade PR: odoo/upgrade#2727
Related ENT PR: odoo/enterprise#20169

Part-of: #74951
  • Loading branch information
ticodoo committed Aug 26, 2021
1 parent 8581700 commit fd52760
Show file tree
Hide file tree
Showing 13 changed files with 325 additions and 38 deletions.
15 changes: 15 additions & 0 deletions addons/mrp/models/mrp_bom.py
Expand Up @@ -120,6 +120,17 @@ def _check_bom_lines(self):
product=ptav.product_tmpl_id.display_name,
bom_product=bom_line.parent_product_tmpl_id.display_name
))
for byproduct in bom.byproduct_ids:
if bom.product_id:
same_product = bom.product_id == byproduct.product_id
else:
same_product = bom.product_tmpl_id == byproduct.product_id.product_tmpl_id
if same_product:
raise ValidationError(_("By-product %s should not be the same as BoM product.") % bom.display_name)
if byproduct.cost_share < 0:
raise ValidationError(_("By-products cost shares must be positive."))
if sum(bom.byproduct_ids.mapped('cost_share')) > 100:
raise ValidationError(_("The total cost share for a BoM's by-products cannot exceed 100."))

@api.onchange('product_uom_id')
def onchange_product_uom_id(self):
Expand Down Expand Up @@ -476,6 +487,10 @@ class MrpByProduct(models.Model):
domain="[('id', 'in', possible_bom_product_template_attribute_value_ids)]",
help="BOM Product Variants needed to apply this line.")
sequence = fields.Integer("Sequence")
cost_share = fields.Float(
"Cost Share (%)", digits=(5, 2), # decimal = 2 is important for rounding calculations!!
help="The percentage of the final production cost for this by-product line (divided between the quantity produced)."
"The total of all by-products' cost share must be less than or equal to 100.")

@api.onchange('product_id')
def _onchange_product_id(self):
Expand Down
17 changes: 14 additions & 3 deletions addons/mrp/models/mrp_production.py
Expand Up @@ -10,7 +10,7 @@
from dateutil.relativedelta import relativedelta

from odoo import api, fields, models, _
from odoo.exceptions import AccessError, UserError
from odoo.exceptions import UserError, ValidationError
from odoo.tools import float_compare, float_round, float_is_zero, format_datetime
from odoo.tools.misc import format_date

Expand Down Expand Up @@ -692,6 +692,16 @@ def _onchange_workorder_ids(self):
else:
self.workorder_ids = False

@api.constrains('move_byproduct_ids')
def _check_byproducts(self):
for order in self:
if any(float_compare(move.product_qty, 0.0, precision_rounding=move.product_uom.rounding or move.product_id.uom_id.rounding) <= 0 for move in order.move_byproduct_ids):
raise ValidationError(_("The quantity produced of by-products must be positive."))
if any(move.cost_share < 0 for move in order.move_byproduct_ids):
raise ValidationError(_("By-products cost shares must be positive."))
if sum(order.move_byproduct_ids.mapped('cost_share')) > 100:
raise ValidationError(_("The total cost share for a manufacturing order's by-products cannot exceed 100."))

def write(self, vals):
if 'workorder_ids' in self:
production_to_replan = self.filtered(lambda p: p.is_planned)
Expand Down Expand Up @@ -846,7 +856,7 @@ def _create_workorder(self):
for workorder in production.workorder_ids:
workorder.duration_expected = workorder._get_duration_expected()

def _get_move_finished_values(self, product_id, product_uom_qty, product_uom, operation_id=False, byproduct_id=False):
def _get_move_finished_values(self, product_id, product_uom_qty, product_uom, operation_id=False, byproduct_id=False, cost_share=0):
group_orders = self.procurement_group_id.mrp_production_ids
move_dest_ids = self.move_dest_ids
if len(group_orders) > 1:
Expand Down Expand Up @@ -874,6 +884,7 @@ def _get_move_finished_values(self, product_id, product_uom_qty, product_uom, op
'group_id': self.procurement_group_id.id,
'propagate_cancel': self.propagate_cancel,
'move_dest_ids': [(4, x.id) for x in self.move_dest_ids if not byproduct_id],
'cost_share': cost_share,
}

def _get_moves_finished_values(self):
Expand All @@ -889,7 +900,7 @@ def _get_moves_finished_values(self):
qty = byproduct.product_qty * (product_uom_factor / production.bom_id.product_qty)
moves.append(production._get_move_finished_values(
byproduct.product_id.id, qty, byproduct.product_uom_id.id,
byproduct.operation_id.id, byproduct.id))
byproduct.operation_id.id, byproduct.id, byproduct.cost_share))
return moves

def _create_update_move_finished(self):
Expand Down
8 changes: 4 additions & 4 deletions addons/mrp/models/product.py
Expand Up @@ -25,7 +25,7 @@ class ProductTemplate(models.Model):

def _compute_bom_count(self):
for product in self:
product.bom_count = self.env['mrp.bom'].search_count([('product_tmpl_id', '=', product.id)])
product.bom_count = self.env['mrp.bom'].search_count(['|', ('product_tmpl_id', '=', product.id), ('byproduct_ids.product_id.product_tmpl_id', '=', product.id)])

def _compute_used_in_bom_count(self):
for template in self:
Expand Down Expand Up @@ -73,7 +73,7 @@ class ProductProduct(models.Model):

def _compute_bom_count(self):
for product in self:
product.bom_count = self.env['mrp.bom'].search_count(['|', ('product_id', '=', product.id), '&', ('product_id', '=', False), ('product_tmpl_id', '=', product.product_tmpl_id.id)])
product.bom_count = self.env['mrp.bom'].search_count(['|', '|', ('byproduct_ids.product_id', '=', product.id), ('product_id', '=', product.id), '&', ('product_id', '=', False), ('product_tmpl_id', '=', product.product_tmpl_id.id)])

def _compute_used_in_bom_count(self):
for product in self:
Expand Down Expand Up @@ -181,12 +181,12 @@ def _compute_quantities_dict(self, lot_id, owner_id, package_id, from_date=False
def action_view_bom(self):
action = self.env["ir.actions.actions"]._for_xml_id("mrp.product_open_bom")
template_ids = self.mapped('product_tmpl_id').ids
# bom specific to this variant or global to template
# bom specific to this variant or global to template or that contains the product as a byproduct
action['context'] = {
'default_product_tmpl_id': template_ids[0],
'default_product_id': self.ids[0],
}
action['domain'] = ['|', ('product_id', 'in', self.ids), '&', ('product_id', '=', False), ('product_tmpl_id', 'in', template_ids)]
action['domain'] = ['|', '|', ('byproduct_ids.product_id', 'in', self.ids), ('product_id', 'in', self.ids), '&', ('product_id', '=', False), ('product_tmpl_id', 'in', template_ids)]
return action

def action_view_mos(self):
Expand Down
13 changes: 9 additions & 4 deletions addons/mrp/models/stock_move.py
Expand Up @@ -2,7 +2,6 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from odoo import api, exceptions, fields, models, _
from odoo.exceptions import UserError
from odoo.tools import float_compare, float_round, float_is_zero, OrderedSet


Expand Down Expand Up @@ -109,6 +108,9 @@ class StockMove(models.Model):
help='Technical Field to order moves')
order_finished_lot_ids = fields.Many2many('stock.production.lot', string="Finished Lot/Serial Number", compute='_compute_order_finished_lot_ids')
should_consume_qty = fields.Float('Quantity To Consume', compute='_compute_should_consume_qty', digits='Product Unit of Measure')
cost_share = fields.Float(
"Cost Share (%)", digits=(5, 2), # decimal = 2 is important for rounding calculations!!
help="The percentage of the final production cost for this by-product. The total of all by-products' cost share must be smaller or equal to 100.")

@api.depends('raw_material_production_id.priority')
def _compute_priority(self):
Expand Down Expand Up @@ -340,9 +342,12 @@ def _key_assign_picking(self):

@api.model
def _prepare_merge_moves_distinct_fields(self):
distinct_fields = super()._prepare_merge_moves_distinct_fields()
distinct_fields.append('created_production_id')
return distinct_fields
return super()._prepare_merge_moves_distinct_fields() + ['created_production_id', 'cost_share']

def _merge_moves_fields(self):
res = super()._merge_moves_fields()
res['cost_share'] = sum(self.mapped('cost_share'))
return res

@api.model
def _prepare_merge_move_sort_method(self, move):
Expand Down
67 changes: 66 additions & 1 deletion addons/mrp/report/mrp_report_bom_structure.py
Expand Up @@ -65,6 +65,17 @@ def get_operations(self, product_id=False, bom_id=False, qty=0, level=0):
}
return self.env.ref('mrp.report_mrp_operation_line')._render({'data': values})

@api.model
def get_byproducts(self, bom_id=False, qty=0, level=0, total=0):
bom = self.env['mrp.bom'].browse(bom_id)
lines, dummy = self._get_byproducts_lines(bom, qty, level, total)
values = {
'bom_id': bom_id,
'currency': self.env.company.currency_id,
'byproducts': lines,
}
return self.env.ref('mrp.report_mrp_byproduct_line')._render({'data': values})

@api.model
def _get_report_data(self, bom_id, searchQty=0, searchVariant=False):
lines = {}
Expand Down Expand Up @@ -128,8 +139,14 @@ def _get_bom(self, bom_id=False, product_id=False, line_qty=False, line_id=False
'operations_time': sum([op['duration_expected'] for op in operations])
}
components, total = self._get_bom_lines(bom, bom_quantity, product, line_id, level)
lines['components'] = components
lines['total'] += total
lines['components'] = components
byproducts, byproduct_cost_portion = self._get_byproducts_lines(bom, bom_quantity, level, lines['total'])
lines['byproducts'] = byproducts
lines['cost_share'] = float_round(1 - byproduct_cost_portion, precision_rounding=0.0001)
lines['bom_cost'] = lines['total'] * lines['cost_share']
lines['byproducts_cost'] = sum(byproduct['bom_cost'] for byproduct in byproducts)
lines['byproducts_total'] = sum(byproduct['product_qty'] for byproduct in byproducts)
return lines

def _get_bom_lines(self, bom, bom_quantity, product, line_id, level):
Expand All @@ -144,6 +161,9 @@ def _get_bom_lines(self, bom, bom_quantity, product, line_id, level):
if line.child_bom_id:
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, line.product_id)
byproduct_cost_share = sum(line.child_bom_id.byproduct_ids.mapped('cost_share'))
if byproduct_cost_share:
sub_total *= float_round(1 - byproduct_cost_share / 100, precision_rounding=0.0001)
else:
sub_total = price
sub_total = self.env.company.currency_id.round(sub_total)
Expand All @@ -167,6 +187,28 @@ def _get_bom_lines(self, bom, bom_quantity, product, line_id, level):
total += sub_total
return components, total

def _get_byproducts_lines(self, bom, bom_quantity, level, total):
byproducts = []
byproduct_cost_portion = 0
company = bom.company_id or self.env.company
for byproduct in bom.byproduct_ids:
line_quantity = (bom_quantity / (bom.product_qty or 1.0)) * byproduct.product_qty
cost_share = byproduct.cost_share / 100
byproduct_cost_portion += cost_share
price = byproduct.product_id.uom_id._compute_price(byproduct.product_id.with_company(company).standard_price, byproduct.product_uom_id) * line_quantity
byproducts.append({
'product_id': byproduct.product_id,
'product_name': byproduct.product_id.display_name,
'product_qty': line_quantity,
'product_uom': byproduct.product_uom_id.name,
'product_cost': company.currency_id.round(price),
'parent_id': bom.id,
'level': level or 0,
'bom_cost': company.currency_id.round(total * cost_share),
'cost_share': cost_share,
})
return byproducts, byproduct_cost_portion

def _get_operation_line(self, product, bom, qty, level):
operations = []
total = 0.0
Expand Down Expand Up @@ -204,6 +246,9 @@ def _get_price(self, bom, factor, product):
if line.child_bom_id:
qty = line.product_uom_id._compute_quantity(line.product_qty * factor, line.child_bom_id.product_uom_id) / line.child_bom_id.product_qty
sub_price = self._get_price(line.child_bom_id, qty, line.product_id)
byproduct_cost_share = sum(line.child_bom_id.byproduct_ids.mapped('cost_share'))
if byproduct_cost_share:
sub_price *= float_round(1 - byproduct_cost_share / 100, precision_rounding=0.0001)
price += sub_price
else:
prod_qty = line.product_qty * factor
Expand Down Expand Up @@ -253,6 +298,26 @@ def get_sub_lines(bom, product_id, line_qty, line_id, level):
'bom_cost': operation['total'],
'level': level + 1,
})
if data['byproducts']:
lines.append({
'name': _('Byproducts'),
'type': 'byproduct',
'uom': False,
'quantity': data['byproducts_total'],
'bom_cost': data['byproducts_cost'],
'level': level,
})
for byproduct in data['byproducts']:
if unfolded or 'byproduct-' + str(bom.id) in child_bom_ids:
lines.append({
'name': byproduct['product_name'],
'type': 'byproduct',
'quantity': byproduct['product_qty'],
'uom': byproduct['product_uom'],
'prod_cost': byproduct['product_cost'],
'bom_cost': byproduct['bom_cost'],
'level': level + 1,
})
return lines

bom = self.env['mrp.bom'].browse(bom_id)
Expand Down

0 comments on commit fd52760

Please sign in to comment.