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

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

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

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

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

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

326 327
    is_mapped_value = isinstance(self, MappedValue)

328 329 330 331 332 333 334
    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)
335 336
    return result

Julien Muchembled's avatar
Julien Muchembled committed
337 338
  security.declareProtected(Permissions.AccessContentsInformation,
                            'getAggregatedAmountList')
339 340
  def getAggregatedAmountList(self, amount_list=None, rounding=False,
                              amount_generator_type_list=None):
341 342 343 344
    """
    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
345

346 347
    TODO:
    - make working sample code
348
    """
349 350 351 352
    generated_amount_list = self.getGeneratedAmountList(
      amount_list=amount_list, rounding=rounding,
      amount_generator_type_list=amount_generator_type_list)
    aggregated_amount_dict = {}
353
    result_list = AggregatedAmountList()
354 355
    for amount in generated_amount_list:
      key = (amount.getPrice(), amount.getEfficiency(),
356
             amount.getReference(), amount.categories)
357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373
      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

374
    raise NotImplementedError
375
    # Example of return code
376
    result = self.getGeneratedAmountList(amount_list=amount_list,
Julien Muchembled's avatar
Julien Muchembled committed
377
                                         rounding=rounding)
378
    return SomeMovementGroup(result)