trytond-product_cost_plan/plan.py

797 lines
27 KiB
Python

# The COPYRIGHT file at the top level of this repository contains the full
# copyright notices and license terms.
from decimal import Decimal
# from sql import Null
from trytond.model import Check, ModelSQL, ModelView, DeactivableMixin, fields, tree
from trytond.pool import Pool
from trytond.pyson import Eval, Bool, If
from trytond.transaction import Transaction
from trytond.wizard import Wizard, StateView, StateAction, Button
from trytond.i18n import gettext
from trytond.exceptions import UserWarning
from trytond.modules.product import price_digits, round_price
from trytond.exceptions import UserError
__all__ = ['PlanCostType', 'Plan', 'PlanBOM', 'PlanProductLine', 'PlanCost',
'CreateBomStart', 'CreateBom']
class PlanCostType(ModelSQL, ModelView):
'Plan Cost Type'
__name__ = 'product.cost.plan.cost.type'
name = fields.Char('Name', required=True, translate=True)
system = fields.Boolean('System Managed', readonly=True)
plan_field_name = fields.Char('Plan Field Name', readonly=True)
class Plan(DeactivableMixin, ModelSQL, ModelView):
'Product Cost Plan'
__name__ = 'product.cost.plan'
number = fields.Char('Number', readonly=True)
name = fields.Char('Name', required=True)
product = fields.Many2One('product.product', 'Product')
product_uom_category = fields.Function(
fields.Many2One('product.uom.category', 'Product UoM Category'),
'on_change_with_product_uom_category')
quantity = fields.Float('Quantity', digits=(16, Eval('uom_digits', 2)),
required=True, depends=['uom_digits'])
uom = fields.Many2One('product.uom', 'UoM', required=True, domain=[
If(Bool(Eval('product')),
('category', '=', Eval('product_uom_category')),
('id', '!=', -1)),
],
states={
'readonly': Bool(Eval('product')),
}, depends=['product', 'product_uom_category'])
uom_digits = fields.Function(fields.Integer('UoM Digits'),
'on_change_with_uom_digits')
bom = fields.Many2One('production.bom', 'BOM',
depends=['product'], domain=[
('output_products', '=', Eval('product', 0)),
])
boms = fields.One2Many('product.cost.plan.bom_line', 'plan', 'BOMs')
products = fields.One2Many('product.cost.plan.product_line', 'plan',
'Products')
all_products = fields.Function(fields.One2Many(
'product.cost.plan.product_line', None, 'All Products'), 'get_all_products')
products_cost = fields.Function(fields.Numeric('Products Cost',
digits=price_digits),
'get_products_cost')
costs = fields.One2Many('product.cost.plan.cost', 'plan', 'Costs')
product_cost_price = fields.Function(fields.Numeric('Product Cost Price',
digits=price_digits),
'on_change_with_product_cost_price')
cost_price = fields.Function(fields.Numeric('Unit Cost Price',
digits=price_digits),
'get_cost_price')
notes = fields.Text('Notes')
@classmethod
def __setup__(cls):
super(Plan, cls).__setup__()
cls._buttons.update({
'compute': {
'icon': 'tryton-spreadsheet',
},
'update_product_cost_price': {
'icon': 'tryton-refresh',
},
})
def get_rec_name(self, name):
res = '[%s]' % self.number
if self.name:
res += ' ' + self.name
elif self.product:
res += ' ' + self.product.rec_name
return res
@classmethod
def search_rec_name(cls, name, clause):
return ['OR',
('number',) + tuple(clause[1:]),
('name',) + tuple(clause[1:]),
('product',) + tuple(clause[1:]),
]
@fields.depends('product', methods=['on_change_with_bom', 'find_boms'])
def on_change_product(self):
self.bom = None
if self.product:
self.name = self.product.rec_name
bom = self.on_change_with_bom()
self.bom = bom
self.boms = [x[1] for x in self.find_boms()]
if self.product:
self.uom = self.product.default_uom
@fields.depends('uom')
def on_change_with_uom_digits(self, name=None):
return self.uom.digits if self.uom else 2
@fields.depends('product', 'uom')
def on_change_with_product_uom_category(self, name=None):
if self.product:
return self.product.default_uom_category.id
if self.uom:
return self.uom.category.id
@fields.depends('product')
def on_change_with_bom(self):
BOM = Pool().get('production.bom')
if not self.product:
return
boms = BOM.search([('output_products', '=', self.product.id)])
if boms:
return boms[0].id
@fields.depends('bom')
def find_boms(self, inputs=None):
res = []
if not self.bom:
return res
if not inputs:
inputs = self.bom.inputs
for input_ in inputs:
if input_.product.boms:
product_bom = input_.product.boms[0].bom
res.append((input_.product.id, product_bom.id))
if product_bom.id not in (x[1] for x in res):
res += self.find_boms(product_bom.inputs)
return res
@fields.depends('bom', 'boms', 'product')
def on_change_with_boms(self):
BomLine = Pool().get('product.cost.plan.bom_line')
if not self.bom:
return []
res = []
products = set(self.find_boms())
for product_id, _ in products:
res.append(BomLine(
product=product_id,
bom=None,
))
return res
def get_all_products(self, name):
# return all lines in product cost plan (parent and children)
product_lines = []
def _get_children(line):
product_lines.append(line)
for child in line.children:
_get_children(child)
for line in self.products:
_get_children(line)
return [x.id for x in product_lines]
def get_products_cost(self, name):
if not self.quantity:
return Decimal('0.0')
lines = Plan.get_all_inputs(self.products)
cost = sum(p.get_total_cost(None, round=False) for p in lines)
cost /= Decimal(str(self.quantity))
return round_price(cost)
@fields.depends('product')
def on_change_with_product_cost_price(self, name=None):
return self.product.cost_price if self.product else None
def get_cost_price(self, name):
return Decimal(sum(c.cost for c in self.costs if c.cost))
@classmethod
def clean(cls, plans):
pool = Pool()
ProductLine = pool.get('product.cost.plan.product_line')
CostLine = pool.get('product.cost.plan.cost')
Warning = Pool().get('res.user.warning')
product_lines = ProductLine.search([
('plan', 'in', [p.id for p in plans]),
])
if product_lines:
key = 'task_product_lines_will_be_removed.%d' % product_lines[0].id
if Warning.check(key):
raise UserWarning(key,
gettext('product_cost_plan.product_lines_will_be_removed'))
ProductLine.delete(product_lines)
with Transaction().set_context(reset_costs=True):
CostLine.delete(CostLine.search([
('plan', 'in', [p.id for p in plans]),
('system', '=', True),
]))
@classmethod
@ModelView.button
def compute(cls, plans):
pool = Pool()
ProductLine = pool.get('product.cost.plan.product_line')
CostLine = pool.get('product.cost.plan.cost')
cls.clean(plans)
to_create = []
for plan in plans:
if plan.product and plan.bom:
to_create.extend(plan.explode_bom(plan.product, plan.bom,
plan.quantity, plan.uom))
if to_create:
ProductLine.create(to_create)
to_create = []
for plan in plans:
to_create.extend(plan.get_costs())
if to_create:
CostLine.create(to_create)
def explode_bom(self, product, bom, quantity, uom):
"Returns products for the especified products"
pool = Pool()
Input = pool.get('production.bom.input')
res = []
plan_boms = {}
for plan_bom in self.boms:
if plan_bom.bom:
plan_boms[plan_bom.product.id] = plan_bom.bom
factor = bom.compute_factor(product, quantity, uom)
for input_ in bom.inputs:
product = input_.product
if product.id in plan_boms:
quantity = Input.compute_quantity(input_, factor)
res.extend(self.explode_bom(product, plan_boms[product.id],
quantity, input_.unit))
else:
line = self.get_product_line(input_, factor)
if line:
line['plan'] = self.id
res.append(line)
return res
def get_product_line(self, input_, factor):
"""
Returns a dict with values of the new line to create
params:
*input_*: Production.bom.input record for the product
*factor*: The factor to calculate the quantity
"""
pool = Pool()
UoM = pool.get('product.uom')
Input = pool.get('production.bom.input')
quantity = Input.compute_quantity(input_, factor)
party_stock = getattr(input_, 'party_stock', False)
product_cost_price = Decimal('0.0')
cost_price = Decimal('0.0')
cost_factor = Decimal(
UoM.compute_qty(input_.product.default_uom, 1, input_.unit))
if cost_factor != Decimal('0.0'):
product_cost_price = Decimal(input_.product.cost_price / cost_factor)
if not party_stock:
cost_price = product_cost_price
return {
'name': input_.product.rec_name,
'product': input_.product.id,
'quantity': quantity,
'uom': input_.unit.id,
'party_stock': getattr(input_, 'party_stock', False),
'product_cost_price': round_price(product_cost_price),
'cost_price': round_price(cost_price),
}
def get_costs(self):
"Returns the cost lines to be created on compute"
pool = Pool()
CostType = pool.get('product.cost.plan.cost.type')
ret = []
system_cost_types = CostType.search([
('system', '=', True),
])
for cost_type in system_cost_types:
ret.append(self._get_cost_line(cost_type))
return ret
def _get_cost_line(self, cost_type):
return {
'plan': self.id,
'type': cost_type.id,
'system': cost_type.system,
'internal_cost': Decimal('0'),
}
@classmethod
@ModelView.button
def update_product_cost_price(cls, plans):
for plan in plans:
if not plan.product:
continue
plan._update_product_cost_price()
plan.product.save()
plan.product.template.save()
def _update_product_cost_price(self):
pool = Pool()
Uom = pool.get('product.uom')
assert self.product
cost_price = Uom.compute_price(self.uom, self.cost_price,
self.product.default_uom)
if hasattr(self.product.__class__, 'cost_price'):
self.product.cost_price = round_price(cost_price)
else:
self.product.template.cost_price = round_price(cost_price)
def create_bom(self, name):
pool = Pool()
BOM = pool.get('production.bom')
ProductBOM = pool.get('product.product-production.bom')
Warning = pool.get('res.user.warning')
key = 'not_product_%s' % self.id,
if not self.product and Warning.check(key):
raise UserWarning(key,
gettext('product_cost_plan.lacks_the_product',
cost_plan=self.rec_name))
key = 'bom_already_exists%s' % self.id
if self.bom and Warning.check(key):
raise UserWarning(key,
gettext('product_cost_plan.bom_already_exists',
cost_plan=self.rec_name))
bom = BOM()
bom.name = name
bom.inputs = self._get_bom_inputs()
bom.outputs = self._get_bom_outputs()
bom.save()
self.bom = bom
self.save()
if self.product.boms:
# TODO: create new bom to allow diferent "versions"?
product_bom = self.product.boms[0]
key = 'product_already_has_bom%s' % self.id
if product_bom.bom and Warning.check(key):
raise UserWarning(key,
gettext('product_cost_plan.product_already_has_bom',
product=self.product.rec_name))
else:
product_bom = ProductBOM()
product_bom.product = self.product
product_bom.bom = bom
product_bom.save()
return bom
def _get_bom_outputs(self):
BOMOutput = Pool().get('production.bom.output')
outputs = []
if self.product:
output = BOMOutput()
output.product = self.product
output.unit = self.uom
output.quantity = self.quantity
outputs.append(output)
return outputs
@classmethod
def get_all_inputs(cls, lines):
lines = [x for x in lines]
for line in lines:
if not line.children:
continue
lines += cls.get_all_inputs(line.children)
return list(set(lines))
def _get_bom_inputs(self):
pool = Pool()
Uom = pool.get('product.uom')
Plan = pool.get('product.cost.plan')
inputs = {}
lines = Plan.get_all_inputs(self.products)
for line in lines:
if not line.product:
continue
input_ = self._get_input_line(line)
if input_.product.id not in inputs:
inputs[input_.product.id] = input_
continue
existing = inputs[input_.product.id]
existing.quantity += Uom.compute_qty(input_.unit, input_.quantity,
existing.uom)
return list(inputs.values())
def _get_input_line(self, line):
'Return the BOM Input line for a product line'
BOMInput = Pool().get('production.bom.input')
input_ = BOMInput()
input_.product = line.product
input_.unit = line.uom
input_.quantity = line.quantity
if hasattr(BOMInput, 'party_stock'):
input_.party_stock = line.party_stock
parent_line = line.parent
while parent_line:
input_.quantity = input_.unit.round(input_.quantity *
parent_line.quantity)
parent_line = parent_line.parent
return input_
@classmethod
def create(cls, vlist):
Config = Pool().get('production.configuration')
vlist = [x.copy() for x in vlist]
config = Config(1)
for values in vlist:
values['number'] = config.product_cost_plan_sequence.get()
return super(Plan, cls).create(vlist)
@classmethod
def copy(cls, plans, default=None):
if default is None:
default = {}
else:
default = default.copy()
default['products'] = None
default['bom'] = None
new_plans = []
for plan in plans:
new_plans.append(plan._copy_plan(default))
return new_plans
def _copy_plan(self, default):
ProductLine = Pool().get('product.cost.plan.product_line')
product_lines = []
def _get_children(line):
product_lines.append(line)
for child in line.children:
_get_children(child)
new_plan, = super(Plan, self).copy([self], default=default)
lines = ProductLine.copy(self.products, default={
'plan': new_plan.id,
})
# sure product.cost_plan.product_line that has parent, set null the plan
for line in lines:
for child in line.children:
_get_children(child)
ProductLine.write(product_lines, {'plan': None})
return new_plan
@classmethod
def delete(cls, plans):
CostLine = Pool().get('product.cost.plan.cost')
to_delete = []
for plan in plans:
to_delete += plan.costs
with Transaction().set_context(reset_costs=True):
CostLine.delete(to_delete)
super(Plan, cls).delete(plans)
class PlanBOM(ModelSQL, ModelView):
'Product Cost Plan BOM'
__name__ = 'product.cost.plan.bom_line'
plan = fields.Many2One('product.cost.plan', 'Plan', required=True,
ondelete='CASCADE')
product = fields.Many2One('product.product', 'Product', required=True)
bom = fields.Many2One('production.bom', 'BOM', domain=[
('output_products', '=', Eval('product', 0)),
], depends=['product'])
class PlanProductLine(ModelSQL, ModelView, tree(separator='/')):
'Product Cost Plan Product Line'
__name__ = 'product.cost.plan.product_line'
name = fields.Char('Name')
sequence = fields.Integer('Sequence')
parent = fields.Many2One('product.cost.plan.product_line', 'Parent')
children = fields.One2Many('product.cost.plan.product_line', 'parent',
'Children')
plan = fields.Many2One('product.cost.plan', 'Plan', ondelete='CASCADE')
product = fields.Many2One('product.product', 'Product', domain=[
('type', '!=', 'service'),
If(Bool(Eval('children')),
('default_uom.category', '=', Eval('uom_category')),
()),
], depends=['children', 'uom_category'])
quantity = fields.Float('Quantity', required=True,
digits=(16, Eval('uom_digits', 2)), depends=['uom_digits'])
uom_category = fields.Function(fields.Many2One('product.uom.category',
'UoM Category'), 'on_change_with_uom_category')
uom = fields.Many2One('product.uom', 'UoM', required=True, domain=[
If(Bool(Eval('children')) | Bool(Eval('product')),
('category', '=', Eval('uom_category')),
()),
], depends=['children', 'product', 'uom_category'])
uom_digits = fields.Function(fields.Integer('UoM Digits'),
'on_change_with_uom_digits')
party_stock = fields.Boolean('Party Stock',
help='Use stock owned by party instead of company stock.')
product_cost_price = fields.Numeric('Product Cost Price', digits=price_digits,
states={
'readonly': True,
}, depends=['product'])
cost_price = fields.Numeric('Cost Price', required=True,
digits=price_digits)
unit_cost = fields.Function(fields.Numeric('Unit Cost', digits=price_digits,
help="The cost of this product for each unit of plan's product."),
'get_unit_cost')
total_cost = fields.Function(fields.Numeric('Total Cost', digits=price_digits,
help="The cost of this product for total plan's quantity."),
'get_total_cost')
@classmethod
def __setup__(cls):
super(PlanProductLine, cls).__setup__()
cls._order.insert(0, ('sequence', 'ASC'))
@staticmethod
def order_sequence(tables):
table, _ = tables[None]
return [table.sequence == None, table.sequence]
@fields.depends('product', 'uom')
def on_change_product(self):
if self.product:
if (not self.uom
or self.uom.category != self.product.default_uom.category):
zero_cost_price = False
self.name = self.product.rec_name
if hasattr(self.product, 'may_belong_to_party'):
self.party_stock = self.product.may_belong_to_party
if self.product.may_belong_to_party:
zero_cost_price = True
self.uom = self.product.default_uom.id
self.product_cost_price = self.product.cost_price
if zero_cost_price:
self.cost_price = Decimal('0.0')
else:
self.cost_price = self.product.cost_price
else:
self.name = None
self.party_stock = False
self.uom = None
self.product_cost_price = None
@fields.depends('children', 'product', 'plan', '_parent_plan.uom')
def on_change_with_uom_category(self, name=None):
if self.children and self.children[0].uom:
return self.children[0].uom.category.id
elif self.product:
return self.product.default_uom.category.id
elif self.plan and self.plan.uom:
return self.plan.uom.category.id
@fields.depends('uom')
def on_change_with_uom_digits(self, name=None):
if self.uom:
return self.uom.digits
return 2
@fields.depends('party_stock', 'cost_price', 'product', 'uom')
def on_change_party_stock(self):
UoM = Pool().get('product.uom')
if self.party_stock:
self.cost_price = Decimal('0.0')
return
if not self.cost_price and self.product and self.uom:
cost = UoM.compute_price(self.product.default_uom,
self.product.cost_price, self.uom)
self.cost_price = round_price(cost)
@fields.depends('product', 'uom', 'cost_price')
def on_change_with_cost_price(self):
UoM = Pool().get('product.uom')
if (not self.product or not self.uom
or (self.cost_price != None
and self.cost_price != self.product.cost_price)):
cost = self.cost_price
else:
cost = UoM.compute_price(self.product.default_uom,
self.product.cost_price, self.uom)
if cost:
return round_price(cost)
return cost
@fields.depends('product', 'uom')
def on_change_with_product_cost_price(self):
UoM = Pool().get('product.uom')
if not self.product:
return
if not self.uom:
cost = self.product.cost_price
else:
cost = UoM.compute_price(self.product.default_uom,
self.product.cost_price, self.uom)
return round_price(cost or 0)
def get_plan(self):
if self.plan:
return self.plan
elif self.parent:
return self.parent.get_plan()
def get_unit_cost(self, name):
unit_cost = self.total_cost
plan = self.get_plan()
if unit_cost and plan and plan.quantity:
unit_cost /= Decimal(str(plan.quantity))
return round_price(unit_cost or 0)
def get_total_cost(self, name, round=True):
if not self.cost_price:
return Decimal('0.0')
# Quantity is the quantity of this line for all plan's quantity
quantity = self.quantity
line = self
while quantity and line.parent:
quantity *= line.parent.quantity
line = line.parent
if not quantity:
return Decimal('0.0')
total_cost = Decimal(str(quantity)) * self.cost_price
if not round:
return total_cost
return round_price(total_cost or 0)
@classmethod
def copy(cls, lines, default=None):
if default is None:
default = {}
else:
default = default.copy()
default['children'] = None
new_lines = []
for line in lines:
new_line, = super(PlanProductLine, cls).copy([line],
default=default)
new_lines.append(new_line)
new_default = default.copy()
new_default['parent'] = new_line.id
cls.copy(line.children, default=new_default)
return new_lines
@classmethod
def validate(cls, lines):
super().validate(lines)
for line in lines:
if ((line.parent and line.plan) or (not line.parent and not line.plan)):
raise UserError(gettext(
'product_cost_plan.msg_product_line_plan_parent',
line=line.rec_name))
STATES = {
'readonly': Eval('system', False),
}
DEPENDS = ['system']
class PlanCost(ModelSQL, ModelView):
'Plan Cost'
__name__ = 'product.cost.plan.cost'
plan = fields.Many2One('product.cost.plan', 'Plan', required=True,
ondelete='CASCADE')
sequence = fields.Integer('Sequence')
type = fields.Many2One('product.cost.plan.cost.type', 'Type', domain=[
('system', '=', Eval('system')),
], required=True, states=STATES, depends=DEPENDS)
internal_cost = fields.Numeric('Cost (Internal Use)', digits=price_digits,
readonly=True)
cost = fields.Function(fields.Numeric('Cost', digits=price_digits,
required=True, states=STATES, depends=DEPENDS),
'get_cost', setter='set_cost')
system = fields.Boolean('System Managed', readonly=True)
@classmethod
def __setup__(cls):
super(PlanCost, cls).__setup__()
cls._order.insert(0, ('sequence', 'ASC'))
@staticmethod
def order_sequence(tables):
table, _ = tables[None]
return [table.sequence == None, table.sequence]
def get_rec_name(self, name):
return self.type.rec_name
@classmethod
def search_rec_name(cls, name, clause):
return [('type.name',) + tuple(clause[1:])]
@staticmethod
def default_system():
return False
def get_cost(self, name):
if self.system:
cost = getattr(self.plan, self.type.plan_field_name)
else:
cost = self.internal_cost
return round_price(cost)
@classmethod
def set_cost(cls, records, name, value):
records_todo = [r for r in records if not r.system]
if records_todo:
cls.write(records_todo, {
'internal_cost': value,
})
@classmethod
def delete(cls, costs):
Warning = Pool().get('res.user.warning')
if not Transaction().context.get('reset_costs', False):
for cost in costs:
if cost.system:
key = 'task_delete_system_cost.%d' % cost.id
if Warning.check(key):
raise UserWarning('delete_system_cost',
gettext('product_cost_plan.delete_system_cost',
cost=cost.rec_name,
plan=cost.plan.rec_name))
super(PlanCost, cls).delete(costs)
class CreateBomStart(ModelView):
'Create BOM Start'
__name__ = 'product.cost.plan.create_bom.start'
name = fields.Char('Name', required=True)
class CreateBom(Wizard):
'Create BOM'
__name__ = 'product.cost.plan.create_bom'
start = StateView('product.cost.plan.create_bom.start',
'product_cost_plan.create_bom_start_view_form', [
Button('Cancel', 'end', 'tryton-cancel'),
Button('Ok', 'bom', 'tryton-ok', True),
])
bom = StateAction('production.act_bom_list')
def default_start(self, fields):
CostPlan = Pool().get('product.cost.plan')
plan = CostPlan(Transaction().context.get('active_id'))
return {
'name': plan.rec_name,
}
def do_bom(self, action):
CostPlan = Pool().get('product.cost.plan')
plan = CostPlan(Transaction().context.get('active_id'))
bom = plan.create_bom(self.start.name)
data = {
'res_id': [bom.id]
}
action['views'].reverse()
return action, data