amount_generator.py 15.5 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 31 32
import zope.interface
from AccessControl import ClassSecurityInfo
from Products.ERP5Type import Permissions, interfaces
33
from Products.ERP5Type.TransactionalVariable import getTransactionalVariable
34 35
from Products.ERP5.Document.MappedValue import MappedValue

36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
# XXX What should be done when there is no base_application ?
#     There are 2 options:
#     1. Make the amount generator line always apply, once, which provides an
#        easy way to generator a fixed quantity.
#     2. Use this criterion to know if a movement was created manually.
#        This is required to not generate amounts from movements that
#        are already the result of generated amounts.
#     Old simulation implemented both but they conflict.
#     Current code implements the 2nd option: Should we use 'use' instead ?

class BaseAmount(dict):
  """Dictionary holding accumulated base amounts
  """

  def __init__(self, context, cache, method_kw):
    self._context = context
    self._frozen = set()
    self._lazy = []
    self._cache = cache
    self._method_kw = method_kw

  def getContext(self):
    return self._context

60 61
  def setAmountGeneratorLine(self, amount_generator_line):
    self._amount_generator_line = amount_generator_line
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85

  def recurse(self, portal_type=None):
    for amount in self._context.objectValues(portal_type=portal_type):
      # Add only movement which are input. Output will be recalculated.
      # XXX See above comment about the absence of base_application
      #     (for example, we could check if resource use category is in the
      #     normal resource use preference list).
      if not amount.getBaseApplication():
        base_amount = self.__class__(amount, self._cache, self._method_kw)
        self._lazy.append(base_amount)
        for base_amount in base_amount.recurse(portal_type):
          yield base_amount
    yield self

  def __getitem__(self, key):
    """Get intermediate computed quantity for given base_application"""
    if key in self._frozen:
      raise ValueError("Can not contribute to %r because this base_amount is"
                       " already applied. Order of Amount Generator Lines is"
                       " wrong." % key)
    try:
      return dict.__getitem__(self, key)
    except KeyError:
      value = 0
86
      amount_generator_line = self._amount_generator_line
87
      for lazy in self._lazy:
88
        lazy._amount_generator_line = amount_generator_line
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
        value += lazy.getQuantity(key)
      self[key] = value
      return value

  def getQuantity(self, key):
    """Get final computed quantity for given base_application

    Note: During a call to getQuantity, this method may be called again by
          getGeneratedAmountQuantity for the same amount and key.
          In this case, the returned value is the last intermediate value just
          before finalization.
    """
    if key in self._frozen:
      return dict.__getitem__(self, key)
    self[key] # initialize entry before we freeze it
    self._frozen.add(key)
105 106 107 108 109 110 111 112 113 114 115
    try:
      method = self._cache[key]
    except KeyError:
      method = self._amount_generator_line._getTypeBasedMethod(
        'getBaseAmountQuantityMethod')
      if method is not None:
        method = method(key)
      if method is None:
        method = self._amount_generator_line.getBaseAmountQuantity
      self._cache[key] = method
    self[key] = value = method(self._context, key, **self._method_kw)
116 117
    return value

118 119 120 121

class AmountGeneratorMixin:
  """
  This class provides a generic implementation of IAmountGenerator.
Julien Muchembled's avatar
Julien Muchembled committed
122
  It is used by Transformation, Trade Model, Paysheet, etc. It is
123 124
  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
125
  Lines and Cell provide either directly or through acquisition the
126 127 128
  methods 'getMappedValuePropertyList' and 'getMappedValueBaseCategoryList'
  to gather the properties and categories to copy from the model
  to the generated amounts.
129 130 131 132 133 134 135 136 137
  """

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

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

138 139 140 141 142 143 144
  security.declareProtected(Permissions.AccessContentsInformation,
                            'getGeneratedAmountQuantity')
  def getGeneratedAmountQuantity(self, base_application):
    """Give access to computed quantities during generation of amounts"""
    base_amount = getTransactionalVariable()[
      'amount_generator.getGeneratedAmountList'][self]
    return base_amount.getQuantity(base_application)
145

Julien Muchembled's avatar
Julien Muchembled committed
146 147
  security.declareProtected(Permissions.AccessContentsInformation,
                            'getGeneratedAmountList')
