amount_generator.py 16.7 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 41 42 43 44 45 46 47
# 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 ?

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
  def contribute(self, base_amount, value):
    if base_amount in self._frozen:
75 76
      raise ValueError("Can not contribute to %r because this base_amount is"
                       " already applied. Order of Amount Generator Lines is"
77 78 79 80 81
                       " wrong." % base_amount)
    self._dict[base_amount] = self._getQuantity(base_amount) + value

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

93 94 95 96 97 98 99 100 101 102 103 104
  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
  def getGeneratedAmountQuantity(self, base_amount):
    """Get final computed quantity for given base_amount
105 106 107 108 109 110

    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.
    """
111 112 113
    if base_amount in self._frozen:
      return self._getQuantity(base_amount)
    self._frozen.add(base_amount)
114
    try:
115
      method = self._cache[base_amount]
116 117 118 119
    except KeyError:
      method = self._amount_generator_line._getTypeBasedMethod(
        'getBaseAmountQuantityMethod')
      if method is not None:
120
        method = method(base_amount)
121 122
      if method is None:
        method = self._amount_generator_line.getBaseAmountQuantity
123 124 125
      self._cache[base_amount] = method
    value = method(self, base_amount, **self._method_kw)
    self._dict[base_amount] = value
126 127
    return value

128 129 130 131

class AmountGeneratorMixin:
  """
  This class provides a generic implementation of IAmountGenerator.
Julien Muchembled's avatar
Julien Muchembled committed
132
  It is used by Transformation, Trade Model, Paysheet, etc. It is
133 134
  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
135
  Lines and Cell provide either directly or through acquisition the
136 137 138
  methods 'getMappedValuePropertyList' and 'getMappedValueBaseCategoryList'
  to gather the properties and categories to copy from the model
  to the generated amounts.
139 140 141 142 143 144 145 146 147
  """

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

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

Julien Muchembled's avatar
Julien Muchembled committed
148 149
  security.declareProtected(Permissions.AccessContentsInformation,
                            'getGeneratedAmountList')
150
  def getGeneratedAmountList(self, amount_list=None, rounding=False,
151 152
                             amount_generator_type_list=None,
                             generate_empty_amounts=False):
Julien Muchembled's avatar
Julien Muchembled committed
153
    """
154 155 156 157 158 159 160 161
    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
    """
162 163 164
    # It is the only place we can import this
    from Products.ERP5Type.Document import newTempAmount
    portal = self.getPortalObject()
165 166 167 168 169
    getRoundingProxy = portal.portal_roundings.getRoundingProxy
    amount_generator_line_type_list = \
      portal.getPortalAmountGeneratorLineTypeList()
    amount_generator_cell_type_list = \
      portal.getPortalAmountGeneratorCellTypeList()
170 171

    # Set empty result by default
172
    result = AggregatedAmountList()
173

174
    args = (getTransactionalVariable().setdefault(
175
              "amount_generator.BaseAmountDict", {}),
176
            dict(rounding=rounding))
Julien Muchembled's avatar
Julien Muchembled committed
177
    # If amount_list is None, then try to collect amount_list from
178
    # the current context
179
    default_target = None
Julien Muchembled's avatar
Julien Muchembled committed
180
    if amount_list is None:
181
      if self.providesIMovementCollection():
182
        default_target = 'isMovement'
183 184
        base_amount_list = BaseAmountDict(*args).__of__(self) \
          .recurseMovementList(self.getMovementList())
185
      elif self.providesIAmount():
186
        base_amount_list = BaseAmountDict(*args).__of__(self),
187
      elif self.providesIAmountList():
188 189
        base_amount_list = (BaseAmountDict(*args).__of__(amount)
                            for amount in self)
190
      else:
191 192 193
        raise ValueError("%r must implement IMovementCollection, IAmount or"
                         " IAmountList" % self)
    else:
194 195
      base_amount_list = (BaseAmountDict(*args).__of__(amount)
                          for amount in amount_list)
196

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

221 222 223 224
      for cell in amount_generator_cell_list:
        if not cell.test(delivery_amount):
          if cell is self:
            return
225
          continue
226
        key = cell.getCellAggregateKey()
227
        try:
228
          property_dict = cell_aggregate[key]
229 230 231 232 233 234
        except KeyError:
          cell_aggregate[key] = property_dict = {
            'base_application_set': set(),
            'base_contribution_set': set(),
            'category_list': [],
            'causality_value_list': [],
235 236
            'efficiency': self.getEfficiency(),
            'quantity_unit': self.getQuantityUnit(),
237 238 239 240 241
            # 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():
242 243 244 245 246
          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
247
          # XXX-JPS Make sure handling of list properties can be handled
248
          property_dict[key] = cell.getProperty(key)
249 250 251 252 253 254 255 256 257 258 259 260
        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)

