[MIG] mrp_average_cost to v14
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
# Copyright (C) 2016-2019 Akretion (http://www.akretion.com)
|
||||
# Copyright (C) 2016-2024 Akretion (http://www.akretion.com)
|
||||
# @author Alexis de Lattre <alexis.delattre@akretion.com>
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
{
|
||||
'name': 'MRP Average Cost',
|
||||
'version': '12.0.1.0.0', # WARNING: we'll probably not port this module to v14, because part of its feature is now provided by the module mrp_account
|
||||
'version': '14.0.1.0.0',
|
||||
'category': 'Manufactuing',
|
||||
'license': 'AGPL-3',
|
||||
'summary': 'Update standard_price upon validation of a manufacturing order',
|
||||
@@ -12,16 +12,16 @@
|
||||
MRP Average Cost
|
||||
================
|
||||
|
||||
By default, the official stock module updates the standard_price of a product that has costing_method = 'average' when validating an incoming picking. But the official 'mrp' module doesn't do that when you validate a manufactuging order.
|
||||
I initially developped this module for Odoo 12.0, when the module mrp_account didn't exist, so Odoo didn't support the update of the standard cost of a manufactured product.
|
||||
|
||||
This module adds this feature : when you validate a manufacturing order of a product that has costing method = 'average', the standard_price of the product will be updated by taking into account the standard_price of each raw material and also a number of work hours defined on the BOM.
|
||||
In the mrp_account module, you must use workcenters to take the labor costs into account. This module aims at encoding theorical labor costs on the BOM and using it to compute the cost of the finished product.
|
||||
|
||||
Together with this module, I recommend the use of my module product_usability, available in the same branch, which contains a backport of the model product.price.history from v8 to v7.
|
||||
With this module, when you validate a manufacturing order of a product that has costing method = 'average', the standard_price of the product will be updated by taking into account the standard_price of each raw material and also a number of work hours defined on the BOM plus the extra cost defined of the BOM.
|
||||
|
||||
This module has been written by Alexis de Lattre from Akretion <alexis.delattre@akretion.com>.
|
||||
""",
|
||||
'author': 'Akretion',
|
||||
'website': 'http://www.akretion.com',
|
||||
'website': 'https://github.com/akretion/odoo-usability',
|
||||
'depends': ['mrp'],
|
||||
'data': [
|
||||
'security/mrp_average_cost_security.xml',
|
||||
@@ -29,5 +29,5 @@ This module has been written by Alexis de Lattre from Akretion <alexis.delattre@
|
||||
'data/mrp_data.xml',
|
||||
'views/mrp_view.xml',
|
||||
],
|
||||
'installable': False,
|
||||
'installable': True,
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
# Copyright (C) 2016-2019 Akretion (http://www.akretion.com)
|
||||
# Copyright (C) 2016-2024 Akretion (http://www.akretion.com)
|
||||
# @author Alexis de Lattre <alexis.delattre@akretion.com>
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
import odoo.addons.decimal_precision as dp
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools import float_compare, float_is_zero
|
||||
from odoo.tools import float_compare
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,23 +15,19 @@ class MrpBomLabourLine(models.Model):
|
||||
|
||||
bom_id = fields.Many2one(
|
||||
comodel_name='mrp.bom',
|
||||
string='Labour Lines',
|
||||
string='Bill of Material',
|
||||
ondelete='cascade')
|
||||
|
||||
labour_time = fields.Float(
|
||||
string='Labour Time',
|
||||
required=True,
|
||||
digits=dp.get_precision('Labour Hours'),
|
||||
digits='Labour Hours',
|
||||
help="Average labour time for the production of "
|
||||
"items of the BOM, in hours.")
|
||||
|
||||
labour_cost_profile_id = fields.Many2one(
|
||||
comodel_name='labour.cost.profile',
|
||||
string='Labour Cost Profile',
|
||||
required=True)
|
||||
|
||||
note = fields.Text(
|
||||
string='Note')
|
||||
note = fields.Text()
|
||||
|
||||
_sql_constraints = [(
|
||||
'labour_time_positive',
|
||||
@@ -44,6 +38,26 @@ class MrpBomLabourLine(models.Model):
|
||||
class MrpBom(models.Model):
|
||||
_inherit = 'mrp.bom'
|
||||
|
||||
labour_line_ids = fields.One2many(
|
||||
'mrp.bom.labour.line', 'bom_id', string='Labour Lines')
|
||||
total_labour_cost = fields.Float(
|
||||
compute='_compute_total_labour_cost', digits='Product Price', store=True)
|
||||
extra_cost = fields.Float(
|
||||
tracking=True, digits='Product Price',
|
||||
help="Extra cost for the production of the quantity of "
|
||||
"items of the BOM, in company currency. "
|
||||
"You can use this field to enter the cost of the consumables "
|
||||
"that are used to produce the product but are not listed in "
|
||||
"the BOM")
|
||||
total_components_cost = fields.Float(
|
||||
compute='_compute_total_cost', digits='Product Price')
|
||||
total_cost = fields.Float(
|
||||
compute='_compute_total_cost', digits='Product Price',
|
||||
help="Total cost for the quantity and unit of measure of the bill of material. "
|
||||
"Total Cost = Total Components Cost + Total Labour Cost + Extra Cost")
|
||||
company_currency_id = fields.Many2one(
|
||||
related='company_id.currency_id', string='Company Currency')
|
||||
|
||||
@api.depends(
|
||||
'labour_line_ids.labour_time',
|
||||
'labour_line_ids.labour_cost_profile_id.hour_cost')
|
||||
@@ -70,107 +84,77 @@ class MrpBom(models.Model):
|
||||
bom.total_components_cost = comp_cost
|
||||
bom.total_cost = total_cost
|
||||
|
||||
labour_line_ids = fields.One2many(
|
||||
'mrp.bom.labour.line', 'bom_id', string='Labour Lines')
|
||||
total_labour_cost = fields.Float(
|
||||
compute='_compute_total_labour_cost', readonly=True,
|
||||
digits=dp.get_precision('Product Price'),
|
||||
string="Total Labour Cost", store=True)
|
||||
extra_cost = fields.Float(
|
||||
string='Extra Cost', track_visibility='onchange',
|
||||
digits=dp.get_precision('Product Price'),
|
||||
help="Extra cost for the production of the quantity of "
|
||||
"items of the BOM, in company currency. "
|
||||
"You can use this field to enter the cost of the consumables "
|
||||
"that are used to produce the product but are not listed in "
|
||||
"the BOM")
|
||||
total_components_cost = fields.Float(
|
||||
compute='_compute_total_cost', readonly=True,
|
||||
digits=dp.get_precision('Product Price'),
|
||||
string='Total Components Cost')
|
||||
total_cost = fields.Float(
|
||||
compute='_compute_total_cost', readonly=True,
|
||||
string='Total Cost',
|
||||
digits=dp.get_precision('Product Price'),
|
||||
help="Total Cost = Total Components Cost + "
|
||||
"Total Labour Cost + Extra Cost")
|
||||
company_currency_id = fields.Many2one(
|
||||
related='company_id.currency_id', string='Company Currency')
|
||||
|
||||
@api.model
|
||||
def _phantom_update_product_standard_price(self):
|
||||
logger.info('Start to auto-update cost price from phantom bom')
|
||||
logger.info('Start to auto-update cost price from phantom boms')
|
||||
boms = self.search([('type', '=', 'phantom')])
|
||||
boms.with_context(
|
||||
product_price_history_origin='Automatic update of Phantom BOMs')\
|
||||
.manual_update_product_standard_price()
|
||||
logger.info('End of the auto-update cost price from phantom bom')
|
||||
return True
|
||||
boms.manual_update_product_standard_price()
|
||||
logger.info('End of the auto-update cost price from phantom boms')
|
||||
|
||||
def manual_update_product_standard_price(self):
|
||||
if 'product_price_history_origin' not in self._context:
|
||||
self = self.with_context(
|
||||
product_price_history_origin='Manual update from BOM')
|
||||
precision = self.env['decimal.precision'].precision_get(
|
||||
prec = self.env['decimal.precision'].precision_get(
|
||||
'Product Price')
|
||||
for bom in self:
|
||||
wproduct = bom.product_id
|
||||
if not wproduct:
|
||||
wproduct = bom.product_tmpl_id
|
||||
if float_compare(
|
||||
wproduct.standard_price, bom.total_cost,
|
||||
precision_digits=precision):
|
||||
wproduct.with_context().write(
|
||||
{'standard_price': bom.total_cost})
|
||||
logger.info(
|
||||
'Cost price updated to %s on product %s',
|
||||
bom.total_cost, wproduct.display_name)
|
||||
return True
|
||||
if bom.product_id:
|
||||
products = bom.product_id
|
||||
else:
|
||||
products = bom.product_tmpl_id.product_variant_ids
|
||||
for product in products:
|
||||
standard_price = product._compute_bom_price(bom)
|
||||
if float_compare(product.standard_price, standard_price, precision_digits=prec):
|
||||
product.write({'standard_price': standard_price})
|
||||
logger.info(
|
||||
'Cost price updated to %s on product %s',
|
||||
standard_price, product.display_name)
|
||||
|
||||
|
||||
class MrpBomLine(models.Model):
|
||||
_inherit = 'mrp.bom.line'
|
||||
|
||||
standard_price = fields.Float(
|
||||
related='product_id.standard_price',
|
||||
readonly=True,
|
||||
string='Standard Price')
|
||||
standard_price = fields.Float(related='product_id.standard_price')
|
||||
|
||||
|
||||
class ProductProduct(models.Model):
|
||||
_inherit = 'product.product'
|
||||
|
||||
def _compute_bom_price(self, bom, boms_to_recompute=False):
|
||||
# Native method of mrp_account
|
||||
# WARNING dirty hack ; I hope it doesn't break too many things
|
||||
self.ensure_one()
|
||||
bom_cost_per_unit_in_product_uom = 0
|
||||
qty_product_uom = bom.product_uom_id._compute_quantity(bom.product_qty, self.uom_id)
|
||||
if qty_product_uom:
|
||||
bom_cost_per_unit_in_product_uom = bom.total_cost / qty_product_uom
|
||||
return bom_cost_per_unit_in_product_uom
|
||||
|
||||
|
||||
class LabourCostProfile(models.Model):
|
||||
_name = 'labour.cost.profile'
|
||||
_inherit = ['mail.thread']
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_description = 'Labour Cost Profile'
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
track_visibility='onchange')
|
||||
|
||||
tracking=True)
|
||||
hour_cost = fields.Float(
|
||||
string='Cost per Hour',
|
||||
required=True,
|
||||
digits=dp.get_precision('Product Price'),
|
||||
track_visibility='onchange',
|
||||
digits='Product Price',
|
||||
tracking=True,
|
||||
help="Labour cost per hour per person in company currency")
|
||||
|
||||
company_id = fields.Many2one(
|
||||
comodel_name='res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env['res.company']._company_default_get())
|
||||
comodel_name='res.company', required=True,
|
||||
default=lambda self: self.env.company)
|
||||
|
||||
company_currency_id = fields.Many2one(
|
||||
related='company_id.currency_id',
|
||||
readonly=True,
|
||||
store=True,
|
||||
string='Company Currency')
|
||||
related='company_id.currency_id', store=True, string='Company Currency')
|
||||
|
||||
@api.depends('name', 'hour_cost', 'company_currency_id.symbol')
|
||||
def name_get(self):
|
||||
res = []
|
||||
for record in self:
|
||||
res.append((record.id, u'%s (%s %s)' % (
|
||||
res.append((record.id, '%s (%s %s)' % (
|
||||
record.name, record.hour_cost,
|
||||
record.company_currency_id.symbol)))
|
||||
return res
|
||||
@@ -179,93 +163,25 @@ class LabourCostProfile(models.Model):
|
||||
class MrpProduction(models.Model):
|
||||
_inherit = 'mrp.production'
|
||||
|
||||
unit_cost = fields.Float(
|
||||
string='Unit Cost', readonly=True,
|
||||
digits=dp.get_precision('Product Price'),
|
||||
help="This cost per unit in the unit of measure of the product "
|
||||
"in company currency takes into account "
|
||||
"the cost of the raw materials and the labour cost defined on"
|
||||
"the BOM.")
|
||||
|
||||
company_currency_id = fields.Many2one(
|
||||
related='company_id.currency_id', readonly=True,
|
||||
string='Company Currency')
|
||||
related='company_id.currency_id', string='Company Currency')
|
||||
# extra_cost is per unit in the UoM of the mrp.production (product_uom_id)
|
||||
extra_cost = fields.Float(
|
||||
compute='_compute_extra_cost', store=True, readonly=False,
|
||||
help="For a regular production order, it takes into account the labor cost "
|
||||
"and the extra cost defined on the bill of material.")
|
||||
|
||||
def compute_order_unit_cost(self):
|
||||
self.ensure_one()
|
||||
mo_total_price = 0.0 # In the UoM of the M0
|
||||
labor_cost_per_unit = 0.0 # In the UoM of the product
|
||||
extra_cost_per_unit = 0.0 # In the UoM of the product
|
||||
subcontract_cost_per_unit = 0.0
|
||||
# I read the raw materials MO, not on BOM, in order to make
|
||||
# it work with the "dynamic" BOMs (few raw material are auto-added
|
||||
# on the fly on MO)
|
||||
prec = self.env['decimal.precision'].precision_get(
|
||||
'Product Unit of Measure')
|
||||
for raw_smove in self.move_raw_ids:
|
||||
# I don't filter on state, in order to make it work with
|
||||
# partial productions
|
||||
# For partial productions, mo.product_qty is not updated
|
||||
# so we compute with fully qty and we compute with all raw
|
||||
# materials (consumed or not), so it gives a good price
|
||||
# per unit at the end
|
||||
raw_price = raw_smove.product_id.standard_price
|
||||
raw_material_cost = raw_price * raw_smove.product_qty
|
||||
logger.info(
|
||||
'MO %s product %s: raw_material_cost=%s',
|
||||
self.name, raw_smove.product_id.display_name,
|
||||
raw_material_cost)
|
||||
mo_total_price += raw_material_cost
|
||||
if self.bom_id:
|
||||
bom = self.bom_id
|
||||
# if not bom.total_labour_cost:
|
||||
# raise orm.except_orm(
|
||||
# _('Error:'),
|
||||
# _("Total Labor Cost is 0 on bill of material '%s'.")
|
||||
# % bom.name)
|
||||
if float_is_zero(bom.product_qty, precision_digits=prec):
|
||||
raise UserError(_(
|
||||
"Missing Product Quantity on bill of material '%s'.")
|
||||
% bom.display_name)
|
||||
bom_qty_product_uom = bom.product_uom_id._compute_quantity(
|
||||
bom.product_qty, bom.product_tmpl_id.uom_id)
|
||||
assert bom_qty_product_uom > 0, 'BoM qty should be positive'
|
||||
labor_cost_per_unit = bom.total_labour_cost / bom_qty_product_uom
|
||||
extra_cost_per_unit = bom.extra_cost / bom_qty_product_uom
|
||||
if bom.type == 'subcontract':
|
||||
one_finished_move = self.env['stock.move'].search([
|
||||
('production_id', '=', self.id),
|
||||
('product_id', '=', self.product_id.id),
|
||||
('move_dest_ids', '!=', False)], limit=1)
|
||||
if one_finished_move:
|
||||
subcontract_cost_per_unit = one_finished_move.move_dest_ids[0].price_unit
|
||||
# mo_standard_price and labor_cost_per_unit are
|
||||
# in the UoM of the product (not of the MO/BOM)
|
||||
mo_qty_product_uom = self.product_uom_id._compute_quantity(
|
||||
self.product_qty, self.product_id.uom_id)
|
||||
assert mo_qty_product_uom > 0, 'MO qty should be positive'
|
||||
mo_standard_price = mo_total_price / mo_qty_product_uom
|
||||
logger.info(
|
||||
'MO %s: labor_cost_per_unit=%s extra_cost_per_unit=%s '
|
||||
'subcontract_cost_per_unit=%s',
|
||||
self.name, labor_cost_per_unit, extra_cost_per_unit,
|
||||
subcontract_cost_per_unit)
|
||||
mo_standard_price += labor_cost_per_unit
|
||||
mo_standard_price += extra_cost_per_unit
|
||||
mo_standard_price += subcontract_cost_per_unit
|
||||
return mo_standard_price
|
||||
# Strategy for v14 : we write labor costs and bom's extra cost on the native field extra_cost
|
||||
# of mrp.production => it is automatically added by the code of mrp_account
|
||||
|
||||
def post_inventory(self):
|
||||
'''This is the method where _action_done() is called on finished move
|
||||
So we write on 'price_unit' of the finished move and THEN we call
|
||||
super() which will call _action_done() which itself calls
|
||||
product_price_update_before_done()'''
|
||||
for order in self:
|
||||
if order.product_id.cost_method == 'average':
|
||||
unit_cost = order.compute_order_unit_cost()
|
||||
order.write({'unit_cost': unit_cost})
|
||||
logger.info('MO %s: unit_cost=%s', order.name, unit_cost)
|
||||
order.move_finished_ids.filtered(
|
||||
lambda x: x.product_id == order.product_id).write({
|
||||
'price_unit': unit_cost})
|
||||
return super(MrpProduction, self).post_inventory()
|
||||
@api.depends('bom_id', 'product_id')
|
||||
def _compute_extra_cost(self):
|
||||
for prod in self:
|
||||
bom = prod.bom_id
|
||||
if bom and bom.type == 'normal':
|
||||
extra_cost_bom_qty_uom = bom.extra_cost + bom.total_labour_cost
|
||||
extra_cost_per_unit_in_prod_uom = 0
|
||||
qty_prod_uom = bom.product_uom_id._compute_quantity(bom.product_qty, prod.product_uom_id)
|
||||
if qty_prod_uom:
|
||||
extra_cost_per_unit_in_prod_uom = extra_cost_bom_qty_uom / qty_prod_uom
|
||||
prod.extra_cost = extra_cost_per_unit_in_prod_uom
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<record id="labour_cost_profile_rule" model="ir.rule">
|
||||
<field name="name">Labour Cost Profile multi-company</field>
|
||||
<field name="model_id" ref="model_labour_cost_profile"/>
|
||||
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'child_of', [user.company_id.id])]</field>
|
||||
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright (C) 2016-2019 Akretion (http://www.akretion.com/)
|
||||
Copyright (C) 2016-2024 Akretion (http://www.akretion.com/)
|
||||
@author Alexis de Lattre <alexis.delattre@akretion.com>
|
||||
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
-->
|
||||
@@ -13,23 +13,25 @@
|
||||
<field name="model">mrp.bom</field>
|
||||
<field name="inherit_id" ref="mrp.mrp_bom_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="picking_type_id" position="after">
|
||||
<field name="total_components_cost" widget="monetary"
|
||||
options="{'currency_field': 'company_currency_id'}"/>
|
||||
<field name="total_labour_cost" widget="monetary"
|
||||
options="{'currency_field': 'company_currency_id'}"/>
|
||||
<field name="extra_cost" widget="monetary"
|
||||
options="{'currency_field': 'company_currency_id'}"/>
|
||||
<label for="total_cost"/>
|
||||
<div>
|
||||
<field name="total_cost" widget="monetary"
|
||||
options="{'currency_field': 'company_currency_id'}"
|
||||
class="oe_inline"/>
|
||||
<button type="object" name="manual_update_product_standard_price"
|
||||
string="Update Cost Price of Product" class="oe_link"/>
|
||||
</div>
|
||||
<field name="company_currency_id" invisible="1"/>
|
||||
</field>
|
||||
<xpath expr="//page[@name='miscellaneous']/group" position="inside">
|
||||
<group name="costs">
|
||||
<field name="total_components_cost" widget="monetary"
|
||||
options="{'currency_field': 'company_currency_id'}"/>
|
||||
<field name="total_labour_cost" widget="monetary"
|
||||
options="{'currency_field': 'company_currency_id'}"/>
|
||||
<field name="extra_cost" widget="monetary"
|
||||
options="{'currency_field': 'company_currency_id'}"/>
|
||||
<label for="total_cost"/>
|
||||
<div>
|
||||
<field name="total_cost" widget="monetary"
|
||||
options="{'currency_field': 'company_currency_id'}"
|
||||
class="oe_inline"/>
|
||||
<button type="object" name="manual_update_product_standard_price"
|
||||
string="Update Cost Price of Product" class="oe_link"/>
|
||||
</div>
|
||||
<field name="company_currency_id" invisible="1"/>
|
||||
</group>
|
||||
</xpath>
|
||||
<notebook position="inside">
|
||||
<page string="Labour" name="labour_lines">
|
||||
<group name="labour_lines_grp">
|
||||
@@ -117,10 +119,10 @@
|
||||
<field name="inherit_id" ref="mrp.mrp_production_form_view"/>
|
||||
<field name="model">mrp.production</field>
|
||||
<field name="arch" type="xml">
|
||||
<field name="availability" position="after">
|
||||
<field name="unit_cost" widget="monetary" options="{'currency_field': 'company_currency_id'}" attrs="{'invisible': [('state', '!=', 'done')]}"/>
|
||||
<xpath expr="//page[@name='miscellaneous']//field[@name='origin']/.." position="inside">
|
||||
<field name="extra_cost" widget="monetary" options="{'currency_field': 'company_currency_id'}"/>
|
||||
<field name="company_currency_id" invisible="1"/>
|
||||
</field>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user