amount_generator.py 19.1 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
import zope.interface
from AccessControl import ClassSecurityInfo
32
from Acquisition import Implicit
33
from Products.ERP5.AggregatedAmountList import AggregatedAmountList
34
from Products.ERP5Type import Permissions, interfaces
35
from Products.ERP5Type.TransactionalVariable import getTransactionalVariable
36 37
from Products.ERP5.Document.MappedValue import MappedValue

38 39 40
# 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
Julien Muchembled's avatar
Julien Muchembled committed
41
#        easy way to generate a fixed quantity.
42 43 44 45 46 47
#     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 ?

48
class BaseAmountDict(Implicit):
49 50
  """Dictionary holding accumulated base amounts
  """
51 52
  def __init__(self, cache, method_kw):
    self._dict = {}
53
    self._frozen = set()
54
    self._amount_list = []
55 56 57
    self._cache = cache
    self._method_kw = method_kw

58 59
  def setAmountGeneratorLine(self, amount_generator_line):
    self._amount_generator_line = amount_generator_line
60

61 62
  def recurseMovementList(self, movement_list):
    for amount in movement_list:
63 64 65 66 67
      # 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():
68 69 70
        amount = self.__class__(self._cache, self._method_kw).__of__(amount)
        self._amount_list.append(amount)
        yield amount
71 72
    yield self

73 74 75 76 77
  def contribute(self, base_amount, variation_category_list, value):
    variated_base_amount = base_amount, variation_category_list
    if variated_base_amount in self._frozen:
      if variation_category_list:
        base_amount = (base_amount,) + variation_category_list
78 79
      raise ValueError("Can not contribute to %r because this base_amount is"
                       " already applied. Order of Amount Generator Lines is"
80 81 82
                       " wrong." % (base_amount,))
    self._dict[variated_base_amount] = \
      self._getQuantity(variated_base_amount) + value
83

84
  def _getQuantity(self, variated_base_amount):
85
    """Get intermediate computed quantity for given base_application"""
86
    try:
87
      return self._dict[variated_base_amount]
88 89
    except KeyError:
      value = 0
90
      amount_generator_line = self._amount_generator_line
91 92
      for base_amount_dict in self._amount_list:
        base_amount_dict._amount_generator_line = amount_generator_line
93 94 95
        value += base_amount_dict.getGeneratedAmountQuantity(
          *variated_base_amount)
      self._dict[variated_base_amount] = value
96 97
      return value

98 99 100 101 102 103 104 105 106 107
  getBaseAmountList__roles__ = None # public
  def getBaseAmountList(self):
    """Return list of amounts that are sub-objects of self

    Returned objects are wrapped like self.
    Example: for a delivery, they are manually created movements.
    """
    return list(self._amount_list)

  getGeneratedAmountQuantity__roles__ = None # public
108
  def getGeneratedAmountQuantity(self, base_amount, variation_category_list=()):
Julien Muchembled's avatar
Julien Muchembled committed
109
    """Get final computed quantity for the given base_amount
110

Julien Muchembled's avatar
Julien Muchembled committed
111 112 113 114
    If not yet finalized, this method actually calls the (custom) method that
    actually computes the final quantity, which in turn usually calls this
    method again, for the same amount and key: in this case, the returned value
    of this inner call is the last intermediate value just before finalization.
115
    """
116 117 118 119
    variated_base_amount = base_amount, variation_category_list
    if variated_base_amount in self._frozen:
      return self._getQuantity(variated_base_amount)
    self._frozen.add(variated_base_amount)
120
    try:
121
      method = self._cache[base_amount]
122 123 124 125
    except KeyError:
      method = self._amount_generator_line._getTypeBasedMethod(
        'getBaseAmountQuantityMethod')
      if method is not None:
126
        method = method(base_amount)
127 128
      if method is None:
        method = self._amount_generator_line.getBaseAmountQuantity
129
      self._cache[base_amount] = method
130 131 132 133 134 135 136
    if variation_category_list:
      kw = dict(self._method_kw,
                variation_category_list=variation_category_list)
    else:
      kw = self._method_kw
    value = method(self, base_amount, **kw)
    self._dict[variated_base_amount] = value
