rule.py 19.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 29 30
# -*- 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.
#
##############################################################################

import zope.interface
from AccessControl import ClassSecurityInfo
Kazuhiko Shiozaki's avatar
Kazuhiko Shiozaki committed
31 32
from Acquisition import aq_base
from Products.CMFCore.utils import getToolByName
33
from Products.ERP5Type import Permissions, interfaces
34
from Products.ERP5Type.Core.Predicate import Predicate
35 36 37
from Products.ERP5.MovementCollectionDiff import _getPropertyAndCategoryList

from zLOG import LOG
38

Jean-Paul Smets's avatar
Jean-Paul Smets committed
39 40 41 42 43 44
def _compare(tester_list, prevision_movement, decision_movement):
  for tester in tester_list:
    if not tester.compare(prevision_movement, decision_movement):
      return False
  return True

45 46 47 48 49
class MovementGeneratorMixin:
  """
  This class provides a generic implementation of IMovementGenerator
  which can be used together the Rule mixin class bellow. It does not
  have any pretention to provide more than that.
50 51 52 53 54

  TODO:
    - _getInputMovementList is still not well defined. Should input
      be an amount (_getInputAmountList) or a movement? This 
      requires careful thiking.
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
  """
  # Default values
  _applied_rule = None
  _rule = None
  _trade_phase_list = None
  _explanation = None

  def __init__(self, applied_rule, explanation=None, rule=None, trade_phase_list=None):
    self._trade_phase_list = trade_phase_list # XXX-JPS Why a list ?
    self._applied_rule = applied_rule
    if rule is None and applied_rule is not None:
      self._rule = applied_rule.getSpecialiseValue()
    else:
      self._rule = rule # for rule specific stuff
    if explanation is None:
70
      self._explanation = applied_rule
71
    else:
72 73 74 75
      # A good example of explicit explanation can be getRootExplanationLineValue
      # since different lines could have different dates
      # such an explicit root explanation only works if
      # indexing of simulation has already happened
76 77 78 79 80 81
      self._explanation = explanation
    # XXX-JPS handle delay_mode

  # Implementation of IMovementGenerator
  def getGeneratedMovementList(self, movement_list=None, rounding=False):
    """
Jérome Perrin's avatar
Jérome Perrin committed
82
    Returns a list of movements generated by that rule.
83 84 85 86 87 88 89 90 91 92

    movement_list - optional IMovementList which can be passed explicitely

    rounding - boolean argument, which controls if rounding shall be applied on
               generated movements or not

    NOTE:
      - implement rounding appropriately (True or False seems
        simplistic)
    """
Leonardo Rochael Almeida's avatar
typo  
Leonardo Rochael Almeida committed
93
    # Default implementation below can be overriden by subclasses
94 95
    # however it should be generic enough not to be overriden
    # by most classes
96
    # Results will be appended to result
97 98
    result = []
    # Build a list of movement and business path
99 100 101
    input_movement_list = self._getInputMovementList(
                            movement_list=movement_list, rounding=rounding)
    for input_movement in input_movement_list:
102 103 104
      # Merge movement and business path properties (core implementation)
      # Lookup Business Process through composition (NOT UNION)
      business_process = input_movement.asComposedDocument()
105
      explanation = self._applied_rule # We use applied rule as local explanation
106
      trade_phase = self._getTradePhaseList(input_movement, business_process) # XXX-JPS not convenient to handle
107
      update_property_dict = self._getUpdatePropertyDict(input_movement)
108
      result.extend(business_process.getTradePhaseMovementList(explanation, input_movement,
109 110
                                                 trade_phase=trade_phase, delay_mode=None,
                                                 update_property_dict=update_property_dict))
111 112 113 114 115

    # And return list of generated movements
    return result

  def _getUpdatePropertyDict(self, input_movement):
116 117 118 119
    # XXX Wouldn't it better to return {} or {'delivery': None} ?
    #     Below code is mainly for root applied rules.
    #     Other movement generators usually want to reset delivery.
    return {'delivery': input_movement.getRelativeUrl()}
120 121 122 123

  def _getTradePhaseList(self, input_movement, business_process): # XXX-JPS WEIRD
    if self._trade_phase_list:
      return self._trade_phase_list
