[Erp5-report] r21250 - in /erp5/trunk/products: ERP5/Constraint/ ERP5/Document/ ERP5/Proper...
nobody at svn.erp5.org
nobody at svn.erp5.org
Fri May 30 17:05:38 CEST 2008
Author: jm
Date: Fri May 30 17:05:37 2008
New Revision: 21250
URL: http://svn.erp5.org?rev=21250&view=rev
Log:
* Add support for unit conversion:
* resources are described using Measure objects
* stock can be queried in any unit
* movements perform unit conversion
* New API to get the list of inventory for all periods at once:
* cf method getAllInventoryList in SimulationTool
* add mergeZRDBResults function in ERP5Type.Utils
to merge several Shared.DC.ZRDB.Results objects
* See following wiki articles:
* HowToUseMeasures
* TechnicalNotesOnUnitConversion
Added:
erp5/trunk/products/ERP5/Constraint/ResourceMeasuresConsistency.py (with props)
erp5/trunk/products/ERP5/Document/Measure.py (with props)
erp5/trunk/products/ERP5/PropertySheet/Measure.py (with props)
Modified:
erp5/trunk/products/ERP5/Document/Amount.py
erp5/trunk/products/ERP5/Document/Currency.py
erp5/trunk/products/ERP5/Document/Movement.py
erp5/trunk/products/ERP5/Document/Resource.py
erp5/trunk/products/ERP5/PropertySheet/Resource.py
erp5/trunk/products/ERP5/Tool/SimulationTool.py
erp5/trunk/products/ERP5/tests/testInventoryAPI.py
erp5/trunk/products/ERP5Type/Utils.py
Added: erp5/trunk/products/ERP5/Constraint/ResourceMeasuresConsistency.py
URL: http://svn.erp5.org/erp5/trunk/products/ERP5/Constraint/ResourceMeasuresConsistency.py?rev=21250&view=auto
==============================================================================
--- erp5/trunk/products/ERP5/Constraint/ResourceMeasuresConsistency.py (added)
+++ erp5/trunk/products/ERP5/Constraint/ResourceMeasuresConsistency.py Fri May 30 17:05:37 2008
@@ -1,0 +1,115 @@
+##############################################################################
+#
+# Copyright (c) 2008 Nexedi SA and Contributors. All Rights Reserved.
+#
+# WARNING: This program as such is intended to be used by professional
+# programmers who take the whole responsability of assessing all potential
+# consequences resulting from its eventual inadequacies and bugs
+# End users who are looking for a ready-to-use solution with commercial
+# garantees and support are strongly adviced to contract a Free Software
+# Service Company
+#
+# This program is Free Software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+#
+##############################################################################
+
+from Products.ERP5Type.Constraint import Constraint
+
+class ResourceMeasuresConsistency(Constraint):
+ """Check that measures defined on a resource are not meaningless.
+
+ Choosing quantity units for a resource without defining measures is
+ tolerated, for compatibility, although conversion between units won't be
+ possible.
+ """
+
+ _message_id_list = ['message_measure_no_quantity_unit',
+ 'message_measure_no_quantity',
+ 'message_duplicate_metric_type',
+ 'message_duplicate_default_measure',
+ 'message_missing_metric_type']
+
+ _message_measure = "Measure for metric_type '${metric_type}' "
+ message_measure_no_quantity_unit = \
+ _message_measure + "doesn't have a valid quantity_unit"
+ message_measure_no_quantity = \
+ _message_measure + "doesn't have a valid quantity value"
+ message_duplicate_metric_type = \
+ "Several measures have the same metric_type '${metric_type}'"
+ message_duplicate_default_measure = \
+ "Several measures are associated to the same unit '${quantity_unit}'"
+ message_missing_metric_type = \
+ "Implicit measure for the management unit can't be created" \
+ " because 'metric_type/${metric_type}' category doesn't exist."
+
+ def checkConsistency(self, obj, fixit=0):
+ """Implement here the consistency checker
+ """
+ error_list = []
+ portal = obj.getPortalObject()
+
+ if obj.getPortalType() not in portal.getPortalResourceTypeList():
+ return error_list
+
+ error = lambda msg, **kw: error_list.append(
+ self._generateError(obj, self._getMessage(msg), mapping=kw))
+
+ getCategoryValue = portal.portal_categories.getCategoryValue
+ display = lambda *args, **kw: \
+ getCategoryValue(*args, **kw).getCompactLogicalPath()
+
+ top = lambda relative_url: relative_url.split('/', 1)[0]
+
+ quantity_map = {}
+ metric_type_set = set()
+
+ for measure in obj.getMeasureList():
+ metric_type = measure.getMetricType()
+ if metric_type:
+ quantity = top(metric_type)
+ default_or_generic = quantity_map.setdefault(quantity, [0, 0])
+ if measure.getDefaultMetricType():
+ default_or_generic[0] += 1
+ if quantity == metric_type:
+ default_or_generic[1] += 1
+
+ if not measure.getConvertedQuantityUnit():
+ error('message_measure_no_quantity_unit',
+ metric_type=display(metric_type, 'metric_type'))
+ elif not measure.asCatalogRowList():
+ error('message_measure_no_quantity',
+ metric_type=display(metric_type, 'metric_type'))
+ if metric_type in metric_type_set:
+ error('message_duplicate_metric_type',
+ metric_type=display(metric_type, 'metric_type'))
+ else:
+ metric_type_set.add(metric_type)
+ #else:
+ # pass # we don't care about undefined measures
+
+ for i, quantity_unit in enumerate(obj.getQuantityUnitList()):
+ quantity = top(quantity_unit)
+ default, generic = quantity_map.get(quantity, (0, 0))
+ if (default or generic) > 1:
+ error('message_duplicate_default_measure',
+ quantity_unit=display(quantity_unit, 'quantity_unit'))
+ elif not (default or generic):
+ if i:
+ pass # tolerate quantity units without any measure associated to them
+ else: # management unit: check we can create an implicit measure
+ if getCategoryValue(quantity, 'metric_type') is None:
+ error('message_missing_metric_type', metric_type=quantity)
+
+ return error_list
Propchange: erp5/trunk/products/ERP5/Constraint/ResourceMeasuresConsistency.py
------------------------------------------------------------------------------
svn:eol-style = native
Modified: erp5/trunk/products/ERP5/Document/Amount.py
URL: http://svn.erp5.org/erp5/trunk/products/ERP5/Document/Amount.py?rev=21250&r1=21249&r2=21250&view=diff
==============================================================================
--- erp5/trunk/products/ERP5/Document/Amount.py (original)
+++ erp5/trunk/products/ERP5/Document/Amount.py Fri May 30 17:05:37 2008
@@ -405,17 +405,10 @@
Price is defined on
"""
- result = None
- efficiency = self.getEfficiency()
- if efficiency != 0:
- resource_price = self.getResourcePrice()
- if resource_price is not None:
- return resource_price * self.getConvertedQuantity() / efficiency
- price = self.getPrice()
- quantity = self.getQuantity()
- if type(price) in (type(1.0), type(1)) and type(quantity) in (type(1.0), type(1)):
- result = quantity * price
- return result
+ price = self.getResourcePrice()
+ quantity = self.getNetConvertedQuantity()
+ if isinstance(price, (int, float)) and isinstance(quantity, (int, float)):
+ return quantity * price
# Conversion to standard unit
security.declareProtected(Permissions.AccessContentsInformation, 'getConvertedQuantity')
@@ -426,25 +419,26 @@
resource = self.getResourceValue()
quantity_unit = self.getQuantityUnit()
quantity = self.getQuantity()
- converted_quantity = None
- if resource is not None:
- resource_quantity_unit = resource.getDefaultQuantityUnit()
- converted_quantity = resource.convertQuantity(quantity, quantity_unit, resource_quantity_unit)
- else:
- #LOG("ERP5 WARNING:", 100, 'could not convert quantity for %s' % self.getRelativeUrl())
- pass
- return converted_quantity
+ if quantity is not None and quantity_unit and resource is not None:
+ converted = resource.convertQuantity(quantity, quantity_unit,
+ resource.getDefaultQuantityUnit(),
+ self.getVariationCategoryList())
+ # For compatibility, return quantity non-converted if conversion fails.
+ if converted is not None:
+ return converted
+ return quantity
security.declareProtected(Permissions.ModifyPortalContent, 'setConvertedQuantity')
def setConvertedQuantity(self, value):
resource = self.getResourceValue()
quantity_unit = self.getQuantityUnit()
- if resource is not None:
- resource_quantity_unit = resource.getDefaultQuantityUnit()
- quantity = resource.convertQuantity(value, resource_quantity_unit, quantity_unit)
- self.setQuantity(quantity)
- else:
- LOG("ERP5 WARNING:", 100, 'could not set converted quantity for %s' % self.getRelativeUrl())
+ if value is not None and quantity_unit and resource is not None:
+ quantity = resource.convertQuantity(value,
+ resource.getDefaultQuantityUnit(),
+ quantity_unit,
+ self.getVariationCategoryList())
+ if quantity is not None:
+ return self.setQuantity(quantity)
security.declareProtected(Permissions.AccessContentsInformation, 'getNetQuantity')
def getNetQuantity(self):
Modified: erp5/trunk/products/ERP5/Document/Currency.py
URL: http://svn.erp5.org/erp5/trunk/products/ERP5/Document/Currency.py?rev=21250&r1=21249&r2=21250&view=diff
==============================================================================
--- erp5/trunk/products/ERP5/Document/Currency.py (original)
+++ erp5/trunk/products/ERP5/Document/Currency.py Fri May 30 17:05:37 2008
@@ -63,7 +63,9 @@
# Unit conversion
security.declareProtected(Permissions.AccessContentsInformation, 'convertQuantity')
- def convertQuantity(self, quantity, from_unit, to_unit):
+ def convertQuantity(self, quantity, from_unit, to_unit, variation_list=()):
+ # 'variation_list' parameter may be deprecated:
+ # cf Measure.getConvertedQuantity
return quantity
security.declareProtected(Permissions.AccessContentsInformation, 'convertCurrency')
Added: erp5/trunk/products/ERP5/Document/Measure.py
URL: http://svn.erp5.org/erp5/trunk/products/ERP5/Document/Measure.py?rev=21250&view=auto
==============================================================================
--- erp5/trunk/products/ERP5/Document/Measure.py (added)
+++ erp5/trunk/products/ERP5/Document/Measure.py Fri May 30 17:05:37 2008
@@ -1,0 +1,285 @@
+##############################################################################
+#
+# Copyright (c) 2008 Nexedi SARL and Contributors. All Rights Reserved.
+#
+# WARNING: This program as such is intended to be used by professional
+# programmers who take the whole responsability of assessing all potential
+# consequences resulting from its eventual inadequacies and bugs
+# End users who are looking for a ready-to-use solution with commercial
+# garantees and support are strongly adviced to contract a Free Software
+# Service Company
+#
+# This program is Free Software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+#
+##############################################################################
+
+from AccessControl import ClassSecurityInfo
+
+from Products.ERP5Type import PropertySheet
+from Products.ERP5Type.Permissions import AccessContentsInformation
+from Products.ERP5Type.XMLMatrix import XMLMatrix
+from Products.ERP5.Variated import Variated
+
+class Measure(XMLMatrix):
+ """
+ A Measure
+ """
+
+ meta_type = 'ERP5 Measure'
+ portal_type = 'Measure'
+
+ # Declarative security
+ security = ClassSecurityInfo()
+ security.declareObjectProtected(AccessContentsInformation)
+
+ # Declarative properties
+ property_sheets = ( PropertySheet.Base
+ , PropertySheet.XMLObject
+ , PropertySheet.CategoryCore
+ , PropertySheet.Measure
+ )
+
+ security.declareProtected(AccessContentsInformation, 'getResourceValue')
+ def getResourceValue(self):
+ """
+ Gets the resource object described by this measure.
+ """
+ return self.getDefaultResourceValue()
+
+ ##
+ # Forms.
+
+ security.declareProtected(AccessContentsInformation, 'getVariationRangeCategoryItemList')
+ def getVariationRangeCategoryItemList(self, variation):
+ """
+ Returns possible variation category values for the selected variation.
+ variation is a 0-based index and possible category values is provided
+ as a list of tuples (id, title). This is mostly useful for matrixbox.
+ """
+ mvbc_list = self.getMeasureVariationBaseCategoryList()
+ if len(mvbc_list) <= variation:
+ return ()
+ return self.getResourceValue().getVariationCategoryItemList(
+ is_right_display=1,
+ base_category_list=(mvbc_list[variation],),
+ omit_individual_variation=0,
+ display_base_category=0,
+ sort_id='id')
+
+ security.declareProtected(AccessContentsInformation, 'getQuantityUnitItemList')
+ def getQuantityUnitItemList(self):
+ """
+ Returns the list of possible quantity units for the current metric type.
+ This is mostly useful in ERP5Form instances to generate selection menus.
+ """
+ metric_type = self.getMetricType()
+ if not metric_type:
+ return ('', ''),
+ portal = self.getPortalObject()
+ return getattr(
+ portal.portal_categories.getCategoryValue(metric_type.split('/', 1)[0],
+ 'quantity_unit'),
+ portal.portal_preferences.getPreference(
+ 'preferred_category_child_item_list_method_id',
+ 'getCategoryChildCompactLogicalPathItemList')
+ )(recursive=0, local_sort_id='quantity', checked_permission='View')
+
+ security.declareProtected(AccessContentsInformation, 'getLocalQuantityUnit')
+ def getLocalQuantityUnit(self):
+ """
+ Returns the 'quantity_unit' category without acquisition.
+ Used in Resource_viewMeasure and Measure_view.
+ """
+ quantity_unit_list = self.getPortalObject().portal_categories \
+ .getSingleCategoryMembershipList(self, 'quantity_unit')
+ if quantity_unit_list:
+ return quantity_unit_list[0]
+
+ ##
+ # Measures associated to a quantity unit of the resource
+ # have a specific behaviour.
+
+ security.declareProtected(AccessContentsInformation, 'isDefaultMeasure')
+ def isDefaultMeasure(self, quantity_unit=None):
+ """
+ Checks if self is a default measure for the associated resource.
+ """
+ default = self.getResourceValue().getDefaultMeasure(quantity_unit)
+ return default is not None \
+ and self.getRelativeUrl() == default.getRelativeUrl()
+
+ ##
+ # Conversion.
+
+ security.declareProtected(AccessContentsInformation, 'getConvertedQuantityUnit')
+ def getConvertedQuantityUnit(self):
+ """
+ Gets the quantity unit ratio, in respect to the base quantity unit.
+ """
+ quantity_unit = self.getQuantityUnitValue()
+ metric_type = self.getMetricType()
+ if quantity_unit is not None and metric_type and \
+ quantity_unit.getParentId() == metric_type.split('/', 1)[0]:
+ return quantity_unit.getProperty('quantity')
+
+ security.declareProtected(AccessContentsInformation, 'getConvertedQuantity')
+ def getConvertedQuantity(self, variation_list=()):
+ """
+ Gets the measure value for a specified variation,
+ in respected to the base quantity unit.
+
+ Should it be reimplemented using predicates?
+ If so, 'variation_list' parameter is deprecated.
+ """
+ quantity_unit = self.getConvertedQuantityUnit()
+ if not quantity_unit:
+ return
+ quantity = self.getQuantity()
+ if variation_list:
+ variation_set = set(variation_list)
+ for cell in self.objectValues():
+ # if cell.test(context): # XXX
+ if variation_set.issuperset(
+ cell.getMembershipCriterionCategoryList()):
+ quantity = cell.getQuantity()
+ break
+ return quantity * quantity_unit
+
+ ##
+ # Cataloging.
+
+ security.declareProtected(AccessContentsInformation, 'asCatalogRowList')
+ def asCatalogRowList(self):
+ """
+ Returns the list of rows to insert in the measure table of the catalog.
+ Called by Resource.getMeasureRowList.
+ """
+ quantity_unit = self.getConvertedQuantityUnit()
+ if not quantity_unit:
+ return ()
+ uid = self.getUid()
+ resource = self.getResourceValue()
+ resource_uid = resource.getUid()
+ metric_type_uid = self.getMetricTypeUid()
+ quantity = self.getQuantity()
+
+ # The only purpose of the defining a default measure explicitly is to
+ # set a specific metric_type for the management unit.
+ # Therefore, the measure mustn't be variated and the described quantity
+ # (quantity * quantity_unit) must match the management unit.
+ # If the conditions aren't met, return an empty list.
+ default = self.isDefaultMeasure()
+
+ measure_variation_base_category_list = \
+ self.getMeasureVariationBaseCategoryList()
+ if not measure_variation_base_category_list:
+ # Easy case: there is no variation axe for this metric_type,
+ # so we simply return 1 row.
+ if quantity is not None:
+ quantity *= quantity_unit
+ if (not default or quantity ==
+ resource.getQuantityUnitValue().getProperty('quantity')):
+ return (uid, resource_uid, '^', metric_type_uid, quantity),
+ return ()
+
+ if default:
+ return ()
+
+ # 1st step: Build a list of possible variation combinations.
+ # Each element of the list (variation_list) is a pair of lists, where:
+ # * first list's elements are regex tokens:
+ # they'll be used to build the 'variation' values (in the result).
+ # * the second list is a combination of categories, used to find
+ # the measure cells containing the 'quantity' values.
+ #
+ # This step is done by starting from a 1-element list (variation_list).
+ # For each variation axe (if variation_base_category in
+ # measure_variation_base_category_list), we rebuild variation_list entirely
+ # and its size is multiplied by the number of categories in this axe.
+ # For other varation base categories (variation_base_category not in
+ # measure_variation_base_category_list), we simply
+ # update variation_list to add 1 regex token to each regex list.
+ #
+ # Example:
+ # * variation base categories: colour, logo, size
+ # * variation axes: colour, size
+ #
+ # variation_list (tokens are simplified for readability):
+ # 0. []
+ # 1. [([colour/red], ['colour/red']),
+ # ([colour/green], ['colour/green']),
+ # ([colour/blue], ['colour/blue'])]
+ # 2. [([colour/red, logo/*], ['colour/red']),
+ # ([colour/green, logo/*], ['colour/green']),
+ # ([colour/blue, logo/*], ['colour/blue'])]
+ # 3. [([colour/red, logo/*, size/small], ['colour/red', 'size/small']),
+ # ([colour/green, logo/*, size/small], ['colour/green', 'size/small']),
+ # ([colour/blue, logo/*, size/small], ['colour/blue', 'size/small']),
+ # ([colour/red, logo/*, size/medium], ['colour/red', 'size/medium']),
+ # ([colour/green, logo/*, size/medium], ['colour/green', 'size/medium']),
+ # ([colour/blue, logo/*, size/medium], ['colour/blue', 'size/medium']),
+ # ([colour/red, logo/*, size/big], ['colour/red', 'size/big']),
+ # ([colour/green, logo/*, size/big], ['colour/green', 'size/big']),
+ # ([colour/blue, logo/*, size/big], ['colour/blue', 'size/big'])]
+
+ # Note that from this point, we always work with sorted lists of
+ # categories (or regex tokens).
+
+ variation_list = ([], []),
+ optional_variation_base_category_set = \
+ set(resource.getOptionalVariationBaseCategoryList())
+ for variation_base_category in sorted(
+ resource.getVariationBaseCategoryList(omit_optional_variation=0)):
+ if variation_base_category in measure_variation_base_category_list:
+ # This is where we rebuild variation_list entirely. Size of
+ # variation_list is multiplied by len(getVariationCategoryList).
+ # The lists of each pairs in variation_list get one more element:
+ # variation_category.
+ variation_list = [(regex_list + [variation_category + '\n'],
+ variation_base_category_list + [variation_category])
+ for variation_category in resource.getVariationCategoryList(
+ base_category_list=(variation_base_category,),
+ omit_individual_variation=0)
+ for regex_list, variation_base_category_list in variation_list]
+ else:
+ variation_base_category_regex = variation_base_category + '/[^\n]+\n'
+ if variation_base_category in optional_variation_base_category_set:
+ variation_base_category_regex = \
+ '(%s)*' % (variation_base_category_regex, )
+ for regex_list, variation_base_category_list in variation_list:
+ regex_list.append(variation_base_category_regex)
+
+ # 2nd step: Retrieve all measure cells in a dictionary for fast lookup.
+ cell_map = {}
+ for cell in self.objectValues():
+ cell_map[tuple(sorted(cell.getMembershipCriterionCategoryList()))] \
+ = cell.getQuantity()
+
+ # 3rd step: Build the list of rows to return,
+ # by merging variation_list (1st step) and cell_map (2nd step).
+ row_list = []
+ for regex_list, variation_base_category_list in variation_list:
+ cell = cell_map.get(tuple(variation_base_category_list))
+ if cell is None:
+ if quantity is None:
+ continue
+ cell = quantity
+ row_list.append((uid,
+ resource_uid,
+ '^%s$' % ''.join(regex_list),
+ metric_type_uid,
+ cell * quantity_unit))
+
+ return row_list
Propchange: erp5/trunk/products/ERP5/Document/Measure.py
------------------------------------------------------------------------------
svn:eol-style = native
Modified: erp5/trunk/products/ERP5/Document/Movement.py
URL: http://svn.erp5.org/erp5/trunk/products/ERP5/Document/Movement.py?rev=21250&r1=21249&r2=21250&view=diff
==============================================================================
--- erp5/trunk/products/ERP5/Document/Movement.py (original)
+++ erp5/trunk/products/ERP5/Document/Movement.py Fri May 30 17:05:37 2008
@@ -199,9 +199,18 @@
# _getPrice is defined in the order / delivery
# Pricing mehod
def _getPrice(self, context):
+ context = self.asContext(context=context,
+ quantity=self.getConvertedQuantity())
operand_dict = self.getPriceCalculationOperandDict(context=context)
if operand_dict is not None:
- return operand_dict['price']
+ price = operand_dict['price']
+ resource = self.getResourceValue()
+ quantity_unit = self.getQuantityUnit()
+ if price is not None and quantity_unit and resource is not None:
+ return resource.convertQuantity(price, quantity_unit,
+ resource.getDefaultQuantityUnit(),
+ self.getVariationCategoryList())
+ return price
def _getTotalPrice(self, default=None, context=None, fast=0):
price = self.getPrice(context=context)
Modified: erp5/trunk/products/ERP5/Document/Resource.py
URL: http://svn.erp5.org/erp5/trunk/products/ERP5/Document/Resource.py?rev=21250&r1=21249&r2=21250&view=diff
==============================================================================
--- erp5/trunk/products/ERP5/Document/Resource.py (original)
+++ erp5/trunk/products/ERP5/Document/Resource.py Fri May 30 17:05:37 2008
@@ -251,11 +251,6 @@
base_category_list=base_category_list,
omit_individual_variation=omit_individual_variation,**kw)
return [x[1] for x in vcil]
-
- # Unit conversion
- security.declareProtected(Permissions.AccessContentsInformation, 'convertQuantity')
- def convertQuantity(self, quantity, from_unit, to_unit):
- return quantity
# This patch is temporary and allows to circumvent name conflict in ZSQLCatalog process for Coramy
security.declareProtected(Permissions.AccessContentsInformation,
@@ -783,3 +778,120 @@
except TypeError:
return 0
return 0
+
+
+ def _getConversionRatio(self, quantity_unit, variation_list):
+ """
+ Converts a quantity unit into a ratio in respect to the resource's
+ management unit, for the specified variation.
+ A quantity can be multiplied by the returned value in order to convert it
+ in the management unit.
+
+ 'variation_list' parameter may be deprecated:
+ cf Measure.getConvertedQuantity
+ """
+ management_unit = self.getDefaultQuantityUnit()
+ if management_unit == quantity_unit:
+ return 1.0
+ traverse = self.portal_categories['quantity_unit'].unrestrictedTraverse
+ quantity = traverse(quantity_unit).getProperty('quantity')
+ if quantity_unit.split('/', 1)[0] != management_unit.split('/', 1)[0]:
+ measure = self.getDefaultMeasure(quantity_unit)
+ quantity /= measure.getConvertedQuantity(variation_list)
+ else:
+ quantity /= traverse(management_unit).getProperty('quantity')
+ return quantity
+
+ # Unit conversion
+ security.declareProtected(Permissions.AccessContentsInformation, 'convertQuantity')
+ def convertQuantity(self, quantity, from_unit, to_unit, variation_list=()):
+ # 'variation_list' parameter may be deprecated:
+ # cf Measure.getConvertedQuantity
+ try:
+ return quantity * self._getConversionRatio(from_unit, variation_list) \
+ / self._getConversionRatio(to_unit, variation_list)
+ except (ArithmeticError, AttributeError, LookupError, TypeError), error:
+ # For compatibility, we only log the error and return None.
+ # No exception for the moment.
+ LOG('Resource.convertQuantity', WARNING,
+ 'could not convert quantity for %s (%r)'
+ % (self.getRelativeUrl(), error))
+
+ security.declareProtected(Permissions.AccessContentsInformation,
+ 'getMeasureList')
+ def getMeasureList(self):
+ """
+ Gets the list of Measure objects describing this resource.
+ """
+ return self.objectValues(portal_type='Measure')
+
+ security.declareProtected(Permissions.AccessContentsInformation,
+ 'getDefaultMeasure')
+ def getDefaultMeasure(self, quantity_unit=None):
+ """
+ Returns the measure object associated to quantity_unit.
+ If no quantity_unit is specified, the quantity_unit of the resource is used.
+ None is returned if the number of found measures differs from 1.
+ """
+ if quantity_unit is None:
+ quantity_unit = self.getQuantityUnit()
+ if quantity_unit:
+ top = lambda relative_url: relative_url.split('/', 1)[0]
+
+ quantity = top(quantity_unit)
+ generic = []
+ default = []
+ for measure in self.getMeasureList():
+ metric_type = measure.getMetricType()
+ if metric_type and quantity == top(metric_type) and \
+ measure.getDefaultMetricType():
+ default.append(measure)
+ if quantity == metric_type:
+ generic.append(measure)
+ result = default or generic
+ if len(result) == 1:
+ return result[0]
+
+ security.declareProtected(Permissions.AccessContentsInformation,
+ 'getMeasureRowList')
+ def getMeasureRowList(self):
+ """
+ Returns a list rows to insert in the measure table.
+ Used by z_catalog_measure_list.
+ """
+ quantity_unit_value = self.getQuantityUnitValue()
+ if quantity_unit_value is None:
+ return ()
+
+ quantity_unit = quantity_unit_value.getCategoryRelativeUrl()
+ default = self.getDefaultMeasure(quantity_unit)
+ if default is not None:
+ default = default.getRelativeUrl()
+ metric_type_map = {} # duplicate metric_type are not valid
+
+ for measure in self.getMeasureList():
+ metric_type = measure.getMetricType()
+ if metric_type in metric_type_map:
+ metric_type_map[metric_type] = ()
+ else:
+ metric_type_map[metric_type] = measure.asCatalogRowList()
+ if measure.getRelativeUrl() == default:
+ quantity_unit = ''
+
+ insert_list = []
+ for measure_list in metric_type_map.itervalues():
+ insert_list += measure_list
+
+ metric_type = quantity_unit.split('/', 1)[0]
+ if metric_type and metric_type not in metric_type_map:
+ # At this point, we know there is no default measure and we must add
+ # a row for the management unit, with the resource's uid as uid, and
+ # a generic metric_type.
+ quantity = quantity_unit_value.getProperty('quantity')
+ metric_type_uid = self.getPortalObject().portal_categories \
+ .getCategoryUid(metric_type, 'metric_type')
+ if quantity and metric_type_uid:
+ uid = self.getUid()
+ insert_list += (uid, uid, '^', metric_type_uid, quantity),
+
+ return insert_list
Added: erp5/trunk/products/ERP5/PropertySheet/Measure.py
URL: http://svn.erp5.org/erp5/trunk/products/ERP5/PropertySheet/Measure.py?rev=21250&view=auto
==============================================================================
--- erp5/trunk/products/ERP5/PropertySheet/Measure.py (added)
+++ erp5/trunk/products/ERP5/PropertySheet/Measure.py Fri May 30 17:05:37 2008
@@ -1,0 +1,46 @@
+##############################################################################
+#
+# Copyright (c) 2008 Nexedi SARL and Contributors. All Rights Reserved.
+#
+# WARNING: This program as such is intended to be used by professional
+# programmers who take the whole responsability of assessing all potential
+# consequences resulting from its eventual inadequacies and bugs
+# End users who are looking for a ready-to-use solution with commercial
+# garantees and support are strongly adviced to contract a Free Software
+# Service Company
+#
+# This program is Free Software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+#
+##############################################################################
+
+class Measure:
+
+ _properties = (
+ { 'id' : 'quantity',
+ 'description' : "Value of the measure, expressed in the selected quantity unit.",
+ 'type' : 'float',
+ 'mode' : 'w' },
+ { 'id' : 'measure_variation_base_category',
+ 'description' : "Base category range of matrix.",
+ 'type' : 'tokens',
+ 'mode' : 'w' },
+ { 'id' : 'default_metric_type',
+ 'description' : "Use this measure by default to perform unit conversion\n"
+ "(useful in cases where only a quantity unit is specified).",
+ 'type' : 'boolean',
+ 'mode' : 'w' },
+ )
+
+ _categories = ( 'metric_type', 'quantity_unit' )
Propchange: erp5/trunk/products/ERP5/PropertySheet/Measure.py
------------------------------------------------------------------------------
svn:eol-style = native
Modified: erp5/trunk/products/ERP5/PropertySheet/Resource.py
URL: http://svn.erp5.org/erp5/trunk/products/ERP5/PropertySheet/Resource.py?rev=21250&r1=21249&r2=21250&view=diff
==============================================================================
--- erp5/trunk/products/ERP5/PropertySheet/Resource.py (original)
+++ erp5/trunk/products/ERP5/PropertySheet/Resource.py Fri May 30 17:05:37 2008
@@ -179,6 +179,13 @@
)
+ _constraints = (
+ { 'id': 'resource_measures_consistency',
+ 'description': '',
+ 'type': 'ResourceMeasuresConsistency',
+ },
+ )
+
_categories = ( 'source', 'destination', 'quantity_unit', 'price_unit',
'weight_unit', 'length_unit', 'height_unit', 'width_unit',
'volume_unit',
Modified: erp5/trunk/products/ERP5/Tool/SimulationTool.py
URL: http://svn.erp5.org/erp5/trunk/products/ERP5/Tool/SimulationTool.py?rev=21250&r1=21249&r2=21250&view=diff
==============================================================================
--- erp5/trunk/products/ERP5/Tool/SimulationTool.py (original)
+++ erp5/trunk/products/ERP5/Tool/SimulationTool.py Fri May 30 17:05:37 2008
@@ -49,6 +49,7 @@
from Products.ZSQLCatalog.SQLCatalog import Query, ComplexQuery, QueryMixin
from Shared.DC.ZRDB.Results import Results
+from Products.ERP5Type.Utils import mergeZRDBResults
class SimulationTool(BaseTool):
"""
@@ -878,7 +879,8 @@
default_stock_table='stock',
selection_domain=None, selection_report=None,
statistic=0, inventory_list=1,
- precision=None, connection_id=None, **kw):
+ precision=None, connection_id=None,
+ quantity_unit=None, **kw):
"""
Returns a list of inventories for a single or multiple
resources on a single or multiple nodes, grouped by resource,
@@ -1058,6 +1060,7 @@
assert isinstance(where_query, basestring) and len(where_query)
stock_sql_kw['where_expression'] = '(%s) AND (%s)' % \
(where_query, greater_than_date_query)
+ LOG(None, 0, 'optimisation_success = True')
# Get initial inventory amount
initial_inventory_line_list = self.Resource_zGetInventoryList(
stock_table_id=EQUAL_DATE_TABLE_ID,
@@ -1066,7 +1069,8 @@
selection_domain=selection_domain,
selection_report=selection_report, precision=precision,
inventory_list=inventory_list,
- statistic=statistic, **inventory_stock_sql_kw)
+ statistic=statistic, quantity_unit=quantity_unit,
+ **inventory_stock_sql_kw)
# Get delta inventory
delta_inventory_line_list = self.Resource_zGetInventoryList(
stock_table_id=GREATER_THAN_DATE_TABLE_ID,
@@ -1075,7 +1079,8 @@
selection_domain=selection_domain,
selection_report=selection_report, precision=precision,
inventory_list=inventory_list,
- statistic=statistic, **stock_sql_kw)
+ statistic=statistic, quantity_unit=quantity_unit,
+ **stock_sql_kw)
# Match & add initial and delta inventories
if src__:
sql_source_list.extend((initial_inventory_line_list,
@@ -1104,6 +1109,8 @@
result_column_id_dict['inventory'] = None
result_column_id_dict['total_quantity'] = None
result_column_id_dict['total_price'] = None
+ if quantity_unit:
+ result_column_id_dict['converted_quantity'] = None
def addLineValues(line_a=None, line_b=None):
"""
Addition columns of 2 lines and return a line with same
@@ -1189,12 +1196,72 @@
selection_domain=selection_domain,
selection_report=selection_report, precision=precision,
inventory_list=inventory_list, connection_id=connection_id,
- statistic=statistic, **stock_sql_kw)
+ statistic=statistic, quantity_unit=quantity_unit,
+ **stock_sql_kw)
if src__:
sql_source_list.append(result)
if src__:
result = ';\n-- NEXT QUERY\n'.join(sql_source_list)
return result
+
+ security.declareProtected(Permissions.AccessContentsInformation,
+ 'getConvertedInventoryList')
+ def getConvertedInventoryList(self, metric_type, quantity_unit=1,
+ simulation_period='', **kw):
+ """
+ Return list of inventory with a 'converted_quantity' additional column,
+ which contains the sum of measurements for the specified metric type,
+ expressed in the 'quantity_unit' unit.
+
+ metric_type - category relative url
+ quantity_unit - int, float or category relative url
+ """
+ getCategory = self.getPortalObject().portal_categories.getCategoryValue
+
+ kw['metric_type_uid'] = Query(
+ metric_type_uid=getCategory(metric_type, 'metric_type').getUid(),
+ table_alias_list=(("measure", "measure"),))
+
+ if isinstance(quantity_unit, str):
+ quantity_unit = getCategory(quantity_unit,
+ 'quantity_unit').getProperty('quantity')
+
+ method = getattr(self,'get%sInventoryList' % simulation_period)
+ return method(quantity_unit=quantity_unit, **kw)
+
+ security.declareProtected(Permissions.AccessContentsInformation,
+ 'getAllInventoryList')
+ def getAllInventoryList(self, src__=0, **kw):
+ """
+ Returns list of inventory, for all periods.
+ Performs 1 SQL request for each simulation state, and merge the results.
+ Rename relevant columns with a '${simulation}_' prefix
+ (ex: 'total_price' -> 'current_total_price').
+ """
+ columns = ('total_quantity', 'total_price', 'converted_quantity')
+
+ # Guess the columns to use to identify each row, by looking at the GROUP
+ # clause. Note that the call to 'mergeZRDBResults' will crash if the GROUP
+ # clause contains a column not requested in the SELECT clause.
+ kw.update(self._getDefaultGroupByParameters(**kw), ignore_group_by=1)
+ group_by_list = self._generateKeywordDict(**kw)[1].get('group_by', ())
+
+ results = []
+ edit_result = {}
+ get_false_value = lambda row, column_name: row.get(column_name) or 0
+
+ for simulation in 'current', 'available', 'future':
+ method = getattr(self, 'get%sInventoryList' % simulation.capitalize())
+ rename = {'inventory': None} # inventory column is deprecated
+ for column in columns:
+ rename[column] = new_name = '%s_%s' % (simulation, column)
+ edit_result[new_name] = get_false_value
+ results += (method(src__=src__, **kw), rename),
+
+ if src__:
+ return ';\n-- NEXT QUERY\n'.join(r[0] for r in results)
+ return mergeZRDBResults(results, group_by_list, edit_result)
+
security.declareProtected(Permissions.AccessContentsInformation,
'getCurrentInventoryList')
@@ -1268,7 +1335,7 @@
'getAvailableInventoryStat')
def getAvailableInventoryStat(self, **kw):
"""
- Returns statistics of current inventory grouped by section or site
+ Returns statistics of available inventory grouped by section or site
"""
return self.getInventoryStat(simulation_period='Available', **kw)
Modified: erp5/trunk/products/ERP5/tests/testInventoryAPI.py
URL: http://svn.erp5.org/erp5/trunk/products/ERP5/tests/testInventoryAPI.py?rev=21250&r1=21249&r2=21250&view=diff
==============================================================================
--- erp5/trunk/products/ERP5/tests/testInventoryAPI.py (original)
+++ erp5/trunk/products/ERP5/tests/testInventoryAPI.py Fri May 30 17:05:37 2008
@@ -58,7 +58,15 @@
'group/test_group/A2/B1/C2',
'group/test_group/A2/B2/C1',
'group/test_group/A2/B2/C2', )
-
+
+ VARIATION_CATEGORIES = ( 'colour/red',
+ 'colour/green',
+ 'colour/blue',
+ 'size/big',
+ 'size/small',
+ 'industrial_phase/phase1',
+ 'industrial_phase/phase2', )
+
def getTitle(self):
"""Title of the test."""
return 'Inventory API'
@@ -181,7 +189,7 @@
'group/anotherlevel',
'product_line/level1/level2',
# we create a huge group category for consolidation tests
- ) + self.GROUP_CATEGORIES
+ ) + self.GROUP_CATEGORIES + self.VARIATION_CATEGORIES
def getBusinessTemplateList(self):
""" erp5_trade is required for transit_simulation_state
@@ -2085,6 +2093,110 @@
_aq_reset()
+class TestUnitConversion(InventoryAPITestCase):
+ QUANTITY_UNIT_CATEGORIES = {
+ 'unit': {'unit': 1, 'a_few': None},
+ 'length': {'m': 1, 'in': .0254},
+ 'mass': {'kg': 1, 't': 1000, 'g': .001},
+ }
+ METRIC_TYPE_CATEGORIES = (
+ 'unit',
+ 'unit/0',
+ 'unit/1',
+ 'unit/2',
+ 'mass/net',
+ 'mass/nutr/lipid',
+ )
+
+ def afterSetUp(self):
+ InventoryAPITestCase.afterSetUp(self)
+
+ self.resource.setQuantityUnitList(('unit/unit', 'length/in'))
+ self.other_resource.setQuantityUnit('mass/g')
+
+ keys = ('metric_type', 'quantity_unit', 'quantity', 'default_metric_type')
+ for resource, measure_list in {
+ self.resource: (
+ ('mass/net', 'mass/kg', .123, None),
+ ('mass/nutr/lipid', 'mass/g', 45, True),
+ ),
+ self.other_resource: (
+ # default measure (only useful to set the metric type)
+ ('mass/net', None, 1, True),
+ # Bad measures
+ ('unit', 'unit/unit', 123, None), ## duplicate
+ ('unit', 'unit/unit', 123, None), #
+ ('unit/0', 'unit/a_few', 123, None), ## incomplete
+ ('unit/1', 'unit/unit', None, None), #
+ ('unit/2', None, 123, None), #
+ (None, 'mass/kg', 123, None), #
+ (None, None, None, None), ## empty
+ )}.iteritems():
+ for measure in measure_list:
+ kw = dict((keys[i], v) for i, v in enumerate(measure) if v is not None)
+ resource.newContent(portal_type='Measure', **kw)
+
+ self.resource.setOptionalVariationBaseCategory('industrial_phase')
+ self.resource.setVariationBaseCategoryList(('colour', 'size'))
+ self.resource.setVariationCategoryList(self.VARIATION_CATEGORIES)
+ m = self.resource.getDefaultMeasure('mass/t')
+
+ m.setMeasureVariationBaseCategory('colour')
+ for colour, quantity in ('green', 43), ('red', 56):
+ m.newContent(portal_type='Measure Cell', quantity=quantity) \
+ ._setMembershipCriterionCategory('colour/' + colour)
+
+ self._safeTic()
+
+ def getNeededCategoryList(self):
+ category_list = ['metric_type/' + c for c in self.METRIC_TYPE_CATEGORIES]
+ for level1, level2 in self.QUANTITY_UNIT_CATEGORIES.iteritems():
+ quantity = 'quantity_unit/%s/' % level1
+ category_list.extend(quantity + unit for unit in level2)
+ category_list += InventoryAPITestCase.getNeededCategoryList(self)
+ return category_list
+
+ def createCategories(self):
+ InventoryAPITestCase.createCategories(self)
+ quantity_unit = self.getCategoryTool().quantity_unit
+ for quantity_id, unit_dict in self.QUANTITY_UNIT_CATEGORIES.iteritems():
+ quantity_value = quantity_unit[quantity_id]
+ for unit_id, unit_scale in unit_dict.iteritems():
+ if unit_scale is not None:
+ quantity_value[unit_id].setProperty('quantity', unit_scale)
+
+ def testConvertedInventoryList(self):
+ def makeMovement(quantity, resource, *variation, **kw):
+ m = self._makeMovement(quantity=quantity, resource_value=resource,
+ source_value=self.node, destination_value=self.mirror_node, **kw)
+ if variation:
+ m.setVariationCategoryList(variation)
+ self._safeTic()
+
+ makeMovement(2, self.resource, 'colour/green', 'size/big')
+ makeMovement(789, self.other_resource)
+ makeMovement(-13, self.resource, 'colour/red', 'size/small',
+ 'industrial_phase/phase1', 'industrial_phase/phase2')
+
+ def simulation(metric_type, **kw):
+ return self.getSimulationTool().getConvertedInventoryList(
+ metric_type=metric_type, node_uid=self.node.getUid(),
+ ignore_group_by=1, inventory_list=0, **kw)[0].converted_quantity
+
+ for i in range(3):
+ self.assertEquals(None, simulation('unit/%i' % i))
+
+ self.assertEquals(None, simulation('unit', simulation_period='Current'))
+ self.assertEquals(11, simulation('unit'))
+
+ self.assertEquals(11 * 123 - 789,
+ simulation('mass/net', quantity_unit=.001))
+ self.assertEquals((11 * 123 - 789) / 1e6,
+ simulation('mass/net', quantity_unit='mass/t'))
+
+ self.assertEquals(13 * .056 - 2 * .043, simulation('mass/nutr/lipid'))
+
+
def test_suite():
suite = unittest.TestSuite()
suite.addTest(unittest.makeSuite(TestInventory))
@@ -2094,6 +2206,7 @@
suite.addTest(unittest.makeSuite(TestNextNegativeInventoryDate))
suite.addTest(unittest.makeSuite(TestTrackingList))
suite.addTest(unittest.makeSuite(TestInventoryDocument))
+ suite.addTest(unittest.makeSuite(TestUnitConversion))
return suite
# vim: foldmethod=marker
Modified: erp5/trunk/products/ERP5Type/Utils.py
URL: http://svn.erp5.org/erp5/trunk/products/ERP5Type/Utils.py?rev=21250&r1=21249&r2=21250&view=diff
==============================================================================
--- erp5/trunk/products/ERP5Type/Utils.py (original)
+++ erp5/trunk/products/ERP5Type/Utils.py Fri May 30 17:05:37 2008
@@ -2505,3 +2505,102 @@
""" Get common (country/capital(major cities) format) timezones list """
from pytz import common_timezones
return common_timezones
+
+
+#####################################################
+# Processing of ZRDB.Results objects
+#####################################################
+
+from Shared.DC.ZRDB.Results import Results
+
+def mergeZRDBResults(results, key_column, edit_result):
+ """
+ Merge several ZRDB.Results into a single ZRDB.Results. It's done in 3 steps:
+ 1. Rename columns of every table (cf 1st parameter).
+ 2. Merge all source tables into a intermediate sparse table.
+ Each processed row is identified according to the columns specified in the
+ 2nd parameter: Rows with the same values in the specified columns are
+ merged together, otherwise they are stored separately.
+ 3. Convert the intermediate table into a ZRDB.Results structure.
+ Cell values are copied or created according the 3rd parameter.
+ By default, values aren't modified and empty cells are set to None.
+
+ results - List of ZRDB.Results to merge. Each item can also be a tuple
+ (ZRDB.Results, rename_map) : the columns are renamed before
+ any other processing, according to rename_map (dict).
+ key_column - 2 rows are merged if and only if there is the same value in
+ the common column(s) specified by key_column.
+ key_column can be either a string or a sequence of strings.
+ () can be passed to merge 1-row tables.
+ edit_result - Map { column_name: function | defaut_value } allowing to edit
+ values (or specify default values) for certain columns in the
+ resulting table:
+ - function (lambda row, column_name: new_value)
+ - default_value is interpreted as
+ (lambda row, column: row.get(column, default_value))
+ Note that whenever a row doesn't have a matching row in every
+ other table, the merged result before editing may contain
+ incomplete rows. get_value also allows you to fill these rows
+ with specific values, instead of the default 'None' value.
+ """
+ if isinstance(key_column,str):
+ key_column = key_column,
+
+ ## Variables holding the resulting table:
+ items = [] # list of columns: each element is an item (cf ZRDB.Results)
+ column_list = [] # list of columns:
+ # each element is a pair (column name, get_value)
+ data = [] # list of rows of maps column_name => value
+
+ ## For each key, record the row number in 'data'
+ index = {}
+
+ ## Set of columns already seen
+ column_set = set()
+
+ for r in results:
+ ## Step 1
+ if isinstance(r, Results):
+ rename = {}
+ else:
+ r, rename = r
+ new_column_list = []
+ columns = {}
+ for i, column in enumerate(r._searchable_result_columns()):
+ name = column['name']
+ name = rename.get(name, name)
+ if name is None:
+ continue
+ columns[name] = i
+ if name not in column_set:
+ column_set.add(name)
+ new_column_list.append(i)
+ column = column.copy()
+ column['name'] = name
+ items.append(column)
+ # prepare step 3
+ get_value = edit_result.get(name)
+ column_list += (name, hasattr(get_value, '__call__') and get_value or
+ (lambda row, column, default=get_value: row.get(column, default))),
+
+ ## Step 2
+ try:
+ index_pos = [ columns[rename.get(name,name)] for name in key_column ]
+ except KeyError:
+ raise KeyError("Missing '%s' column in source table" % name)
+ for row in r:
+ key = tuple(row[i] for i in index_pos)
+ if key in index: # merge the row to an existing one
+ merged_row = data[index[key]]
+ else: # new row
+ index[key] = len(data)
+ merged_row = {}
+ data.append(merged_row)
+ for column, i in columns.iteritems():
+ merged_row[column] = row[i]
+
+ ## Step 3
+ return Results((items, [
+ [ get_value(row, column) for column, get_value in column_list ]
+ for row in data
+ ]))
More information about the Erp5-report
mailing list