From 4fa7b4e3190d5b039c0b290dfacd5cbbef2529a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 8 Jul 2019 17:34:44 +0200 Subject: [PATCH] Allow UoM conversion between different categories issue8238 review265151005 --- CHANGELOG | 1 + doc/index.rst | 9 ++-- tests/test_product.py | 47 +++++++++++++++++++ uom.py | 107 ++++++++++++++++++++++++++++++------------ 4 files changed, 130 insertions(+), 34 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 6dca00f963..0e08176b29 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Allow UoM conversion between different categories * Add product identifiers Version 5.2.0 - 2019-05-06 diff --git a/doc/index.rst b/doc/index.rst index 1f79353082..8c04fc65e7 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -76,9 +76,12 @@ Configuration The product module uses the section `product` to retrieve some parameters: -- `price_decimal`: defines the number of decimal with which the unit prices are - stored. The default value is `4`. +- `price_decimal`: defines the number of decimal digits with which the unit + prices are stored. The default value is `4`. + +- `uom_conversion_decimal`: defines the number of decimal digits with which the + conversion rates and factors of UoM are stored. The default value is `12`. .. warning:: - It can not be lowered once a database is created. + They can not be lowered once a database is created. .. diff --git a/tests/test_product.py b/tests/test_product.py index 9f967e7190..ea8720359d 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -167,6 +167,29 @@ class ProductTestCase(ModuleTestCase): self.assertRaises(ValueError, Uom.compute_qty, from_uom, qty, None, True) + @with_transaction() + def test_uom_compute_qty_category(self): + "Test uom compute_qty with different category" + pool = Pool() + Uom = pool.get('product.uom') + + g, = Uom.search([ + ('name', '=', "Gram"), + ], limit=1) + m3, = Uom.search([ + ('name', '=', "Cubic meter"), + ], limit=1) + + for quantity, result, keys in [ + (10000, 0.02, dict(factor=2)), + (20000, 0.01, dict(rate=2)), + (30000, 0.01, dict(rate=3, factor=0.333333, round=False)), + ]: + msg = 'quantity: %r, keys: %r' % (quantity, keys) + self.assertEqual( + Uom.compute_qty(g, quantity, m3, **keys), result, + msg=msg) + @with_transaction() def test_uom_compute_price(self): 'Test uom compute_price function' @@ -211,6 +234,30 @@ class ProductTestCase(ModuleTestCase): self.assertRaises(ValueError, Uom.compute_price, from_uom, price, None) + @with_transaction() + def test_uom_compute_price_category(self): + "Test uom compute_price with different category" + pool = Pool() + Uom = pool.get('product.uom') + + g, = Uom.search([ + ('name', '=', "Gram"), + ], limit=1) + m3, = Uom.search([ + ('name', '=', "Cubic meter"), + ], limit=1) + + for price, result, keys in [ + (Decimal('0.001'), Decimal('500'), dict(factor=2)), + (Decimal('0.002'), Decimal('4000'), dict(rate=2)), + (Decimal('0.003'), Decimal('9000'), dict( + rate=3, factor=0.333333)), + ]: + msg = 'price: %r, keys: %r' % (price, keys) + self.assertEqual( + Uom.compute_price(g, price, m3, **keys), result, + msg=msg) + @with_transaction() def test_product_search_domain(self): 'Test product.product search_domain function' diff --git a/uom.py b/uom.py index c5813f7f2a..4676ff568b 100644 --- a/uom.py +++ b/uom.py @@ -4,6 +4,7 @@ from decimal import Decimal from math import ceil, floor, log10 +from trytond.config import config from trytond.i18n import gettext from trytond.model import ModelView, ModelSQL, DeactivableMixin, fields, Check from trytond.model.exceptions import AccessError @@ -12,13 +13,16 @@ from trytond.transaction import Transaction from .exceptions import UOMValidationError -__all__ = ['UomCategory', 'Uom'] +__all__ = ['UomCategory', 'Uom', 'uom_conversion_digits'] STATES = { 'readonly': ~Eval('active', True), } DEPENDS = ['active'] +uom_conversion_digits = ( + config.getint('product', 'uom_conversion_decimal', default=12),) * 2 + class UomCategory(ModelSQL, ModelView): 'Product uom category' @@ -41,12 +45,14 @@ class Uom(DeactivableMixin, ModelSQL, ModelView): translate=True, depends=DEPENDS) category = fields.Many2One('product.uom.category', 'Category', required=True, ondelete='RESTRICT', states=STATES, depends=DEPENDS) - rate = fields.Float('Rate', digits=(12, 12), required=True, + rate = fields.Float( + "Rate", digits=uom_conversion_digits, required=True, states=STATES, depends=DEPENDS, help=('The coefficient for the formula:\n' '1 (base unit) = coef (this unit)')) - factor = fields.Float('Factor', digits=(12, 12), states=STATES, - required=True, depends=DEPENDS, + factor = fields.Float( + "Factor", digits=uom_conversion_digits, required=True, + states=STATES, depends=DEPENDS, help=('The coefficient for the formula:\n' 'coef (base unit) = 1 (this unit)')) rounding = fields.Float('Rounding Precision', @@ -92,7 +98,7 @@ class Uom(DeactivableMixin, ModelSQL, ModelView): if (self.factor or 0.0) == 0.0: self.rate = 0.0 else: - self.rate = round(1.0 / self.factor, self.__class__.rate.digits[1]) + self.rate = round(1.0 / self.factor, uom_conversion_digits[1]) @fields.depends('rate') def on_change_rate(self): @@ -100,7 +106,7 @@ class Uom(DeactivableMixin, ModelSQL, ModelView): self.factor = 0.0 else: self.factor = round( - 1.0 / self.rate, self.__class__.factor.digits[1]) + 1.0 / self.rate, uom_conversion_digits[1]) @classmethod def search_rec_name(cls, name, clause): @@ -133,9 +139,9 @@ class Uom(DeactivableMixin, ModelSQL, ModelView): if self.rate == self.factor == 0.0: return if (self.rate != round( - 1.0 / self.factor, self.__class__.rate.digits[1]) + 1.0 / self.factor, uom_conversion_digits[1]) and self.factor != round( - 1.0 / self.rate, self.__class__.factor.digits[1])): + 1.0 / self.rate, uom_conversion_digits[1])): raise UOMValidationError( gettext('product.msg_uom_incompatible_factor_rate', uom=self.rec_name)) @@ -173,24 +179,17 @@ class Uom(DeactivableMixin, ModelSQL, ModelView): Select the more accurate field. It chooses the field that has the least decimal. """ - lengths = {} - for field in ('rate', 'factor'): - format = '%%.%df' % getattr(self.__class__, field).digits[1] - lengths[field] = len((format % getattr(self, - field)).split('.')[1].rstrip('0')) - if lengths['rate'] < lengths['factor']: - return 'rate' - elif lengths['factor'] < lengths['rate']: - return 'factor' - elif self.factor >= 1.0: - return 'factor' - else: - return 'rate' + return _accurate_operator(self.factor, self.rate) @classmethod - def compute_qty(cls, from_uom, qty, to_uom, round=True): + 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 @@ -199,14 +198,28 @@ class Uom(DeactivableMixin, ModelSQL, ModelView): if to_uom is None: raise ValueError("missing to_uom") if from_uom.category.id != to_uom.category.id: - raise ValueError("cannot convert between %s and %s" + 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.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: @@ -218,9 +231,13 @@ class Uom(DeactivableMixin, ModelSQL, ModelView): return amount @classmethod - def compute_price(cls, from_uom, price, to_uom): + 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 @@ -229,21 +246,34 @@ class Uom(DeactivableMixin, ModelSQL, ModelView): if to_uom is None: raise ValueError("missing to_uom") if from_uom.category.id != to_uom.category.id: - raise ValueError('cannot convert between %s and %s' + 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") - factor_format = '%%.%df' % cls.factor.digits[1] - rate_format = '%%.%df' % cls.rate.digits[1] + format_ = '%%.%df' % uom_conversion_digits[1] if from_uom.accurate_field == 'factor': - new_price = price / Decimal(factor_format % from_uom.factor) + new_price = price / Decimal(format_ % from_uom.factor) else: - new_price = price * Decimal(rate_format % from_uom.rate) + 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(factor_format % to_uom.factor) + new_price = new_price * Decimal(format_ % to_uom.factor) else: - new_price = new_price / Decimal(rate_format % to_uom.rate) + new_price = new_price / Decimal(format_ % to_uom.rate) return new_price @@ -267,3 +297,18 @@ def _round(uom, number, func=round): # >>> 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'