amount_generator.py 17.6 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
# -*- coding: utf-8 -*-
##############################################################################
#
# Copyright (c) 2009 Nexedi SA and Contributors. All Rights Reserved.
#
# WARNING: This program as such is intended to be used by professional
# programmers who take the whole responsibility 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
# guarantees 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., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
##############################################################################

29
import random
30
import zope.interface
31
from zLOG import LOG, WARNING
32 33
from AccessControl import ClassSecurityInfo
from Products.ERP5Type import Permissions, interfaces
34
from Products.ERP5.Document.Amount import Amount
35 36
from Products.ERP5.Document.MappedValue import MappedValue

37 38 39 40

class AmountGeneratorMixin:
  """
  This class provides a generic implementation of IAmountGenerator.
Julien Muchembled's avatar
Julien Muchembled committed
41
  It is used by Transformation, Trade Model, Paysheet, etc. It is
42 43
  designed to support about any transformation process based
  on IMappedValue interface. The key idea is that the Amount Generator
Julien Muchembled's avatar
Julien Muchembled committed
44
  Lines and Cell provide either directly or through acquisition the
45 46 47
  methods 'getMappedValuePropertyList' and 'getMappedValueBaseCategoryList'
  to gather the properties and categories to copy from the model
  to the generated amounts.
48

49
  NOTE: this is an first prototype of implementation
50 51 52 53 54 55 56 57 58
  """

  # Declarative security
  security = ClassSecurityInfo()
  security.declareObjectProtected(Permissions.AccessContentsInformation)

  # Declarative interfaces
  zope.interface.implements(interfaces.IAmountGenerator,)

59 60 61
  # XXX to be specificied in an interface (IAmountGeneratorLine ?)
  def getCellAggregateKey(self, amount_generator_cell):
    """Define a key in order to aggregate amounts at cell level
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82

      Transformed Resource (Transformation)
        key must be None because:
          - quantity and variation are defined in different cells so that the
            user does not need to enter values depending on all axes
          - amount_generator_cell.test should filter only 1 variant
        current key = (acquired resource, acquired variation)

      Assorted Resource (Transformation)
        key = (assorted resource, assorted resource variation)
        usually resource and quantity provided together

      Payroll
        key = (payroll resource, payroll resource variation)

      Tax
        key = (tax resource, tax resource variation)
    """
    return (amount_generator_cell.getResource(),
            amount_generator_cell.getVariationText()) # Variation UID, Hash ?

Julien Muchembled's avatar
Julien Muchembled committed
83 84
  security.declareProtected(Permissions.AccessContentsInformation,
                            'getGeneratedAmountList')
85 86
  def getGeneratedAmountList(self, amount_list=None, rounding=False,
                             amount_generator_type_list=None):
Julien Muchembled's avatar
Julien Muchembled committed
87
    """
88 89 90 91 92 93 94 95 96
    Implementation of a generic transformation algorithm which is
    applicable to payroll, tax generation and BOMs. Return the
    list of amounts without any aggregation.

    TODO:
    - getTargetLevel support
    - is rounding really well supported (ie. before and after aggregation)
      very likely not - proxying before or after must be decided
    """
97 98 99
    # It is the only place we can import this
    from Products.ERP5Type.Document import newTempAmount
    portal = self.getPortalObject()
100 101 102 103 104
    getRoundingProxy = portal.portal_roundings.getRoundingProxy
    amount_generator_line_type_list = \
      portal.getPortalAmountGeneratorLineTypeList()
    amount_generator_cell_type_list = \
      portal.getPortalAmountGeneratorCellTypeList()
105 106 107

    # Set empty result by default
    result = []
108

Julien Muchembled's avatar
Julien Muchembled committed
109
    # If amount_list is None, then try to collect amount_list from
110
    # the current context
