rule.py 20 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
Kazuhiko Shiozaki's avatar
Kazuhiko Shiozaki committed
34
from Products.ERP5.Document.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 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
      self._explanation = explanation
    # XXX-JPS handle delay_mode

  # Implementation of IMovementGenerator
  def getGeneratedMovementList(self, movement_list=None, rounding=False):
    """
    Returns an IMovementList generated by a model applied to the context

    context - an IMovementCollection, an IMovementList or an IMovement

    movement_list - optional IMovementList which can be passed explicitely
                    whenever context is an IMovementCollection and whenever
                    we want to filter context.getMovementList

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

    NOTE:
      - implement rounding appropriately (True or False seems
        simplistic)
    """
    # Default implementation bellow can be overriden by subclasses
    # however it should be generic enough not to be overriden
    # by most classes
    # Results will be appended to result, objects created inside folder
    from Products.ERP5Type.Document import newTempMovement
    result = []
    folder = self._applied_rule
    # Build a list of movement and business path
105 106
    LOG('_getInputMovementList', 0, repr(self._getInputMovementList(movement_list=movement_list, 
                                                   rounding=rounding)))
107
    for input_movement in self._getInputMovementList(movement_list=movement_list, 
108
                                                     rounding=rounding):
109 110 111
      # Merge movement and business path properties (core implementation)
      # Lookup Business Process through composition (NOT UNION)
      business_process = input_movement.asComposedDocument()
112
      explanation = self._applied_rule # We use applied rule as local explanation
113
      trade_phase = self._getTradePhaseList(input_movement, business_process) # XXX-JPS not convenient to handle
114
      update_property_dict = self._getUpdatePropertyDict(input_movement)
115
      result.extend(business_process.getTradePhaseMovementList(explanation, input_movement,
116 117
                                                 trade_phase=trade_phase, delay_mode=None,
                                                 update_property_dict=update_property_dict))
118 119 120 121 122 123 124 125 126 127

    # And return list of generated movements
    return result

  def _getUpdatePropertyDict(self, input_movement):
    # Default implementation bellow can be overriden by subclasses
    return {'delivery': input_movement.getRelativeUrl(), # XXX-JPS empty is better
            }

  def _getTradePhaseList(self, input_movement, business_process): # XXX-JPS WEIRD
128 129 130
    movement_trade_phase = input_movement.getTradePhaseList()
    if movement_trade_phase:
      return movement_trade_phase
131 132
    if self._trade_phase_list:
      return self._trade_phase_list
133 134 135 136
    if self._rule is not None:
      trade_phase_list = self._rule.getTradePhaseList()
      if trade_phase_list:
        return trade_phase_list
137 138
    return business_process.getTradePhaseList()

139
  def _getInputMovementList(self, movement_list=None, rounding=None): #XXX-JPS should it be amount or movement ?
140 141 142 143 144 145
    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


146 147 148
class RuleMixin:
  """
  Provides generic methods and helper methods to implement
149
  IRule and IMovementCollectionUpdater.
150 151 152 153 154 155 156
  """
  # Declarative security
  security = ClassSecurityInfo()
  security.declareObjectProtected(Permissions.AccessContentsInformation)

  # Declarative interfaces
  zope.interface.implements(interfaces.IRule,
157
                            interfaces.IDivergenceController,
158 159
                            interfaces.IMovementCollectionUpdater,)

160 161 162
  # Portal Type of created children
  movement_type = 'Simulation Movement'

163
  # Implementation of IRule
164
  def constructNewAppliedRule(self, context, id=None,
165 166 167 168
                              activate_kw=None, **kw):
    """
    Create a new applied rule in the context.

169
    An applied rule is an instantiation of a Rule. The applied rule is
170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189
    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)

190 191 192
  def test(self, *args, **kw):
    """
    If no test method is defined, return False, to prevent infinite loop
193 194

    XXX-JPS - I do not understand why 
195
    """
196 197
    #if not self.getTestMethodId():
    #  return False # XXX-JPS - if people are stupid enough not to configfure predicates, 
198 199 200 201
                   # 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
202 203
    return Predicate.test(self, *args, **kw)

204 205 206 207 208 209
  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
210
    properties. However, if some properties were overwritten
211 212 213 214 215
    by a decision (ie. a resource if changed), then we
    should not try to compensate such a decision.
    """
    # Update movements
    #  NOTE-JPS: it is OK to make rounding a standard parameter of rules
216
    #            although rounding in simulation is not recommended at all
217
    self.updateMovementCollection(applied_rule, movement_generator=self._getMovementGenerator(applied_rule))
218 219
    # And forward expand
    for movement in applied_rule.getMovementList():
220
      movement.expand(**kw)
221

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

  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 = []
248 249
    for divergence_tester in self._getDivergenceTesterList(
      exclude_quantity=False):
250 251 252 253 254 255 256
      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

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

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

271
  def _getMovementGeneratorMovementList(self, applied_rule):
272 273 274 275 276
    """
    Return the movement lists to provide to the movement generator
    """
    raise NotImplementedError

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

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

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

303 304 305 306 307 308 309
  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
310
    """
311 312 313 314 315 316 317 318
    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()))

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

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

Jean-Paul Smets's avatar
Jean-Paul Smets committed
333
    prevision_movement -- a simulation movement
334
    """