124 125 126 127
    if self._rule is not None:
      trade_phase_list = self._rule.getTradePhaseList()
      if trade_phase_list:
        return trade_phase_list
128 129
    return input_movement.getTradePhaseList() or \
      business_process.getTradePhaseList()
130

131
  def _getInputMovementList(self, movement_list=None, rounding=None): #XXX-JPS should it be amount or movement ?
132 133 134 135 136 137
    raise NotImplementedError
    # Default implementation takes amounts ?
    # Use TradeModelRuleMovementGenerator._getInputMovementList as default implementation
    # and potentially use trade phase for that.... as a way to filter out


Julien Muchembled's avatar
Julien Muchembled committed
138
class RuleMixin(Predicate):
139 140
  """
  Provides generic methods and helper methods to implement
141
  IRule and IMovementCollectionUpdater.
142 143 144 145 146 147 148
  """
  # Declarative security
  security = ClassSecurityInfo()
  security.declareObjectProtected(Permissions.AccessContentsInformation)

  # Declarative interfaces
  zope.interface.implements(interfaces.IRule,
149
                            interfaces.IDivergenceController,
150 151
                            interfaces.IMovementCollectionUpdater,)

152 153 154
  # Portal Type of created children
  movement_type = 'Simulation Movement'

155
  # Implementation of IRule
156
  def constructNewAppliedRule(self, context, id=None,
157 158 159 160
                              activate_kw=None, **kw):
    """
    Create a new applied rule in the context.

161
    An applied rule is an instantiation of a Rule. The applied rule is
162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181
    linked to the Rule through the `specialise` relation. The newly
    created rule should thus point to self.

    context -- usually, a parent simulation movement of the
               newly created applied rule

    activate_kw -- activity parameters, required to control
                   activity constraints

    kw -- XXX-JPS probably wrong interface specification
    """
    if id is None:
      id = context.generateNewId()
    if getattr(aq_base(context), id, None) is None:
      context.newContent(id=id,
                         portal_type='Applied Rule',
                         specialise_value=self,
                         activate_kw=activate_kw)
    return context.get(id)

Julien Muchembled's avatar
Julien Muchembled committed
182 183 184 185 186 187
  if 0: # XXX-JPS - if people are stupid enough not to configfure predicates,
        # it is not our role to be clever for them
        # Rules have a workflow - make sure applicable rule system works
        # if you wish, add a test here on workflow state to prevent using
        # rules which are no longer applicable
   def test(self, *args, **kw):
188 189 190
    """
    If no test method is defined, return False, to prevent infinite loop
    """
Julien Muchembled's avatar
Julien Muchembled committed
191 192 193
    if not self.getTestMethodId():
      return False
    return super(RuleMixin, self).test(*args, **kw)
194

195 196 197 198 199 200
  def expand(self, applied_rule, **kw):
    """
    Expand this applied rule to create new documents inside the
    applied rule.

    At expand time, we must replace or compensate certain
201
    properties. However, if some properties were overwritten
Jérome Perrin's avatar
Jérome Perrin committed
202
    by a decision (ie. a resource is changed), then we
203 204 205 206
    should not try to compensate such a decision.
    """
    # Update movements
    #  NOTE-JPS: it is OK to make rounding a standard parameter of rules
207
    #            although rounding in simulation is not recommended at all
208
    self.updateMovementCollection(applied_rule, movement_generator=self._getMovementGenerator(applied_rule))
209 210
    # And forward expand
    for movement in applied_rule.getMovementList():
211
      movement.expand(**kw)
212

Julien Muchembled's avatar
Julien Muchembled committed
213 214 215 216 217 218 219 220
  security.declareProtected(Permissions.AccessContentsInformation,
                            'isAccountable')
  def isAccountable(self, movement):
    """Tells wether generated movement needs to be accounted or not.

    Only account movements which are not associated to a delivery;
    Whenever delivery is there, delivery has priority
    """
221
    return not movement.getDelivery()
Julien Muchembled's avatar
Julien Muchembled committed
222

223
  # Implementation of IDivergenceController # XXX-JPS move to IDivergenceController only mixin for 