148 149
  def getGeneratedAmountList(self, amount_list=None, rounding=False,
                             amount_generator_type_list=None):
Julien Muchembled's avatar
Julien Muchembled committed
150
    """
151 152 153 154 155 156 157 158
    Implementation of a generic transformation algorithm which is
    applicable to payroll, tax generation and BOMs. Return the
    list of amounts without any aggregation.

    TODO:
    - is rounding really well supported (ie. before and after aggregation)
      very likely not - proxying before or after must be decided
    """
159 160 161
    # It is the only place we can import this
    from Products.ERP5Type.Document import newTempAmount
    portal = self.getPortalObject()
162 163 164 165 166
    getRoundingProxy = portal.portal_roundings.getRoundingProxy
    amount_generator_line_type_list = \
      portal.getPortalAmountGeneratorLineTypeList()
    amount_generator_cell_type_list = \
      portal.getPortalAmountGeneratorCellTypeList()
167 168 169

    # Set empty result by default
    result = []
170

171 172 173
    args = (getTransactionalVariable().setdefault(
              "amount_generator.BaseAmount", {}),
            dict(rounding=rounding))
Julien Muchembled's avatar
Julien Muchembled committed
174
    # If amount_list is None, then try to collect amount_list from
175
    # the current context
Julien Muchembled's avatar
Julien Muchembled committed
176
    if amount_list is None:
177
      if self.providesIMovementCollection():
178 179
        base_amount_list = BaseAmount(self, *args) \
          .recurse(amount_generator_type_list)
180
      elif self.providesIAmount():
181
        base_amount_list = BaseAmount(self, *args),
182
      elif self.providesIAmountList():
183
        base_amount_list = (BaseAmount(amount, *args) for amount in self)
184
      else:
185 186 187 188
        raise ValueError("%r must implement IMovementCollection, IAmount or"
                         " IAmountList" % self)
    else:
      base_amount_list = (BaseAmount(amount, *args) for amount in amount_list)
189

190
    # First define the method that will browse recursively
191
    # the amount generator lines and accumulate applicable values
192 193 194
    def accumulateAmountList(self):
      amount_generator_line_list = self.contentValues(
        portal_type=amount_generator_line_type_list)
195
      # Recursively feed base_amount
196 197 198
      if amount_generator_line_list:
        amount_generator_line_list.sort(key=lambda x: (x.getIntIndex(),
                                                       random.random()))
199 200 201
        for amount_generator_line in amount_generator_line_list:
          accumulateAmountList(amount_generator_line)
        return
202 203
      elif (self.getPortalType() not in amount_generator_line_type_list):
        return
204 205 206
      if not getattr(delivery_amount, self.isTargetDelivery() and
                                      'isDelivery' or 'isMovement')():
        return
207 208 209
      # Try to collect cells and aggregate their mapped properties
      # using resource + variation as aggregation key or base_application
      # for intermediate lines
210
      amount_generator_cell_list = [self] + self.contentValues(
211
        portal_type=amount_generator_cell_type_list)
212
      cell_aggregate = {} # aggregates final line information
213

214 215 216 217
      for cell in amount_generator_cell_list:
        if not cell.test(delivery_amount):
          if cell is self:
            return
218
          continue
219
        key = cell.getCellAggregateKey()
220
        try:
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246
          application_dict = cell_aggregate[key]
        except KeyError:
          cell_aggregate[key] = property_dict = {
            'base_application_set': set(),
            'base_contribution_set': set(),
            'category_list': [],
            'causality_value_list': [],
            # XXX If they are several cells, we have duplicate references.
            'reference': self.getReference(),
          }
        # Then collect the mapped values (quantity, price, trade_phase...)
        for key in cell.getMappedValuePropertyList():
          # XXX-JPS Make sure handling of list properties can be handled
          property_dict[key] = cell.getProperty(key)
        category_list = cell.getAcquiredCategoryMembershipList(
          cell.getMappedValueBaseCategoryList(), base=1)
        property_dict['category_list'] += category_list
        property_dict['resource'] = cell.getResource()
        # For final amounts, base_application and id MUST be defined
        property_dict['base_application_set'].update(
            cell.getBaseApplicationList())
        # For intermediate calculations, base_contribution_list MUST be defined
        property_dict['base_contribution_set'].update(
            cell.getBaseContributionList())
        property_dict['causality_value_list'].append(cell)

