first commit
This commit is contained in:
324
modules/product/uom.py
Normal file
324
modules/product/uom.py
Normal file
@@ -0,0 +1,324 @@
|
||||
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
|
||||
from decimal import Decimal
|
||||
from math import ceil, floor, log10
|
||||
|
||||
import trytond.config as config
|
||||
from trytond.i18n import gettext
|
||||
from trytond.model import (
|
||||
Check, DeactivableMixin, DigitsMixin, ModelSQL, ModelView, SymbolMixin,
|
||||
fields)
|
||||
from trytond.pyson import Eval, If
|
||||
|
||||
from .exceptions import UOMAccessError, UOMValidationError
|
||||
|
||||
__all__ = ['uom_conversion_digits']
|
||||
|
||||
uom_conversion_digits = (
|
||||
config.getint('product', 'uom_conversion_decimal', default=12),) * 2
|
||||
|
||||
|
||||
class UomCategory(ModelSQL, ModelView):
|
||||
__name__ = 'product.uom.category'
|
||||
name = fields.Char('Name', required=True, translate=True)
|
||||
uoms = fields.One2Many('product.uom', 'category', "Units of Measure")
|
||||
|
||||
@classmethod
|
||||
def __setup__(cls):
|
||||
super().__setup__()
|
||||
cls._order.insert(0, ('name', 'ASC'))
|
||||
|
||||
|
||||
class Uom(SymbolMixin, DigitsMixin, DeactivableMixin, ModelSQL, ModelView):
|
||||
__name__ = 'product.uom'
|
||||
name = fields.Char("Name", size=None, required=True, translate=True)
|
||||
symbol = fields.Char(
|
||||
"Symbol", size=10, required=True, translate=True,
|
||||
help="The symbol that represents the unit of measure.")
|
||||
category = fields.Many2One(
|
||||
'product.uom.category', "Category", required=True, ondelete='RESTRICT',
|
||||
help="The category that contains the unit of measure.\n"
|
||||
"Conversions between different units of measure can be done if they "
|
||||
"are in the same category.")
|
||||
rate = fields.Float(
|
||||
"Rate", digits=uom_conversion_digits, required=True,
|
||||
domain=[
|
||||
If(Eval('factor', 0) == 0, ('rate', '!=', 0), ()),
|
||||
],
|
||||
help="The coefficient for the formula:\n"
|
||||
"1 (base unit) = coef (this unit)")
|
||||
factor = fields.Float(
|
||||
"Factor", digits=uom_conversion_digits, required=True,
|
||||
domain=[
|
||||
If(Eval('rate', 0) == 0, ('factor', '!=', 0), ()),
|
||||
],
|
||||
help="The coefficient for the formula:\n"
|
||||
"coefficient (base unit) = 1 (this unit)")
|
||||
rounding = fields.Float(
|
||||
"Rounding Precision",
|
||||
digits=(None, Eval('digits', None)), required=True,
|
||||
domain=[
|
||||
('rounding', '>', 0),
|
||||
],
|
||||
help="The accuracy to which values are rounded.")
|
||||
digits = fields.Integer(
|
||||
"Display Digits", required=True,
|
||||
help="The number of digits to display after the decimal separator.")
|
||||
|
||||
@classmethod
|
||||
def __setup__(cls):
|
||||
super().__setup__()
|
||||
t = cls.__table__()
|
||||
cls._sql_constraints += [
|
||||
('non_zero_rate_factor', Check(t, (t.rate != 0) | (t.factor != 0)),
|
||||
'product.msg_uom_no_zero_factor_rate')
|
||||
]
|
||||
cls._order.insert(0, ('name', 'ASC'))
|
||||
|
||||
@staticmethod
|
||||
def default_rate():
|
||||
return 1.0
|
||||
|
||||
@staticmethod
|
||||
def default_factor():
|
||||
return 1.0
|
||||
|
||||
@staticmethod
|
||||
def default_rounding():
|
||||
return 0.01
|
||||
|
||||
@staticmethod
|
||||
def default_digits():
|
||||
return 2
|
||||
|
||||
@fields.depends('factor')
|
||||
def on_change_factor(self):
|
||||
if (self.factor or 0.0) == 0.0:
|
||||
self.rate = 0.0
|
||||
else:
|
||||
self.rate = round(1.0 / self.factor, uom_conversion_digits[1])
|
||||
|
||||
@fields.depends('rate')
|
||||
def on_change_rate(self):
|
||||
if (self.rate or 0.0) == 0.0:
|
||||
self.factor = 0.0
|
||||
else:
|
||||
self.factor = round(
|
||||
1.0 / self.rate, uom_conversion_digits[1])
|
||||
|
||||
@classmethod
|
||||
def search_rec_name(cls, name, clause):
|
||||
if clause[1].startswith('!') or clause[1].startswith('not '):
|
||||
bool_op = 'AND'
|
||||
else:
|
||||
bool_op = 'OR'
|
||||
return [bool_op,
|
||||
(cls._rec_name,) + tuple(clause[1:]),
|
||||
('symbol',) + tuple(clause[1:]),
|
||||
]
|
||||
|
||||
def round(self, number):
|
||||
return _round(self, number, func=round)
|
||||
|
||||
def ceil(self, number):
|
||||
return _round(self, number, func=ceil)
|
||||
|
||||
def floor(self, number):
|
||||
return _round(self, number, func=floor)
|
||||
|
||||
@classmethod
|
||||
def validate_fields(cls, uoms, field_names):
|
||||
super().validate_fields(uoms, field_names)
|
||||
cls.check_factor_and_rate(uoms, field_names)
|
||||
|
||||
@classmethod
|
||||
def check_factor_and_rate(cls, uoms, field_names=None):
|
||||
"Check coherence between factor and rate"
|
||||
if field_names and not (field_names & {'rate', 'factor'}):
|
||||
return
|
||||
for uom in uoms:
|
||||
if uom.rate == uom.factor == 0.0:
|
||||
continue
|
||||
if (uom.rate != round(
|
||||
1.0 / uom.factor, uom_conversion_digits[1])
|
||||
and uom.factor != round(
|
||||
1.0 / uom.rate, uom_conversion_digits[1])):
|
||||
raise UOMValidationError(
|
||||
gettext('product.msg_uom_incompatible_factor_rate',
|
||||
uom=uom.rec_name))
|
||||
|
||||
@classmethod
|
||||
def check_modification(cls, mode, uoms, values=None, external=False):
|
||||
super().check_modification(
|
||||
mode, uoms, values=values, external=external)
|
||||
if (mode == 'write'
|
||||
and values.keys() & {'factor', 'rate', 'category', 'digits'}):
|
||||
for uom in uoms:
|
||||
for field_name in values.keys() & {'factor', 'rate'}:
|
||||
if values[field_name] != getattr(uom, field_name):
|
||||
raise UOMAccessError(gettext(
|
||||
'product.msg_uom_modify_%s' % field_name,
|
||||
uom=uom.rec_name),
|
||||
gettext('product.msg_uom_modify_options'))
|
||||
if 'category' in values:
|
||||
if values['category'] != uom.category.id:
|
||||
raise UOMAccessError(gettext(
|
||||
'product.msg_uom_modify_category',
|
||||
uom=uom.rec_name),
|
||||
gettext('product.msg_uom_modify_options'))
|
||||
if 'digits' in values:
|
||||
if values['digits'] < uom.digits:
|
||||
raise UOMAccessError(gettext(
|
||||
'product.msg_uom_decrease_digits',
|
||||
uom=uom.rec_name),
|
||||
gettext('product.msg_uom_modify_options'))
|
||||
|
||||
@property
|
||||
def accurate_field(self):
|
||||
"""
|
||||
Select the more accurate field.
|
||||
It chooses the field that has the least decimal.
|
||||
"""
|
||||
return _accurate_operator(self.factor, self.rate)
|
||||
|
||||
@classmethod
|
||||
def compute_qty(cls, from_uom, qty, to_uom, round=True,
|
||||
factor=None, rate=None):
|
||||
"""
|
||||
Convert quantity for given uom's.
|
||||
|
||||
When converting between uom's from different categories the factor and
|
||||
rate provide the ratio to use to convert between the category's base
|
||||
uom's.
|
||||
"""
|
||||
if not qty or (from_uom is None and to_uom is None):
|
||||
return qty
|
||||
if from_uom is None:
|
||||
raise ValueError("missing from_UoM")
|
||||
if to_uom is None:
|
||||
raise ValueError("missing to_UoM")
|
||||
if from_uom.category.id != to_uom.category.id:
|
||||
if not factor and not rate:
|
||||
raise ValueError(
|
||||
"cannot convert between %s and %s without a factor or rate"
|
||||
% (from_uom.category.name, to_uom.category.name))
|
||||
elif factor or rate:
|
||||
raise ValueError("factor and rate not allowed for same category")
|
||||
|
||||
if from_uom != to_uom:
|
||||
if from_uom.accurate_field == 'factor':
|
||||
amount = qty * from_uom.factor
|
||||
else:
|
||||
amount = qty / from_uom.rate
|
||||
|
||||
if factor and rate:
|
||||
if _accurate_operator(factor, rate) == 'rate':
|
||||
factor = None
|
||||
else:
|
||||
rate = None
|
||||
if factor:
|
||||
amount *= factor
|
||||
elif rate:
|
||||
amount /= rate
|
||||
|
||||
if to_uom.accurate_field == 'factor':
|
||||
amount = amount / to_uom.factor
|
||||
else:
|
||||
amount = amount * to_uom.rate
|
||||
else:
|
||||
amount = qty
|
||||
|
||||
if round:
|
||||
amount = to_uom.round(amount)
|
||||
|
||||
return amount
|
||||
|
||||
@classmethod
|
||||
def compute_price(cls, from_uom, price, to_uom, factor=None, rate=None):
|
||||
"""
|
||||
Convert price for given uom's.
|
||||
|
||||
When converting between uom's from different categories the factor and
|
||||
rate provide the ratio to use to convert between the category's base
|
||||
uom's.
|
||||
"""
|
||||
if not price or (from_uom is None and to_uom is None):
|
||||
return price
|
||||
if from_uom is None:
|
||||
raise ValueError("missing from_UoM")
|
||||
if to_uom is None:
|
||||
raise ValueError("missing to_UoM")
|
||||
if from_uom.category.id != to_uom.category.id:
|
||||
if not factor and not rate:
|
||||
raise ValueError(
|
||||
"cannot convert between %s and %s without a factor or rate"
|
||||
% (from_uom.category.name, to_uom.category.name))
|
||||
elif factor or rate:
|
||||
raise ValueError("factor and rate not allow for same category")
|
||||
|
||||
if from_uom != to_uom:
|
||||
format_ = '%%.%df' % uom_conversion_digits[1]
|
||||
|
||||
if from_uom.accurate_field == 'factor':
|
||||
new_price = price / Decimal(format_ % from_uom.factor)
|
||||
else:
|
||||
new_price = price * Decimal(format_ % from_uom.rate)
|
||||
|
||||
if factor and rate:
|
||||
if _accurate_operator(factor, rate) == 'rate':
|
||||
factor = None
|
||||
else:
|
||||
rate = None
|
||||
if factor:
|
||||
new_price /= Decimal(factor)
|
||||
elif rate:
|
||||
new_price *= Decimal(rate)
|
||||
|
||||
if to_uom.accurate_field == 'factor':
|
||||
new_price = new_price * Decimal(format_ % to_uom.factor)
|
||||
else:
|
||||
new_price = new_price / Decimal(format_ % to_uom.rate)
|
||||
else:
|
||||
new_price = price
|
||||
|
||||
return new_price
|
||||
|
||||
|
||||
def _round(uom, number, func=round):
|
||||
if not number:
|
||||
# Avoid unnecessary computation
|
||||
return number
|
||||
precision = uom.rounding
|
||||
# Convert precision into an integer greater than 1 to avoid precision lost.
|
||||
# This works for most cases because rounding is often: n * 10**i
|
||||
if precision < 1:
|
||||
exp = -floor(log10(precision))
|
||||
factor = 10 ** exp
|
||||
number *= factor
|
||||
precision *= factor
|
||||
else:
|
||||
factor = 1
|
||||
# Divide by factor which is an integer to avoid precision lost due to
|
||||
# multiplication by float < 1.
|
||||
# example:
|
||||
# >>> 3 * 0.1
|
||||
# 0.30000000000000004
|
||||
# >>> 3 / 10.
|
||||
# 0.3
|
||||
return func(number / precision) * precision / factor
|
||||
|
||||
|
||||
def _accurate_operator(factor, rate):
|
||||
lengths = {}
|
||||
for name, value in [('rate', rate), ('factor', factor)]:
|
||||
format_ = '%%.%df' % uom_conversion_digits[1]
|
||||
lengths[name] = len((format_ % value).split('.')[1].rstrip('0'))
|
||||
if lengths['rate'] < lengths['factor']:
|
||||
return 'rate'
|
||||
elif lengths['factor'] < lengths['rate']:
|
||||
return 'factor'
|
||||
elif factor >= 1.0:
|
||||
return 'factor'
|
||||
else:
|
||||
return 'rate'
|
||||
Reference in New Issue
Block a user