137 138
    return value

139 140 141 142

class AmountGeneratorMixin:
  """
  This class provides a generic implementation of IAmountGenerator.
Julien Muchembled's avatar
Julien Muchembled committed
143
  It is used by Transformation, Trade Model, Paysheet, etc. It is
144 145
  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
146
  Lines and Cell provide either directly or through acquisition the
147 148 149
  methods 'getMappedValuePropertyList' and 'getMappedValueBaseCategoryList'
  to gather the properties and categories to copy from the model
  to the generated amounts.
150 151 152 153 154 155 156 157 158
  """

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

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

Julien Muchembled's avatar
Julien Muchembled committed
159 160
  security.declareProtected(Permissions.AccessContentsInformation,
                            'getGeneratedAmountList')
161
  def getGeneratedAmountList(self, amount_list=None, rounding=False,
162 163
                             amount_generator_type_list=None,
                             generate_empty_amounts=True):
Julien Muchembled's avatar
Julien Muchembled committed
164
    """
165 166 167 168 169 170 171 172
    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
    """
Julien Muchembled's avatar
Julien Muchembled committed
173
    # It is the only place where we can import this
174 175
    from Products.ERP5Type.Document import newTempAmount
    portal = self.getPortalObject()
176 177 178 179 180
    getRoundingProxy = portal.portal_roundings.getRoundingProxy
    amount_generator_line_type_list = \
      portal.getPortalAmountGeneratorLineTypeList()
    amount_generator_cell_type_list = \
      portal.getPortalAmountGeneratorCellTypeList()
181 182

    # Set empty result by default
183
    result = AggregatedAmountList()
184

185
    args = (getTransactionalVariable().setdefault(
186
              "amount_generator.BaseAmountDict", {}),
187
            dict(rounding=rounding))
Julien Muchembled's avatar
Julien Muchembled committed
188
    # If amount_list is None, then try to collect amount_list from
189
    # the current context
190
    default_target = None
Julien Muchembled's avatar
Julien Muchembled committed
191
    if amount_list is None:
192
      if self.providesIMovementCollection():
193
        default_target = 'isMovement'
194 195
        base_amount_list = BaseAmountDict(*args).__of__(self) \
          .recurseMovementList(self.getMovementList())
196
      elif self.providesIAmount():
197
        base_amount_list = BaseAmountDict(*args).__of__(self),
198
      elif self.providesIAmountList():
199 200
        base_amount_list = (BaseAmountDict(*args).__of__(amount)
                            for amount in self)
201
      else:
202 203 204
        raise ValueError("%r must implement IMovementCollection, IAmount or"
                         " IAmountList" % self)
    else:
205 206
      base_amount_list = (BaseAmountDict(*args).__of__(amount)
                          for amount in amount_list)
207

208 209 210 211 212
    def getLineSortKey(line):
      int_index = line.getIntIndex()
      return (line.getFloatIndex() if int_index is None else int_index,
              random.random())

213
    def accumulateAmountList(self):
214 215 216
      """Browse recursively the amount generator lines
         and accumulate applicable values
      """
217 218
      amount_generator_line_list = self.contentValues(
        portal_type=amount_generator_line_type_list)
219
      # Recursively feed base_amount
220
      if amount_generator_line_list:
221
        amount_generator_line_list.sort(key=getLineSortKey)
222 223 224
        for amount_generator_line in amount_generator_line_list:
          accumulateAmountList(amount_generator_line)
        return
225 226
      elif (self.getPortalType() not in amount_generator_line_type_list):
        return
227 228
      target_method = self.isTargetDelivery() and 'isDelivery' or default_target
      if target_method and not getattr(delivery_amount, target_method)():
229
        return
230 231
      if not self.test(delivery_amount):
        return
232
      self = self.asPredicate()
233 234 235 236 237
      reference = self.getReference()
      if reference:
        if reference in reference_set:
          return
        reference_set.add(reference)
238 239
      # Try to collect cells and aggregate their mapped properties
      # using resource + variation as aggregation key or base_application
240
      # for intermediate lines.
241
      amount_generator_cell_list = [self] + self.contentValues(
242
        portal_type=amount_generator_cell_type_list)
