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