224 225 226 227 228 229 230
  security.declareProtected( Permissions.AccessContentsInformation,
                            'isDivergent')
  def isDivergent(self, movement, ignore_list=[]):
    """
    Returns true if the Simulation Movement is divergent comparing to
    the delivery value
    """
231
    if not movement.getDelivery():
232
      return False
233
    return bool(self.getDivergenceList(movement))
234 235 236 237 238 239 240 241 242 243 244

  security.declareProtected(Permissions.View, 'getDivergenceList')
  def getDivergenceList(self, movement):
    """
    Returns a list of divergences of the movements provided
    in delivery_or_movement.

    movement -- a movement, a delivery, a simulation movement,
                or a list thereof
    """
    result_list = []
245 246
    for divergence_tester in self._getDivergenceTesterList(
      exclude_quantity=False):
247 248 249 250 251 252 253
      result = divergence_tester.explain(movement)
      if isinstance(result, (list, tuple)): # for compatibility
        result_list.extend(result)
      elif result is not None:
        result_list.append(result)
    return result_list

254
  # Placeholder for methods to override
255
  def _getMovementGenerator(self, applied_rule):
256 257 258 259 260
    """
    Return the movement generator to use in the expand process
    """
    raise NotImplementedError

261
  def _getMovementGeneratorContext(self, applied_rule):
262 263
    """
    Return the movement generator context to use for expand
264
    XXX-JPS likely useless
265 266 267
    """
    raise NotImplementedError

268
  def _getMovementGeneratorMovementList(self, applied_rule):
269 270 271 272 273
    """
    Return the movement lists to provide to the movement generator
    """
    raise NotImplementedError

274
  def _getDivergenceTesterList(self, exclude_quantity=True):
Jean-Paul Smets's avatar
Jean-Paul Smets committed
275
    """
276
    Return the applicable divergence testers which must
277 278
    be used to test movement divergence. (ie. not all
    divergence testers of the Rule)
279 280 281

     exclude_quantity -- if set to true, do not consider
                         quantity divergence testers
282
    """
283
    if exclude_quantity:
284
      return filter(lambda x:x.isDivergenceProvider() and \
285
                    'quantity' not in x.getTestedPropertyList(), self.objectValues(
286 287
        portal_type=self.getPortalDivergenceTesterTypeList()))
    else:
288
      return filter(lambda x:x.isDivergenceProvider(), self.objectValues(
289
        portal_type=self.getPortalDivergenceTesterTypeList()))
Jean-Paul Smets's avatar
Jean-Paul Smets committed
290

291 292
  def _getMatchingTesterList(self):
    """
293
    Return the applicable divergence testers which must
294 295 296
    be used to match movements and build the diff (ie.
    not all divergence testers of the Rule)
    """
297 298
    return filter(lambda x:x.isMatchingProvider(), self.objectValues(
      portal_type=self.getPortalDivergenceTesterTypeList()))
299

300 301 302 303 304 305 306
  def _getUpdatingTesterList(self, exclude_quantity=True):
    """
    Return the applicable divergence testers which must be used to
    update movements. (ie. not all divergence testers of the Rule)

    exclude_quantity -- if set to true, do not consider
                        quantity divergence testers
307
    """
308 309 310 311 312 313 314 315
    if exclude_quantity:
      return filter(lambda x:x.isUpdatingProvider() and \
                    'quantity' not in x.getTestedPropertyList(), self.objectValues(
        portal_type=self.getPortalDivergenceTesterTypeList()))
    else:
      return filter(lambda x:x.isUpdatingProvider(), self.objectValues(
        portal_type=self.getPortalDivergenceTesterTypeList()))

316 317
  def _getQuantityTesterList(self):
    """
Jean-Paul Smets's avatar
Jean-Paul Smets committed
318
    Return the applicable quantity divergence testers.
319
    """
320 321
    tester_list = self.objectValues(
      portal_type=self.getPortalDivergenceTesterTypeList())
322
    return [x for x in tester_list if 'quantity' in x.getTestedPropertyList()]
323

Jean-Paul Smets's avatar
Jean-Paul Smets committed
324
  def _newProfitAndLossMovement(self, prevision_movement):