Julien Muchembled's avatar
Julien Muchembled committed
111
    if amount_list is None:
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129
      if self.providesIMovementCollection():
        # Amounts are sorted to process deeper objects first.
        movement_portal_type_list = self.getPortalMovementTypeList()
        amount_list = [self]
        amount_index = 0
        while amount_index < len(amount_list):
          amount_list += amount_list[amount_index].objectValues(
              portal_type=movement_portal_type_list)
          amount_index += 1
        # Add only movement which are input (i.e. resource use category
        # is in the normal resource use preference list). Output will
        # be recalculated.
        amount_list = [x for x in amount_list[:0:-1] # skip self
                         if not x.getBaseApplication()] + [self]
      elif self.providesIAmount():
        amount_list = self,
      elif self.providesIAmountList():
        amount_list = self
130
      else:
Julien Muchembled's avatar
Julien Muchembled committed
131
        raise ValueError(
132 133 134 135 136 137 138 139 140 141 142 143 144
          'self must implement IMovementCollection, IAmount or IAmountList')

    def getAmountProperty(amount_generator_line, base_application):
      """Produced amount quantity is needed to initialize transformation"""
      if base_application in base_contribution_set:
        method = amount_generator_line._getTypeBasedMethod('getAmountProperty')
        if method is not None:
          value = method(delivery_amount, base_application, amount_list,
                         rounding)
          if value is not None:
            return value
        return amount_generator_line.getAmountProperty(
            delivery_amount, base_application, amount_list, rounding)
145

146 147
    # First define the method that will browses recursively
    # the amount generator lines and accumulate applicable values
148 149 150
    def accumulateAmountList(self):
      amount_generator_line_list = self.contentValues(
        portal_type=amount_generator_line_type_list)
151
      # Recursively feed base_amount
152 153 154 155 156 157 158 159 160 161 162 163 164
      if amount_generator_line_list:
        # Append lines with missing or duplicate int_index
        if self in check_wrong_index_set:
          check_wrong_index_set.update(amount_generator_line_list)
        else:
          index_dict = {}
          for line in amount_generator_line_list:
            index_dict.setdefault(line.getIntIndex(), []).append(line)
          for line_list in index_dict.itervalues():
            if len(line_list) > 1:
              check_wrong_index_set.update(line_list)
        amount_generator_line_list.sort(key=lambda x: (x.getIntIndex(),
                                                       random.random()))
165 166 167
        for amount_generator_line in amount_generator_line_list:
          accumulateAmountList(amount_generator_line)
        return
168 169
      elif (self.getPortalType() not in amount_generator_line_type_list):
        return
170 171 172
      # Try to collect cells and aggregate their mapped properties
      # using resource + variation as aggregation key or base_application
      # for intermediate lines
173 174
      amount_generator_cell_list = self.contentValues(
        portal_type=amount_generator_cell_type_list)
175 176
      resource_amount_aggregate = {} # aggregates final line information
      value_amount_aggregate = {} # aggregates intermediate line information
177

178
      for amount_generator_cell in amount_generator_cell_list or (self,):
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203
        if not amount_generator_cell.test(delivery_amount):
          continue
        base_application_list = amount_generator_cell.getBaseApplicationList()
        try:
          base_contribution_list = \
            amount_generator_cell.getBaseContributionList()
        except AttributeError:
          base_contribution_list = ()
        resource = amount_generator_cell.getResource()
        if resource or base_contribution_list: # case 1 & 2
          applied_base_amount_set.update(base_application_list)
        # XXX What should be done when there is no base_application ?
        #     With the following code, it always applies, once, like in
        #     the old implementation, but this is not consistent with
        #     the way we ignore automatically created movements
        #     (see above code when self provides IMovementCollection).
        #     We should either do nothing if there is no base_application,
        #     or find a criterion other than base_application to find
        #     manually created movements.
        for base_application in base_application_list or (None,):
          if base_application not in base_amount:
            value = getAmountProperty(self, base_application)
            if value is None:
              continue
            base_amount[base_application] = value
Julien Muchembled's avatar
Julien Muchembled committed
204 205
          # Case 1: the cell defines a final amount of resource
          if resource:
206
            key = self.getCellAggregateKey(amount_generator_cell)