243
      cell_aggregate = {} # aggregates final line information
244

245 246
      base_application_list = self.getBaseApplicationList()
      base_contribution_list = self.getBaseContributionList()
247
      for cell in amount_generator_cell_list:
248 249 250 251
        if cell is not self:
          if not cell.test(delivery_amount):
            continue
          cell = cell.asPredicate()
252
        key = cell.getCellAggregateKey()
253
        try:
254
          property_dict = cell_aggregate[key]
255 256
        except KeyError:
          cell_aggregate[key] = property_dict = {
257 258
            'base_application_set': set(base_application_list),
            'base_contribution_set': set(base_contribution_list),
259 260
            'category_list': [],
            'causality_value_list': [],
261 262
            'efficiency': self.getEfficiency(),
            'quantity_unit': self.getQuantityUnit(),
263
            # XXX If they are several cells, we have duplicate references.
264
            'reference': reference,
265 266 267
          }
        # Then collect the mapped values (quantity, price, trade_phase...)
        for key in cell.getMappedValuePropertyList():
268 269 270 271 272
          if key in ('net_converted_quantity',
                     'net_quantity', 'converted_quantity'):
            # XXX only 'quantity' is accepted and it is treated
            #     as if it was 'converted_quantity'
            raise NotImplementedError
273
          # XXX-JPS Make sure handling of list properties can be handled
274
          property_dict[key] = cell.getProperty(key)
275 276 277 278
        category_list = cell.getAcquiredCategoryMembershipList(
          cell.getMappedValueBaseCategoryList(), base=1)
        property_dict['category_list'] += category_list
        property_dict['resource'] = cell.getResource()
279 280 281
        if cell is not self:
          # cells inherit base_application and base_contribution from line
          property_dict['base_application_set'].update(
282
            cell.getBaseApplicationList())
283
          property_dict['base_contribution_set'].update(
284 285 286
            cell.getBaseContributionList())
        property_dict['causality_value_list'].append(cell)

287
      base_amount.setAmountGeneratorLine(self)
288
      for property_dict in cell_aggregate.itervalues():
289 290 291
        # Ignore line (i.e. self) if cells produce unrelated amounts.
        # With Transformed Resource (Transformation), line is considered in
        # order to gather common properties and cells are used to describe
Julien Muchembled's avatar
Julien Muchembled committed
292
        # variated properties: only 1 amount is produced.
293 294 295 296 297 298
        # In cases like trade, payroll or assorted resources,
        # we want to ignore the line if they are cells.
        # See also implementations of 'getCellAggregateKey'
        causality_value = property_dict['causality_value_list'][-1]
        if causality_value is self and len(cell_aggregate) > 1:
          continue
299
        base_application_set = property_dict['base_application_set']
300 301 302 303 304
        # allow a single base_application to be variated
        variation_category_list = tuple(sorted([x for x in base_application_set
                                                  if x[:12] != 'base_amount/']))
        if variation_category_list:
          base_application_set.difference_update(variation_category_list)
305
        # property_dict may include
306
        #   resource - VAT service or a Component in MRP
307
        #              (if unset, the amount will only be used for reporting)
308
        #   variation params - color, size, employer share, etc.
309 310 311
        #   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
312 313 314 315
        #   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
316
        # 'efficiency' is stored separately in the generated amount,
317
        # for future simulation of efficiencies.
318 319
        # If no quantity is provided, we consider that the value is 1.0
        # (XXX is it OK ?) XXX-JPS Need careful review with taxes
320 321 322
        quantity = float(sum(base_amount.getGeneratedAmountQuantity(
                               base_application, variation_category_list)
                             for base_application in base_application_set))
323 324 325 326
        for key in 'quantity', 'price', 'efficiency':
          if property_dict.get(key, 0) in (None, ''):
            del property_dict[key]
        quantity *= property_dict.pop('quantity', 1)
327 328 329 330 331 332 333 334

        # Before we ignore 'quantity==0' amount here for better
        # performance, but it is not a good idea, especially when the
        # first expand causes non-zero quantity and then quantity
        # becomes zero.
        # if not (quantity or generate_empty_amounts):
        #   continue

