[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