247
      base_amount.setAmountGeneratorLine(self)
248
      for property_dict in cell_aggregate.itervalues():
249
        base_application_set = property_dict['base_application_set']
250
        # property_dict may include
251
        #   resource - VAT service or a Component in MRP
252
        #              (if unset, the amount will only be used for reporting)
253
        #   variation params - color, size, employer share, etc.
254 255 256
        #   one of (net_)(converted_)quantity - used as a multiplier
        #     -> in MRP, quantity in component
        #     -> for trade, it provides a way to configure a fixed quantity
257 258 259 260
        #   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
261 262 263 264
        # 'efficiency' is stored separately in the generated amount,
        # for future simulation of efficiencies (use net_quantity otherwise).
        # 'converted_quantity' is used preferrably to 'quantity' since we
        # need values converted to the default management unit.
265 266
        # If no quantity is provided, we consider that the value is 1.0
        # (XXX is it OK ?) XXX-JPS Need careful review with taxes
267 268 269 270 271 272 273 274 275 276 277
        quantity = float(sum(map(base_amount.getQuantity,
                                 base_application_set)))
        for quantity_key in ('net_quantity', 'converted_quantity',
                             'net_converted_quantity', 'quantity'):
          if quantity_key in property_dict:
            try:
              quantity *= property_dict.pop(quantity_key)
            except ValueError: # None or ''
              pass
            break
        if not quantity:
278
          continue
279
        # Create an Amount object
280 281
        amount = newTempAmount(portal,
          # we only want the id to be unique
282 283
          property_dict['causality_value_list'][0]
          .getRelativeUrl().replace('/', '_'))
284
        amount._setCategoryList(property_dict.pop('category_list', ()))
285
        amount._edit(
286
          quantity=quantity,
287 288 289 290 291
          # XXX Are title, int_index and description useful ??
          title=self.getTitle(),
          int_index=self.getIntIndex(),
          description=self.getDescription(),
          **property_dict)
292 293
        if rounding:
          # We hope here that rounding is sufficient at line level
294
          amount = getRoundingProxy(amount, context=self)
295
        result.append(amount)
296
        # Contribute
297 298 299 300
        quantity *= (property_dict.get('price') or 1) / \
                    (property_dict.get('efficiency') or 1)
        for base_contribution in property_dict['base_contribution_set']:
          base_amount[base_contribution] += quantity
301

302 303
    is_mapped_value = isinstance(self, MappedValue)

304 305 306 307 308 309 310 311 312 313 314 315 316 317 318
    tv = getTransactionalVariable()
    # backup & restore existing cached value for reentrancy
    original_cache = tv.get('amount_generator.getGeneratedAmountList')
    try:
      tv['amount_generator.getGeneratedAmountList'] = base_amount_cache = {}
      for base_amount in base_amount_list:
        delivery_amount = base_amount.getContext()
        base_amount_cache[delivery_amount] = base_amount
        if not is_mapped_value:
          self = delivery_amount.asComposedDocument(amount_generator_type_list)
        # Browse recursively the amount generator lines and accumulate
        # applicable values - now execute the method
        accumulateAmountList(self)
    finally:
      tv['amount_generator.getGeneratedAmountList'] = original_cache
319 320
    return result

Julien Muchembled's avatar
Julien Muchembled committed
321 322
  security.declareProtected(Permissions.AccessContentsInformation,
                            'getAggregatedAmountList')
323 324
  def getAggregatedAmountList(self, amount_list=None, rounding=False,
                              amount_generator_type_list=None):
325 326 327 328
    """
    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
329

330 331
    TODO:
    - make working sample code
332
    """
333 334 335 336 337 338 339
    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(),
340
             amount.getReference(), amount.categories)
341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357
      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

358
    raise NotImplementedError
359
    # Example of return code
360
    result = self.getGeneratedAmountList(amount_list=amount_list,
Julien Muchembled's avatar
Julien Muchembled committed
361
                                         rounding=rounding)
362
    return SomeMovementGroup(result)