Julien Muchembled's avatar
Julien Muchembled committed
207 208 209 210 211 212
            property_dict = resource_amount_aggregate.setdefault(key, {})
            # Then collect the mapped properties (net_converted_quantity,
            # resource, quantity, base_contribution_list, base_application...)
            for key in amount_generator_cell.getMappedValuePropertyList():
              # XXX-JPS Make sure handling of list properties can be handled
              property_dict[key] = amount_generator_cell.getProperty(key)
213 214 215
            category_list = amount_generator_cell.getCategoryMembershipList(
              amount_generator_cell.getMappedValueBaseCategoryList(), base=1)
            if category_list:
216 217
              property_dict.setdefault('category_list',
                                       []).extend(category_list)
Julien Muchembled's avatar
Julien Muchembled committed
218 219
            property_dict['resource'] = resource
            # For final amounts, base_application and id MUST be defined
220 221
            property_dict.setdefault('base_application_set',
                                     set()).add(base_application)
Julien Muchembled's avatar
Julien Muchembled committed
222
            #property_dict['trade_phase_list'] = amount_generator_cell.getTradePhaseList() # Required moved to MappedValue
223 224
            property_dict['reference'] = (amount_generator_cell.getReference()
                                          or self.getReference()) # XXX
Julien Muchembled's avatar
Julien Muchembled committed
225 226
            property_dict['id'] = amount_generator_cell.getRelativeUrl().replace('/', '_')
          # Case 2: the cell defines a temporary calculation line
227
          if base_contribution_list:
Julien Muchembled's avatar
Julien Muchembled committed
228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243
            # Define a key in order to aggregate amounts in cells
            #   base_application MUST be defined
            #
            # Single line case: key = base_application
            #
            # Payroll
            #
            #   key = base_application
            #     it is not possible to use cells to add amounts
            #     in intermediate calculation but only to
            #     select one amount
            #
            #   key = (base_application, XXX) would be required
            #
            #  Use of a method to generate keys is probably better.
            #  than hardcoding it here
244 245
            property_dict = value_amount_aggregate.setdefault(base_application,
                                                              {})
Julien Muchembled's avatar
Julien Muchembled committed
246 247 248 249 250
            # Then collect the mapped properties
            for key in amount_generator_cell.getMappedValuePropertyList():
              property_dict[key] = amount_generator_cell.getProperty(key)
            # For intermediate calculations,
            # base_contribution_list MUST be defined
251
            property_dict['base_contribution_list'] = base_contribution_list
252
      for property_dict in resource_amount_aggregate.itervalues():
253
        base_application_set = property_dict['base_application_set']
254 255 256 257 258 259 260 261 262 263 264 265 266 267
        # property_dict should include
        #   resource - VAT service or a Component in MRP
        #   quantity - quantity in component in MRP, (what else XXX)
        #   variation params - color, size, employer share, etc.
        #   price -  empty (like in Transformation) price of a product
        #            (ex. a Stamp) or tax ratio (ie. price per value units)
        #   base_contribution_list - needed to produce reports with
        #                            getTotalPrice
        #
        # Quantity is used as a multiplier (like in transformations for MRP)
        # net_converted_quantity is used preferrably to quantity since we
        # need values converted to the default management unit
        # If no quantity is provided, we consider that the value is 1.0
        # (XXX is it OK ?) XXX-JPS Need careful review with taxes
268 269
        property_dict['quantity'] = sum(base_amount[x]
                                        for x in base_application_set) * \
270 271
          property_dict.pop('net_converted_quantity',
                            property_dict.get('quantity', 1.0))
272 273 274 275
        base_application_set.discard(None)
        # XXX Is it correct to generate nothing if the computed quantity is 0 ?
        if not property_dict['quantity']:
          continue
276 277
        # Create an Amount object
        # XXX-JPS Could we use a movement for safety ?
278 279 280
        amount = newTempAmount(portal, property_dict.pop('id'))
        amount._setCategoryList(property_dict.pop('category_list', ()))
        amount._edit(**property_dict)
281 282
        if rounding:
          # We hope here that rounding is sufficient at line level
283
          amount = getRoundingProxy(amount, context=self)
