Files
tradon/modules/product/uom.py
2026-03-14 09:42:12 +00:00

325 lines
11 KiB
Python

# 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'