335
    raise NotImplementedError
336

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

Jean-Paul Smets's avatar
Jean-Paul Smets committed
343 344 345 346 347 348
  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
349 350 351 352 353 354 355 356

    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:
       - is this asumption appropriate ?
Jean-Paul Smets's avatar
Jean-Paul Smets committed
357 358 359 360 361 362 363 364 365 366 367 368 369 370 371
    """
    # Sample implementation - but it actually looks very generic
    # 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:
        if decision_movement.isDeletable(): # If not frozen and all children are deletable
          # Delete deletable
          movement_collection_diff.addDeletableMovement(decision_movement)
        else:
          # Compensate non deletable
          new_movement = decision_movement.asContext(quantity=-decision_movement.getQuantity())
          movement_collection_diff.addNewMovement(new_movement)
      return
372 373 374 375 376 377
    # 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
    # Case 3: movements which are needed but may need update or compensation_movement_list
Jean-Paul Smets's avatar
Jean-Paul Smets committed
378 379 380
    #  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()
381 382 383
    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
384 385 386 387 388 389 390 391 392 393
    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
    # 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:
394 395 396 397
      if decision_movement.isPropertyRecorded('quantity'):
        decision_movement_quantity = decision_movement.getRecordedProperty('quantity')
      else:
        decision_movement_quantity = decision_movement.getQuantity()
398
      decision_quantity += decision_movement_quantity
Jean-Paul Smets's avatar
Jean-Paul Smets committed
399 400 401 402 403
      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
404
          # Frozen must be compensated
Jean-Paul Smets's avatar
Jean-Paul Smets committed
405
          if not _compare(profit_tester_list, prevision_movement, decision_movement):
406
            new_movement = decision_movement.asContext(quantity=-decision_movement_quantity)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
407
            movement_collection_diff.addNewMovement(new_movement)
408
            compensated_quantity += decision_movement_quantity
Jean-Paul Smets's avatar
Jean-Paul Smets committed
409 410 411 412
        else:
          updatable_compensation_movement = decision_movement
          # Not Frozen can be updated
          kw = {}
413
          for tester in profit_updating_tester_list:
414
            if not tester.compare(prevision_movement, decision_movement):
415 416 417 418 419 420 421 422
              # Only update those updatable properties which are not recorded
              kw_candidate = tester.getUpdatablePropertyDict(prevision_movement,
                                                             decision_movement)
              accept_candidate = True
              for property_key in kw_candidate.keys():
                if decision_movement.isPropertyRecorded(property_key):
                  del kw_candidate[property_key]
              kw.update(kw_candidate)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
423 424 425 426
          if kw:
            movement_collection_diff.addUpdatableMovement(decision_movement, kw)
      else:
        if decision_movement.isFrozen():
427
          # Frozen must be compensated
Jean-Paul Smets's avatar
Jean-Paul Smets committed
428
          if not _compare(divergence_tester_list, prevision_movement, decision_movement):
429
            new_movement = decision_movement.asContext(quantity=-decision_movement_quantity)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
430
            movement_collection_diff.addNewMovement(new_movement)
431
            compensated_quantity += decision_movement_quantity
Jean-Paul Smets's avatar
Jean-Paul Smets committed
432 433 434 435
        else:
          updatable_movement = decision_movement
          # Not Frozen can be updated
          kw = {}
436
          for tester in updating_tester_list:
437
            if not tester.compare(prevision_movement, decision_movement): 
438 439 440 441 442 443 444 445
              # Only update those updatable properties which are not recorded
              kw_candidate = tester.getUpdatablePropertyDict(prevision_movement,
                                                             decision_movement)
              accept_candidate = True
              for property_key in kw_candidate.keys():
                if decision_movement.isPropertyRecorded(property_key):
                  del kw_candidate[property_key]
              kw.update(kw_candidate)
446
              # XXX-JPS - there is a risk here that quanity is wrongly updated
Jean-Paul Smets's avatar
Jean-Paul Smets committed
447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463
          if kw:
            movement_collection_diff.addUpdatableMovement(decision_movement, kw)
    # Second, we calculate if the total quantity is the same on both sides
    # after compensation
    quantity_movement = prevision_movement.asContext(quantity=decision_quantity-compensated_quantity)
    if not _compare(quantity_tester_list, prevision_movement, quantity_movement):
      missing_quantity = prevision_quantity - decision_quantity + compensated_quantity
      if updatable_movement is not None:
        # If an updatable movement still exists, we update it
        updatable_movement.setQuantity(updatable_movement.getQuantity() + missing_quantity)
      elif not_completed_movement is not None:
        # It is still possible to add a new movement some movements are not completed
        new_movement = prevision_movement.asContext(quantity=missing_quantity)
        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
464
        updatable_compensation_movement.setQuantity(updatable_compensation_movement.getQuantity()
Jean-Paul Smets's avatar
Jean-Paul Smets committed
465
                                                  + missing_quantity)
466
      else:
Jean-Paul Smets's avatar
Jean-Paul Smets committed
467 468 469
        # We must create a profit and loss movement
        new_movement = self._newProfitAndLossMovement(prevision_movement)
        movement_collection_diff.addNewMovement(new_movement)