325
    """
Jean-Paul Smets's avatar
Jean-Paul Smets committed
326 327 328
    Returns a new temp simulation movement which can
    be used to represent a profit or loss in relation
    with prevision_movement
329

Jean-Paul Smets's avatar
Jean-Paul Smets committed
330
    prevision_movement -- a simulation movement
331
    """
332
    raise NotImplementedError
333

334
  def _isProfitAndLossMovement(movement): # applied_rule XXX-JPS add this ?
335 336 337 338 339
    """
    Returns True if movement is a profit and loss movement.
    """
    raise NotImplementedError

Jean-Paul Smets's avatar
Jean-Paul Smets committed
340 341 342 343 344 345
  def _extendMovementCollectionDiff(self, movement_collection_diff,
                                    prevision_movement, decision_movement_list):
    """
    Compares a prevision_movement to decision_movement_list which
    are part of the matching group and updates movement_collection_diff
    accordingly
346 347 348 349 350 351 352

    NOTE: this method API implicitely considers that each group of matching 
    movements has 1 prevision_movement (aggregated) for N decision_movement
    It implies that prevision_movement are "more" aggregated than 
    decision_movement.

    TODO:
Jérome Perrin's avatar
Jérome Perrin committed
353
       - is this assumption appropriate ?
Jean-Paul Smets's avatar
Jean-Paul Smets committed
354 355
    """
    # Sample implementation - but it actually looks very generic
356

Jean-Paul Smets's avatar
Jean-Paul Smets committed
357 358 359 360 361
    # Case 1: movements which are not needed
    if prevision_movement is None:
      # decision_movement_list contains simulation movements which must
      # be deleted
      for decision_movement in decision_movement_list:
362 363
        # If not frozen and all children are deletable
        if decision_movement.isDeletable():
Jean-Paul Smets's avatar
Jean-Paul Smets committed
364 365 366 367
          # Delete deletable
          movement_collection_diff.addDeletableMovement(decision_movement)
        else:
          # Compensate non deletable
368 369
          new_movement = decision_movement.asContext(
                            quantity=-decision_movement.getQuantity())
370
          new_movement.setDelivery(None)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
371 372
          movement_collection_diff.addNewMovement(new_movement)
      return
373

374 375 376 377 378
    # Case 2: movements which should be added
    elif len(decision_movement_list) == 0:
      # if decision_movement_list is empty, we can just create a new one.
      movement_collection_diff.addNewMovement(prevision_movement)
      return
379 380 381

    # Case 3: movements which are needed but may need update or
    # compensation_movement_list.
Jean-Paul Smets's avatar
Jean-Paul Smets committed
382 383 384
    #  let us imagine the case of a forward rule
    #  ie. what comes in must either go out or has been lost
    divergence_tester_list = self._getDivergenceTesterList()
385 386 387
    profit_tester_list = divergence_tester_list
    updating_tester_list = self._getUpdatingTesterList()
    profit_updating_tester_list = updating_tester_list
Jean-Paul Smets's avatar
Jean-Paul Smets committed
388 389 390 391 392 393 394
    quantity_tester_list = self._getQuantityTesterList()
    compensated_quantity = 0.0
    updatable_movement = None
    not_completed_movement = None
    updatable_compensation_movement = None
    prevision_quantity = prevision_movement.getQuantity()
    decision_quantity = 0.0
395
    real_quantity = 0.0
Jean-Paul Smets's avatar
Jean-Paul Smets committed
396 397 398
    # First, we update all properties (exc. quantity) which could be divergent
    # and if we can not, we compensate them
    for decision_movement in decision_movement_list:
399
      real_movement_quantity = decision_movement.getQuantity()
400 401 402
      if decision_movement.isPropertyRecorded('quantity'):
        decision_movement_quantity = decision_movement.getRecordedProperty('quantity')
      else:
403
        decision_movement_quantity = real_movement_quantity
404
      decision_quantity += decision_movement_quantity
405
      real_quantity += real_movement_quantity
Jean-Paul Smets's avatar
Jean-Paul Smets committed
406 407 408 409 410
      if self._isProfitAndLossMovement(decision_movement):
        if decision_movement.isFrozen():
          # Record not completed movements
          if not_completed_movement is None and not decision_movement.isCompleted():
            not_completed_movement = decision_movement
