571 lines
21 KiB
Python
571 lines
21 KiB
Python
# -*- coding: utf-8 -*-
|
|
# This file is part of lims_production module for Tryton.
|
|
# The COPYRIGHT file at the top level of this repository contains
|
|
# the full copyright notices and license terms.
|
|
from decimal import Decimal
|
|
from collections import defaultdict
|
|
from functools import partial
|
|
|
|
from trytond.model import ModelView, ModelSQL, fields
|
|
from trytond.pyson import PYSONEncoder, Eval, Equal, Bool, Not
|
|
from trytond.transaction import Transaction
|
|
from trytond.pool import PoolMeta, Pool
|
|
from trytond.wizard import Wizard, StateAction
|
|
from trytond.modules.product import price_digits
|
|
from trytond.exceptions import UserError
|
|
from trytond.i18n import gettext
|
|
|
|
__all__ = ['PurityDegree', 'Brand', 'FamilyEquivalent', 'Template', 'Product',
|
|
'LotCategory', 'Lot', 'Move', 'ShipmentIn', 'MoveProductionRelated']
|
|
|
|
|
|
class PurityDegree(ModelSQL, ModelView):
|
|
'Purity Degree'
|
|
__name__ = 'lims.purity.degree'
|
|
|
|
code = fields.Char('Code', required=True)
|
|
name = fields.Char('Name', required=True)
|
|
|
|
|
|
class Brand(ModelSQL, ModelView):
|
|
'Brand'
|
|
__name__ = 'lims.brand'
|
|
|
|
code = fields.Char('Code', required=True)
|
|
name = fields.Char('Name', required=True)
|
|
|
|
|
|
class FamilyEquivalent(ModelSQL, ModelView):
|
|
'Family/Equivalent'
|
|
__name__ = 'lims.family.equivalent'
|
|
|
|
name = fields.Char('Name', required=True)
|
|
code = fields.Char('Code', required=True)
|
|
uom = fields.Many2One('product.uom', 'UoM', required=True,
|
|
domain=[('category.lims_only_available', '=', False)],
|
|
help='The UoM\'s Category selected here will determine the set '
|
|
'of Products that can be related to this Family/Equivalent.')
|
|
products = fields.One2Many('product.template', 'family_equivalent',
|
|
'Products', readonly=True)
|
|
|
|
@classmethod
|
|
def validate(cls, family_equivalents):
|
|
super(FamilyEquivalent, cls).validate(family_equivalents)
|
|
for fe in family_equivalents:
|
|
fe.check_products()
|
|
|
|
def check_products(self):
|
|
if self.products:
|
|
main_category = self.uom.category
|
|
for product in self.products:
|
|
if main_category != product.default_uom.category:
|
|
raise UserError(gettext(
|
|
'lims_production.msg_invalid_product_uom_category'))
|
|
|
|
@classmethod
|
|
def copy(cls, family_equivalents, default=None):
|
|
if default is None:
|
|
default = {}
|
|
current_default = default.copy()
|
|
current_default['products'] = None
|
|
return super(FamilyEquivalent, cls).copy(family_equivalents,
|
|
default=current_default)
|
|
|
|
|
|
class Template(metaclass=PoolMeta):
|
|
__name__ = 'product.template'
|
|
|
|
common_name = fields.Char('Common name')
|
|
chemical_name = fields.Char('Chemical name')
|
|
commercial_name = fields.Char('Commercial name')
|
|
cas_number = fields.Char('CAS number')
|
|
commercial_brand = fields.Many2One('lims.brand', 'Commercial Brand')
|
|
purity_degree = fields.Many2One('lims.purity.degree', 'Purity Degree')
|
|
family_equivalent = fields.Many2One('lims.family.equivalent',
|
|
'Family/Equivalent',
|
|
domain=[('uom.category', '=', Eval('default_uom_category'))],
|
|
depends=['default_uom_category'],
|
|
help='The UoM\'s Category of Family/Equivalent which you can '
|
|
'select here will match the UoM\'s Category of this Product.')
|
|
controlled = fields.Boolean('Controlled')
|
|
reference_material = fields.Boolean('Reference Material')
|
|
certified = fields.Boolean('Certified')
|
|
|
|
@classmethod
|
|
def search_rec_name(cls, name, clause):
|
|
Product = Pool().get('product.product')
|
|
products = Product.search(['OR',
|
|
[('code',) + tuple(clause[1:])],
|
|
[('barcode',) + tuple(clause[1:])],
|
|
], order=[])
|
|
if products:
|
|
return [('id', 'in', list(map(int, [product.template.id
|
|
for product in products])))]
|
|
return super(Template, cls).search_rec_name(name, clause)
|
|
|
|
|
|
class Product(metaclass=PoolMeta):
|
|
__name__ = 'product.product'
|
|
|
|
catalog = fields.Char('Catalog', depends=['active'],
|
|
states={'readonly': ~Eval('active', True)})
|
|
barcode = fields.Char('Bar Code', depends=['active'],
|
|
states={'readonly': ~Eval('active', True)})
|
|
common_name = fields.Function(fields.Char('Common name'),
|
|
'get_template_field', searcher='search_template_field')
|
|
chemical_name = fields.Function(fields.Char('Chemical name'),
|
|
'get_template_field', searcher='search_template_field')
|
|
commercial_name = fields.Function(fields.Char('Commercial name'),
|
|
'get_template_field', searcher='search_template_field')
|
|
cas_number = fields.Function(fields.Char('CAS number'),
|
|
'get_template_field', searcher='search_template_field')
|
|
commercial_brand = fields.Function(fields.Many2One('lims.brand',
|
|
'Commercial Brand'), 'get_template_field',
|
|
searcher='search_template_field')
|
|
purity_degree = fields.Function(fields.Many2One('lims.purity.degree',
|
|
'Purity Degree'), 'get_template_field',
|
|
searcher='search_template_field')
|
|
family_equivalent = fields.Function(fields.Many2One(
|
|
'lims.family.equivalent', 'Family/Equivalent'), 'get_template_field',
|
|
searcher='search_template_field')
|
|
controlled = fields.Function(fields.Boolean('Controlled'),
|
|
'get_template_field', searcher='search_template_field')
|
|
reference_material = fields.Function(fields.Boolean('Reference Material'),
|
|
'get_template_field', searcher='search_template_field')
|
|
certified = fields.Function(fields.Boolean('Certified'),
|
|
'get_template_field', searcher='search_template_field')
|
|
|
|
@classmethod
|
|
def search_rec_name(cls, name, clause):
|
|
res = super(Product, cls).search_rec_name(name, clause)
|
|
return ['OR',
|
|
res,
|
|
[('barcode', ) + tuple(clause[1:])]
|
|
]
|
|
|
|
@classmethod
|
|
def recompute_cost_price(cls, products):
|
|
# original function rewritten to use cost_price and
|
|
# quantity from Template
|
|
pool = Pool()
|
|
Template = pool.get('product.template')
|
|
|
|
digits = Template.cost_price.digits
|
|
write = Template.write
|
|
record = lambda p: p.template
|
|
|
|
costs = defaultdict(list)
|
|
for product in products:
|
|
if product.type == 'service':
|
|
continue
|
|
cost = getattr(product,
|
|
'recompute_cost_price_%s' % product.cost_price_method)()
|
|
cost = cost.quantize(Decimal(str(10.0 ** -digits[1])))
|
|
costs[cost].append(record(product))
|
|
|
|
if not costs:
|
|
return
|
|
|
|
to_write = []
|
|
for cost, records in costs.items():
|
|
to_write.append(records)
|
|
to_write.append({'cost_price': cost})
|
|
|
|
# Enforce check access for account_stock*
|
|
with Transaction().set_context(_check_access=True):
|
|
write(*to_write)
|
|
|
|
def recompute_cost_price_average(self):
|
|
# original function rewritten to use cost_price and
|
|
# quantity from Template
|
|
pool = Pool()
|
|
Move = pool.get('stock.move')
|
|
Currency = pool.get('currency.currency')
|
|
Uom = pool.get('product.uom')
|
|
|
|
context = Transaction().context
|
|
|
|
product_clause = ('product.template', '=', self.template.id)
|
|
|
|
moves = Move.search([
|
|
product_clause,
|
|
('state', '=', 'done'),
|
|
('company', '=', context.get('company')),
|
|
['OR',
|
|
[
|
|
('to_location.type', '=', 'storage'),
|
|
('from_location.type', '!=', 'storage'),
|
|
],
|
|
[
|
|
('from_location.type', '=', 'storage'),
|
|
('to_location.type', '!=', 'storage'),
|
|
],
|
|
],
|
|
], order=[('effective_date', 'ASC'), ('id', 'ASC')])
|
|
|
|
cost_price = Decimal(0)
|
|
quantity = 0
|
|
for move in moves:
|
|
qty = Uom.compute_qty(move.uom, move.quantity,
|
|
self.template.default_uom)
|
|
qty = Decimal(str(qty))
|
|
if move.from_location.type == 'storage':
|
|
qty *= -1
|
|
if (move.from_location.type in ['supplier', 'production'] or
|
|
move.to_location.type == 'supplier'):
|
|
with Transaction().set_context(date=move.effective_date):
|
|
unit_price = Currency.compute(
|
|
move.currency, move.unit_price,
|
|
move.company.currency, round=False)
|
|
unit_price = Uom.compute_price(move.uom, unit_price,
|
|
self.template.default_uom)
|
|
if quantity + qty != 0:
|
|
cost_price = (
|
|
(cost_price * quantity) + (unit_price * qty)
|
|
) / (quantity + qty)
|
|
quantity += qty
|
|
return cost_price
|
|
|
|
@classmethod
|
|
def get_template_field(cls, products, names):
|
|
result = {}
|
|
for name in names:
|
|
result[name] = {}
|
|
if name in ('commercial_brand', 'purity_degree',
|
|
'family_equivalent'):
|
|
for p in products:
|
|
field = getattr(p.template, name, None)
|
|
result[name][p.id] = field.id if field else None
|
|
else:
|
|
for p in products:
|
|
result[name][p.id] = getattr(p.template, name, None)
|
|
return result
|
|
|
|
@classmethod
|
|
def search_template_field(cls, name, clause):
|
|
return [('template.' + name,) + tuple(clause[1:])]
|
|
|
|
|
|
class LotCategory(ModelSQL, ModelView):
|
|
"Lot Category"
|
|
__name__ = "stock.lot.category"
|
|
_rec_name = 'name'
|
|
|
|
name = fields.Char('Name', required=True)
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super(LotCategory, cls).__setup__()
|
|
cls._order.insert(0, ('name', 'ASC'))
|
|
|
|
|
|
class Lot(metaclass=PoolMeta):
|
|
__name__ = 'stock.lot'
|
|
|
|
category = fields.Many2One('stock.lot.category', 'Category')
|
|
special_category = fields.Function(fields.Char('Category'),
|
|
'on_change_with_special_category')
|
|
|
|
stability = fields.Char('Stability', depends=['special_category'],
|
|
states={
|
|
'invisible': Not(Bool(Equal(Eval('special_category'),
|
|
'input_prod'))),
|
|
})
|
|
homogeneity = fields.Char('Homogeneity', depends=['special_category'],
|
|
states={
|
|
'invisible': Not(Bool(Equal(Eval('special_category'),
|
|
'input_prod'))),
|
|
})
|
|
concentration = fields.Char('Concentration',
|
|
depends=['special_category'], states={
|
|
'invisible': ~Eval('special_category').in_(
|
|
['input_prod', 'domestic_use']),
|
|
})
|
|
reception_date = fields.Date('Reception date',
|
|
depends=['special_category'], states={
|
|
'invisible': Not(Bool(Equal(Eval('special_category'),
|
|
'input_prod'))),
|
|
})
|
|
preparation_date = fields.Date('Preparation date',
|
|
depends=['special_category'], states={
|
|
'invisible': Not(Bool(Equal(Eval('special_category'),
|
|
'domestic_use'))),
|
|
})
|
|
common_name = fields.Function(fields.Char('Common name',
|
|
depends=['special_category'], states={
|
|
'invisible': Not(Bool(Equal(Eval('special_category'),
|
|
'input_prod'))),
|
|
}), 'get_common_name')
|
|
chemical_name = fields.Function(fields.Char('Chemical name',
|
|
depends=['special_category'], states={
|
|
'invisible': Not(Bool(Equal(Eval('special_category'),
|
|
'input_prod'))),
|
|
}), 'get_chemical_name')
|
|
commercial_name = fields.Function(fields.Char('Commercial name',
|
|
depends=['special_category'], states={
|
|
'invisible': Not(Bool(Equal(Eval('special_category'),
|
|
'input_prod'))),
|
|
}), 'get_commercial_name')
|
|
cas_number = fields.Function(fields.Char('CAS number',
|
|
depends=['special_category'], states={
|
|
'invisible': Not(Bool(Equal(Eval('special_category'),
|
|
'input_prod'))),
|
|
}), 'get_cas_number', searcher='search_cas_number')
|
|
commercial_brand = fields.Function(fields.Many2One('lims.brand',
|
|
'Commercial Brand', depends=['special_category'], states={
|
|
'invisible': Not(Bool(Equal(Eval('special_category'),
|
|
'input_prod'))),
|
|
}), 'get_commercial_brand')
|
|
catalog = fields.Function(fields.Char('Catalog',
|
|
depends=['special_category'], states={
|
|
'invisible': Not(Bool(Equal(Eval('special_category'),
|
|
'input_prod'))),
|
|
}), 'get_catalog')
|
|
purity_degree = fields.Function(fields.Many2One('lims.purity.degree',
|
|
'Purity Degree', depends=['special_category'], states={
|
|
'invisible': Not(Bool(Equal(Eval('special_category'),
|
|
'input_prod'))),
|
|
}), 'get_purity_degree')
|
|
solvent = fields.Many2One('product.product', 'Solvent',
|
|
depends=['special_category'], states={
|
|
'invisible': Not(Bool(Equal(Eval('special_category'),
|
|
'domestic_use'))),
|
|
})
|
|
technician = fields.Many2One('lims.laboratory.professional', 'Technician',
|
|
depends=['special_category'], states={
|
|
'invisible': ~Eval('special_category').in_(
|
|
['domestic_use', 'prod_sale']),
|
|
})
|
|
account_category = fields.Function(fields.Many2One('product.category',
|
|
'Account Category', depends=['special_category'], states={
|
|
'invisible': Not(Bool(Equal(Eval('special_category'),
|
|
'input_prod'))),
|
|
}), 'get_account_category', searcher='search_account_category')
|
|
exclusive_glp = fields.Boolean('Exclusive use GLP',
|
|
depends=['special_category'], states={
|
|
'invisible': Not(Bool(Equal(Eval('special_category'),
|
|
'input_prod')))})
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super(Lot, cls).__setup__()
|
|
cls.expiration_date.states['invisible'] = Eval(False)
|
|
|
|
@staticmethod
|
|
def default_exclusive_glp():
|
|
return False
|
|
|
|
@fields.depends('category', 'product', '_parent_product.purchasable',
|
|
'_parent_product.salable')
|
|
def on_change_with_special_category(self, name=None):
|
|
Config = Pool().get('lims.configuration')
|
|
if self.category:
|
|
config = Config(1)
|
|
if self.category == config.lot_category_input_prod:
|
|
return 'input_prod'
|
|
elif self.category == config.lot_category_prod_sale:
|
|
return 'prod_sale'
|
|
elif self.category == config.lot_category_prod_domestic_use:
|
|
return 'domestic_use'
|
|
elif self.product:
|
|
if (self.product.purchasable and not self.product.salable):
|
|
return 'input_prod'
|
|
elif (not self.product.purchasable and self.product.salable):
|
|
return 'prod_sale'
|
|
elif (not self.product.purchasable and not self.product.salable):
|
|
return 'domestic_use'
|
|
else:
|
|
return ''
|
|
|
|
def get_common_name(self, name=None):
|
|
if self.product:
|
|
return self.product.common_name
|
|
return ''
|
|
|
|
def get_chemical_name(self, name=None):
|
|
if self.product:
|
|
return self.product.chemical_name
|
|
return ''
|
|
|
|
def get_commercial_name(self, name=None):
|
|
if self.product:
|
|
return self.product.commercial_name
|
|
return ''
|
|
|
|
def get_cas_number(self, name=None):
|
|
if self.product:
|
|
return self.product.cas_number
|
|
return ''
|
|
|
|
@classmethod
|
|
def search_cas_number(cls, name, clause):
|
|
return [('product.cas_number',) + tuple(clause[1:])]
|
|
|
|
def get_commercial_brand(self, name=None):
|
|
if self.product and self.product.commercial_brand:
|
|
return self.product.commercial_brand.id
|
|
return None
|
|
|
|
def get_catalog(self, name=None):
|
|
if self.product:
|
|
return self.product.catalog
|
|
return ''
|
|
|
|
def get_purity_degree(self, name=None):
|
|
if self.product and self.product.purity_degree:
|
|
return self.product.purity_degree.id
|
|
return None
|
|
|
|
def get_account_category(self, name=None):
|
|
if self.product:
|
|
return self.product.account_category.id
|
|
return None
|
|
|
|
@classmethod
|
|
def create(cls, vlist):
|
|
pool = Pool()
|
|
Product = pool.get('product.product')
|
|
Config = pool.get('lims.configuration')
|
|
|
|
config = Config(1)
|
|
vlist = [x.copy() for x in vlist]
|
|
for values in vlist:
|
|
if not values.get('category'):
|
|
product = Product(values['product'])
|
|
lot_category_id = None
|
|
if (product.purchasable and not product.salable):
|
|
lot_category_id = (config.lot_category_input_prod.id
|
|
if config.lot_category_input_prod else None)
|
|
elif (not product.purchasable and product.salable):
|
|
lot_category_id = (config.lot_category_prod_sale.id
|
|
if config.lot_category_prod_sale else None)
|
|
elif (not product.purchasable and not product.salable):
|
|
lot_category_id = (
|
|
config.lot_category_prod_domestic_use.id if
|
|
config.lot_category_prod_domestic_use else None)
|
|
if lot_category_id:
|
|
values['category'] = lot_category_id
|
|
return super(Lot, cls).create(vlist)
|
|
|
|
@classmethod
|
|
def search_account_category(cls, name, clause):
|
|
return [('product.' + name,) + tuple(clause[1:])]
|
|
|
|
|
|
class Move(metaclass=PoolMeta):
|
|
__name__ = 'stock.move'
|
|
|
|
label_quantity = fields.Float("Label Quantity",
|
|
digits=(16, Eval('unit_digits', 2)), depends=['unit_digits'])
|
|
origin_purchase_unit_price = fields.Numeric('Unit Price',
|
|
digits=price_digits)
|
|
origin_purchase_currency = fields.Many2One('currency.currency',
|
|
'Currency')
|
|
|
|
@fields.depends('quantity')
|
|
def on_change_quantity(self):
|
|
if self.quantity:
|
|
self.label_quantity = self.quantity
|
|
|
|
@classmethod
|
|
def _get_origin(cls):
|
|
models = super(Move, cls)._get_origin()
|
|
models.append('production')
|
|
return models
|
|
|
|
def _update_product_cost_price(self, direction):
|
|
# original function rewritten to use cost_price and
|
|
# quantity from Template
|
|
pool = Pool()
|
|
Uom = pool.get('product.uom')
|
|
ProductTemplate = pool.get('product.template')
|
|
Location = pool.get('stock.location')
|
|
Currency = pool.get('currency.currency')
|
|
Date = pool.get('ir.date')
|
|
|
|
if direction == 'in':
|
|
quantity = self.quantity
|
|
elif direction == 'out':
|
|
quantity = -self.quantity
|
|
context = {}
|
|
locations = Location.search([
|
|
('type', '=', 'storage'),
|
|
])
|
|
context['with_childs'] = False
|
|
context['locations'] = [l.id for l in locations]
|
|
context['stock_date_end'] = Date.today()
|
|
with Transaction().set_context(context):
|
|
template = ProductTemplate(self.product.template.id)
|
|
|
|
qty = Uom.compute_qty(self.uom, quantity, template.default_uom)
|
|
qty = Decimal(str(qty))
|
|
|
|
product_qty = template.quantity
|
|
product_qty = Decimal(str(max(product_qty, 0)))
|
|
# convert wrt currency
|
|
with Transaction().set_context(date=self.effective_date):
|
|
unit_price = Currency.compute(self.currency, self.unit_price,
|
|
self.company.currency, round=False)
|
|
# convert wrt to the uom
|
|
unit_price = Uom.compute_price(self.uom, unit_price,
|
|
template.default_uom)
|
|
if product_qty + qty != Decimal('0.0'):
|
|
new_cost_price = (
|
|
(template.cost_price * product_qty) + (unit_price * qty)
|
|
) / (product_qty + qty)
|
|
else:
|
|
new_cost_price = template.cost_price
|
|
|
|
digits = ProductTemplate.cost_price.digits
|
|
write = partial(ProductTemplate.write, [template])
|
|
|
|
new_cost_price = new_cost_price.quantize(
|
|
Decimal(str(10.0 ** -digits[1])))
|
|
|
|
write({'cost_price': new_cost_price})
|
|
|
|
|
|
class ShipmentIn(metaclass=PoolMeta):
|
|
__name__ = 'stock.shipment.in'
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super(ShipmentIn, cls).__setup__()
|
|
cls.inventory_moves.states['readonly'] = Eval('state').in_(
|
|
['draft', 'cancel'])
|
|
|
|
def _get_inventory_move(self, incoming_move):
|
|
move = super(ShipmentIn, self)._get_inventory_move(incoming_move)
|
|
if not move:
|
|
return None
|
|
|
|
move.label_quantity = move.quantity
|
|
move.origin_purchase_currency = \
|
|
incoming_move.origin.purchase.currency.id
|
|
move.origin_purchase_unit_price = incoming_move.origin.unit_price
|
|
return move
|
|
|
|
|
|
class MoveProductionRelated(Wizard):
|
|
'Related Productions'
|
|
__name__ = 'lims.move.production_related'
|
|
|
|
start = StateAction('lims_production.act_production_related')
|
|
|
|
def do_start(self, action):
|
|
Move = Pool().get('stock.move')
|
|
|
|
move = Move(Transaction().context['active_id'])
|
|
|
|
production_id = None
|
|
if move.production_input:
|
|
production_id = move.production_input.id
|
|
elif move.production_output:
|
|
production_id = move.production_output.id
|
|
|
|
action['pyson_domain'] = PYSONEncoder().encode([
|
|
('id', '=', production_id),
|
|
])
|
|
|
|
return action, {}
|