284 285 286 287 288 289 290 291
        result.append(amount)
      for base_application, property_dict in value_amount_aggregate.iteritems():
        # property_dict should include
        #   base_contribution_list - needed to produce reports with
        #                            getTotalPrice
        #   quantity - quantity in component in MRP, (what else XXX)
        #   price -  empty (like in Transformation) price of a product
        #            (ex. a Stamp) or tax ratio (ie. price per value units)
292
        # XXX Why price ? What about efficiency ?
293 294 295 296 297 298
        value = base_amount[base_application] * \
          (property_dict.get('quantity') or 1.0) * \
          (property_dict.get('price') or 1.0) # XXX-JPS is it really 1.0 ?
          # Quantity is used as a multiplier
          # Price is used as a ratio (also a kind of multiplier)
        for base_key in property_dict['base_contribution_list']:
299 300 301 302 303 304 305 306 307 308
          if base_key in applied_base_amount_set:
            if self in check_wrong_index_list:
              raise ValueError("Duplicate or missing int_index on Amount"
                               " Generator Lines while processing %r" % self)
            else:
              LOG("getGeneratedAmountList", WARNING, "%r contributes to %r"
                  " but this base_amount was already applied. Order of Amount"
                  " Generator Lines may be wrong." % (self, base_key))
          if base_key not in base_amount:
            base_amount[base_key] = getAmountProperty(self, base_key) or 0
309 310
          base_amount[base_key] += value

311 312
    is_mapped_value = isinstance(self, MappedValue)

313 314 315
    # Each amount in amount_list creates a new amount to take into account
    # We thus need to start with a loop on amount_list
    for delivery_amount in amount_list:
316 317 318 319 320 321 322 323 324 325 326 327
      if not is_mapped_value:
        self = delivery_amount.asComposedDocument(amount_generator_type_list)
      # XXX It should be possible to keep specific keys in base_amount dict.
      #     This can be done by a preference listing base_amount categories
      #     for which we want to accumulate quantities.
      base_amount = {None: 1}
      base_contribution_set = delivery_amount.getBaseContributionSet()
      # Check that lines are sorted correctly
      applied_base_amount_set = set()
      # Check that lines with missing or duplicate int_index are independant
      check_wrong_index_set = set()
      # Browse recursively the amount generator lines and accumulate
328 329 330 331 332
      # applicable values - now execute the method
      accumulateAmountList(self)

    return result

Julien Muchembled's avatar
Julien Muchembled committed
333 334
  security.declareProtected(Permissions.AccessContentsInformation,
                            'getAggregatedAmountList')
335 336
  def getAggregatedAmountList(self, amount_list=None, rounding=False,
                              amount_generator_type_list=None):
337 338 339 340
    """
    Implementation of a generic transformation algorith which is
    applicable to payroll, tax generation and BOMs. Return the
    list of amounts with aggregation.
Julien Muchembled's avatar
Julien Muchembled committed
341

342 343
    TODO:
    - make working sample code
344
    """
345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369
    generated_amount_list = self.getGeneratedAmountList(
      amount_list=amount_list, rounding=rounding,
      amount_generator_type_list=amount_generator_type_list)
    aggregated_amount_dict = {}
    result_list = []
    for amount in generated_amount_list:
      key = (amount.getPrice(), amount.getEfficiency(),
             amount.reference, amount.categories)
      aggregated_amount = aggregated_amount_dict.get(key)
      if aggregated_amount is None:
        aggregated_amount_dict[key] = amount
        result_list.append(amount)
      else:
        # XXX How to aggregate rounded amounts ?
        #     What to do if the total price is rounded ??
        assert not rounding, "TODO"
        aggregated_amount.quantity += amount.quantity
    if 0:
      print 'getAggregatedAmountList(%r) -> (%s)' % (
        self.getRelativeUrl(),
        ', '.join('(%s, %s, %s)'
                  % (x.getResourceTitle(), x.getQuantity(), x.getPrice())
                  for x in result_list))
    return result_list

370
    raise NotImplementedError
371
    # Example of return code
372
    result = self.getGeneratedAmountList(amount_list=amount_list,
Julien Muchembled's avatar
Julien Muchembled committed
373
                                         rounding=rounding)
374
    return SomeMovementGroup(result)