Allow UoM conversion between different categories

issue8238
review265151005
This commit is contained in:
Cédric Krier
2019-07-08 17:34:44 +02:00
parent c1f769451b
commit 4fa7b4e319
4 changed files with 130 additions and 34 deletions
+1
View File
@@ -1,3 +1,4 @@
* Allow UoM conversion between different categories
* Add product identifiers
Version 5.2.0 - 2019-05-06
+6 -3
View File
@@ -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.
..
+47
View File
@@ -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'
+76 -31
View File
@@ -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'