261
      base_amount.setAmountGeneratorLine(self)
262
      for property_dict in cell_aggregate.itervalues():
263 264 265 266 267 268 269 270 271 272
        # 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
        # varianted properties: only 1 amount is produced.
        # 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
273
        base_application_set = property_dict['base_application_set']
274
        # property_dict may include
275
        #   resource - VAT service or a Component in MRP
276
        #              (if unset, the amount will only be used for reporting)
277
        #   variation params - color, size, employer share, etc.
278 279 280
        #   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
281 282 283 284
        #   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
285
        # 'efficiency' is stored separately in the generated amount,
286
        # for future simulation of efficiencies.
287 288
        # If no quantity is provided, we consider that the value is 1.0
        # (XXX is it OK ?) XXX-JPS Need careful review with taxes
289
        quantity = float(sum(map(base_amount.getGeneratedAmountQuantity,
290
                                 base_application_set)))
291 292 293 294
        for key in 'quantity', 'price', 'efficiency':
          if property_dict.get(key, 0) in (None, ''):
            del property_dict[key]
        quantity *= property_dict.pop('quantity', 1)
295
        if not (quantity or generate_empty_amounts):
296
          continue
297
        # Backward compatibility
Julien Muchembled's avatar
Julien Muchembled committed
298
        if getattr(self.aq_base, 'create_line', None) == 0:
299
          property_dict['resource'] = None
300
        # Create an Amount object
301
        amount = newTempAmount(portal,
302 303
          # we only want the id to be unique so we pick a random causality
          causality_value.getRelativeUrl().replace('/', '_'))
304
        amount._setCategoryList(property_dict.pop('category_list', ()))
305 306
        if amount.getQuantityUnit():
          del property_dict['quantity_unit']
307
        amount._edit(
308
          quantity=quantity,
309 310 311 312 313
          # XXX Are title, int_index and description useful ??
          title=self.getTitle(),
          int_index=self.getIntIndex(),
          description=self.getDescription(),
          **property_dict)
314 315 316
        # convert to default management unit if possible
        amount._setQuantity(amount.getConvertedQuantity())
        amount._setQuantityUnit(amount.getResourceDefaultQuantityUnit())
317 318
        if rounding:
          # We hope here that rounding is sufficient at line level
319
          amount = getRoundingProxy(amount, context=self)
320
        result.append(amount)
321
        # Contribute
322 323 324 325 326
        quantity *= property_dict.get('price', 1)
        try:
          quantity /= property_dict.get('efficiency', 1)
        except ZeroDivisionError:
          quantity *= float('inf')
327
        for base_contribution in property_dict['base_contribution_set']:
328
          base_amount.contribute(base_contribution, quantity)
329

330 331
    is_mapped_value = isinstance(self, MappedValue)

332 333 334 335 336 337 338
    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)
      # Browse recursively the amount generator lines and accumulate
      # applicable values - now execute the method
      accumulateAmountList(self)
339 340
    return result

Julien Muchembled's avatar
Julien Muchembled committed
341 342
  security.declareProtected(Permissions.AccessContentsInformation,
                            'getAggregatedAmountList')
343
  def getAggregatedAmountList(self, amount_list=None, rounding=False,
344 345
                              amount_generator_type_list=None,
                              generate_empty_amounts=False):
346 347 348 349 350
    """
    Implementation of a generic transformation algorith which is
    applicable to payroll, tax generation and BOMs. Return the
    list of amounts with aggregation.
    """
351 352
    generated_amount_list = self.getGeneratedAmountList(
      amount_list=amount_list, rounding=rounding,
353 354
      amount_generator_type_list=amount_generator_type_list,
      generate_empty_amounts=generate_empty_amounts)
355 356 357
    # XXX: Do we handle rounding correctly ?
    #      What to do if only total price is rounded ??
    aggregate_dict = {}
358
    result_list = AggregatedAmountList()
359 360
    for amount in generated_amount_list:
      key = (amount.getPrice(), amount.getEfficiency(),
361
             amount.getReference(), amount.categories)
362 363 364
      aggregate = aggregate_dict.get(key)
      if aggregate is None:
        aggregate_dict[key] = [amount, amount.getQuantity()]
365 366
        result_list.append(amount)
      else:
367 368
        aggregate[1] += amount.getQuantity()
    for amount, quantity in aggregate_dict.itervalues():
369 370 371 372
      if quantity or generate_empty_amounts:
        amount._setQuantity(quantity)
      else:
        result_list.remove(amount)
373 374 375 376 377 378 379
    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