changeset a45fc9c0007c in modules/product:default
details: https://hg.tryton.org/modules/product?cmd=changeset;node=a45fc9c0007c
description:
Allow UoM conversion between different categories
issue8238
review265151005
diffstat:
CHANGELOG | 1 +
doc/index.rst | 9 ++-
tests/test_product.py | 47 +++++++++++++++++++++
uom.py | 107 +++++++++++++++++++++++++++++++++++--------------
4 files changed, 130 insertions(+), 34 deletions(-)
diffs (306 lines):
diff -r e14322b1fac0 -r a45fc9c0007c CHANGELOG
--- a/CHANGELOG Sun Jun 16 15:42:42 2019 +0200
+++ b/CHANGELOG Mon Jul 08 17:34:44 2019 +0200
@@ -1,3 +1,4 @@
+* Allow UoM conversion between different categories
* Add product identifiers
Version 5.2.0 - 2019-05-06
diff -r e14322b1fac0 -r a45fc9c0007c doc/index.rst
--- a/doc/index.rst Sun Jun 16 15:42:42 2019 +0200
+++ b/doc/index.rst Mon Jul 08 17:34:44 2019 +0200
@@ -76,9 +76,12 @@
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 -r e14322b1fac0 -r a45fc9c0007c tests/test_product.py
--- a/tests/test_product.py Sun Jun 16 15:42:42 2019 +0200
+++ b/tests/test_product.py Mon Jul 08 17:34:44 2019 +0200
@@ -168,6 +168,29 @@
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'
pool = Pool()
@@ -212,6 +235,30 @@
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'
pool = Pool()
diff -r e14322b1fac0 -r a45fc9c0007c uom.py
--- a/uom.py Sun Jun 16 15:42:42 2019 +0200
+++ b/uom.py Mon Jul 08 17:34:44 2019 +0200
@@ -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 .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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
# >>> 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'