335
        # Backward compatibility
Julien Muchembled's avatar
Julien Muchembled committed
336
        if getattr(self.aq_base, 'create_line', None) == 0:
337
          property_dict['resource'] = None
338
        # Create an Amount object
339
        amount = newTempAmount(portal,
340
          # we only want the id to be unique so we pick a random causality
341 342
          causality_value.getRelativeUrl().replace('/', '_'),
          notify_workflow=False)
343
        amount._setCategoryList(property_dict.pop('category_list', ()))
344 345
        if amount.getQuantityUnit():
          del property_dict['quantity_unit']
346
        amount._edit(
347
          quantity=quantity,
348 349 350 351 352
          # XXX Are title, int_index and description useful ??
          title=self.getTitle(),
          int_index=self.getIntIndex(),
          description=self.getDescription(),
          **property_dict)
353 354 355
        # convert to default management unit if possible
        amount._setQuantity(amount.getConvertedQuantity())
        amount._setQuantityUnit(amount.getResourceDefaultQuantityUnit())
356 357
        if rounding:
          # We hope here that rounding is sufficient at line level
358
          amount = getRoundingProxy(amount, context=self)
359
        result.append(amount)
360
        # Contribute
361 362 363 364 365
        quantity *= property_dict.get('price', 1)
        try:
          quantity /= property_dict.get('efficiency', 1)
        except ZeroDivisionError:
          quantity *= float('inf')
366 367 368 369 370 371 372 373 374
        base_contribution_set = property_dict['base_contribution_set']
        # allow a single base_contribution to be variated
        variation_category_list = tuple(sorted([x for x in base_contribution_set
                                                  if x[:12] != 'base_amount/']))
        if variation_category_list:
          base_contribution_set.difference_update(variation_category_list)
        for base_contribution in base_contribution_set:
          base_amount.contribute(base_contribution, variation_category_list,
                                 quantity)
375

376 377
    is_mapped_value = isinstance(self, MappedValue)

378 379 380 381
    for base_amount in base_amount_list:
      delivery_amount = base_amount.getObject()
      if not is_mapped_value:
        self = delivery_amount.asComposedDocument(amount_generator_type_list)
382 383 384
      # If several amount generator lines have same reference, the first
      # (sorted by int_index or float_index) matching one will mask the others.
      reference_set = set()
385
      accumulateAmountList(self)
386
    return result
387

Julien Muchembled's avatar
Julien Muchembled committed
388 389
  security.declareProtected(Permissions.AccessContentsInformation,
                            'getAggregatedAmountList')
390
  def getAggregatedAmountList(self, amount_list=None, rounding=False,
391 392
                              amount_generator_type_list=None,
                              generate_empty_amounts=True):
393 394 395 396 397
    """
    Implementation of a generic transformation algorith which is
    applicable to payroll, tax generation and BOMs. Return the
    list of amounts with aggregation.
    """
398 399
    generated_amount_list = self.getGeneratedAmountList(
      amount_list=amount_list, rounding=rounding,
400
      amount_generator_type_list=amount_generator_type_list)
401 402 403
    # XXX: Do we handle rounding correctly ?
    #      What to do if only total price is rounded ??
    aggregate_dict = {}
404
    result_list = AggregatedAmountList()
405 406
    for amount in generated_amount_list:
      key = (amount.getPrice(), amount.getEfficiency(),
407
             amount.getReference(), amount.categories)
408 409 410
      aggregate = aggregate_dict.get(key)
      if aggregate is None:
        aggregate_dict[key] = [amount, amount.getQuantity()]
411 412
        result_list.append(amount)
      else:
413 414
        aggregate[1] += amount.getQuantity()
    for amount, quantity in aggregate_dict.itervalues():
415 416 417 418 419 420 421 422 423
      # Before we ignore 'quantity==0' amount here for better
      # performance, but it is not a good idea, especially when the
      # first expand causes non-zero quantity and then quantity
      # becomes zero.
      # if quantity or generate_empty_amounts:
      #   amount._setQuantity(quantity)
      # else:
      #   result_list.remove(amount)
      amount._setQuantity(quantity)
424 425 426 427 428 429 430
    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