411
          # Frozen must be compensated
Jean-Paul Smets's avatar
Jean-Paul Smets committed
412
          if not _compare(profit_tester_list, prevision_movement, decision_movement):
413 414
            new_movement = decision_movement.asContext(
                                quantity=-decision_movement_quantity)
415
            new_movement.setDelivery(None)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
416
            movement_collection_diff.addNewMovement(new_movement)
417
            compensated_quantity += decision_movement_quantity
Jean-Paul Smets's avatar
Jean-Paul Smets committed
418 419 420 421
        else:
          updatable_compensation_movement = decision_movement
          # Not Frozen can be updated
          kw = {}
422
          for tester in profit_updating_tester_list:
423
            if not tester.compare(prevision_movement, decision_movement):
424
              kw.update(tester.getUpdatablePropertyDict(prevision_movement, decision_movement))
Jean-Paul Smets's avatar
Jean-Paul Smets committed
425 426 427 428
          if kw:
            movement_collection_diff.addUpdatableMovement(decision_movement, kw)
      else:
        if decision_movement.isFrozen():
429
          # Frozen must be compensated
Jean-Paul Smets's avatar
Jean-Paul Smets committed
430
          if not _compare(divergence_tester_list, prevision_movement, decision_movement):
431 432
            new_movement = decision_movement.asContext(
                                  quantity=-decision_movement_quantity)
433
            new_movement.setDelivery(None)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
434
            movement_collection_diff.addNewMovement(new_movement)
435
            compensated_quantity += decision_movement_quantity
Jean-Paul Smets's avatar
Jean-Paul Smets committed
436 437 438 439
        else:
          updatable_movement = decision_movement
          # Not Frozen can be updated
          kw = {}
440
          for tester in updating_tester_list:
Jérome Perrin's avatar
Jérome Perrin committed
441
            if not tester.compare(prevision_movement, decision_movement):
442
              kw.update(tester.getUpdatablePropertyDict(prevision_movement, decision_movement))
Jérome Perrin's avatar
Jérome Perrin committed
443
              # XXX-JPS - there is a risk here that quantity is wrongly updated
Jean-Paul Smets's avatar
Jean-Paul Smets committed
444 445 446 447
          if kw:
            movement_collection_diff.addUpdatableMovement(decision_movement, kw)
    # Second, we calculate if the total quantity is the same on both sides
    # after compensation
448 449
    quantity_movement = prevision_movement.asContext(
                            quantity=decision_quantity-compensated_quantity)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
450
    if not _compare(quantity_tester_list, prevision_movement, quantity_movement):
451 452 453
      missing_quantity = ( prevision_quantity
                           - real_quantity
                           + compensated_quantity )
Jean-Paul Smets's avatar
Jean-Paul Smets committed
454 455
      if updatable_movement is not None:
        # If an updatable movement still exists, we update it
456 457
        updatable_movement.setQuantity(
            updatable_movement.getQuantity() + missing_quantity)
458
        updatable_movement.clearRecordedProperty('quantity')
Jean-Paul Smets's avatar
Jean-Paul Smets committed
459
      elif not_completed_movement is not None:
460 461
        # It is still possible to add a new movement some movements are not
        # completed
Jean-Paul Smets's avatar
Jean-Paul Smets committed
462
        new_movement = prevision_movement.asContext(quantity=missing_quantity)
463
        new_movement.setDelivery(None)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
464 465 466 467
        movement_collection_diff.addNewMovement(new_movement)
      elif updatable_compensation_movement is not None:
        # If not, it means that all movements are completed
        # but we can still update a profit and loss movement_collection_diff
468 469
        updatable_compensation_movement.setQuantity(
            updatable_compensation_movement.getQuantity() + missing_quantity)
470
        updatable_compensation_movement.clearRecordedProperty('quantity')
471
      else:
Jean-Paul Smets's avatar
Jean-Paul Smets committed
472 473 474
        # We must create a profit and loss movement
        new_movement = self._newProfitAndLossMovement(prevision_movement)
        movement_collection_diff.addNewMovement(new_movement)
Jérome Perrin's avatar
Jérome Perrin committed
475