rule.py 13.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 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

Jean-Paul Smets's avatar
Jean-Paul Smets committed
36 37 38 39 40 41
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

42 43 44
class RuleMixin:
  """
  Provides generic methods and helper methods to implement
45
  IRule and IMovementCollectionUpdater.
46 47 48 49 50 51 52
  """
  # Declarative security
  security = ClassSecurityInfo()
  security.declareObjectProtected(Permissions.AccessContentsInformation)

  # Declarative interfaces
  zope.interface.implements(interfaces.IRule,
53
                            interfaces.IDivergenceController,
54 55
                            interfaces.IMovementCollectionUpdater,)

56 57 58
  # Portal Type of created children
  movement_type = 'Simulation Movement'

59
  # Implementation of IRule
60
  def constructNewAppliedRule(self, context, id=None,
61 62 63 64
                              activate_kw=None, **kw):
    """
    Create a new applied rule in the context.

65
    An applied rule is an instantiation of a Rule. The applied rule is
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
    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
    """
    portal_types = getToolByName(self, 'portal_types')
    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)

87 88 89
  def test(self, *args, **kw):
    """
    If no test method is defined, return False, to prevent infinite loop
90 91

    XXX-JPS - I do not understand why 
92 93
    """
    if not self.getTestMethodId():
94 95 96 97 98
      return False # XXX-JPS - if people are stupid are 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
99 100
    return Predicate.test(self, *args, **kw)

101 102 103 104 105 106
  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
107
    properties. However, if some properties were overwritten
108 109 110 111 112
    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
113
    #            although rounding in simulation is not recommended at all
Kazuhiko Shiozaki's avatar
Kazuhiko Shiozaki committed
114
    self.updateMovementCollection(applied_rule, movement_generator=self._getMovementGenerator())
115 116
    # And forward expand
    for movement in applied_rule.getMovementList():
117
      movement.expand(**kw)
118

119
  # Implementation of IDivergenceController # XXX-JPS move to IDivergenceController only mixin for 
120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144
  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 = []
145 146
    for divergence_tester in self._getDivergenceTesterList(
      exclude_quantity=False):
147 148 149 150 151 152 153
      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

154
  # Placeholder for methods to override
Kazuhiko Shiozaki's avatar
Kazuhiko Shiozaki committed
155
  def _getMovementGenerator(self):
156 157 158 159 160
    """
    Return the movement generator to use in the expand process
    """
    raise NotImplementedError

161
  def _getMovementGeneratorContext(self, applied_rule):
162 163 164 165 166
    """
    Return the movement generator context to use for expand
    """
    raise NotImplementedError

Kazuhiko Shiozaki's avatar
Kazuhiko Shiozaki committed
167
  def _getMovementGeneratorMovementList(self):
168 169 170 171 172
    """
    Return the movement lists to provide to the movement generator
    """
    raise NotImplementedError

173
  def _getDivergenceTesterList(self, exclude_quantity=True):
Jean-Paul Smets's avatar
Jean-Paul Smets committed
174
    """
175
    Return the applicable divergence testers which must
176 177
    be used to test movement divergence. (ie. not all
    divergence testers of the Rule)
178 179 180 181 182

     exclude_quantity -- if set to true, do not consider
                         quantity divergence testers
     """
    if exclude_quantity:
183
      return filter(lambda x:x.isDivergenceProvider() and \
184 185 186
                    x.getTestedProperty() != 'quantity', self.objectValues(
        portal_type=self.getPortalDivergenceTesterTypeList()))
    else:
187
      return filter(lambda x:x.isDivergenceProvider(), self.objectValues(
188
        portal_type=self.getPortalDivergenceTesterTypeList()))
Jean-Paul Smets's avatar
Jean-Paul Smets committed
189

190 191
  def _getMatchingTesterList(self):
    """
192
    Return the applicable divergence testers which must
193 194 195
    be used to match movements and build the diff (ie.
    not all divergence testers of the Rule)
    """
196 197
    return filter(lambda x:x.isMatchingProvider(), self.objectValues(
      portal_type=self.getPortalDivergenceTesterTypeList()))
198 199 200

  def _getQuantityTesterList(self):
    """
Jean-Paul Smets's avatar
Jean-Paul Smets committed
201
    Return the applicable quantity divergence testers.
202
    """
203 204 205
    tester_list = self.objectValues(
      portal_type=self.getPortalDivergenceTesterTypeList())
    return [x for x in tester_list if x.getTestedProperty() == 'quantity']
206

Jean-Paul Smets's avatar
Jean-Paul Smets committed
207
  def _newProfitAndLossMovement(self, prevision_movement):
208
    """
Jean-Paul Smets's avatar
Jean-Paul Smets committed
209 210 211
    Returns a new temp simulation movement which can
    be used to represent a profit or loss in relation
    with prevision_movement
212

Jean-Paul Smets's avatar
Jean-Paul Smets committed
213
    prevision_movement -- a simulation movement
214
    """
215
    raise NotImplementedError
216

217 218 219 220 221 222
  def _isProfitAndLossMovement(movement):
    """
    Returns True if movement is a profit and loss movement.
    """
    raise NotImplementedError

Jean-Paul Smets's avatar
Jean-Paul Smets committed
223 224 225 226 227 228
  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
229 230 231 232 233 234 235 236

    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
237 238 239 240 241 242 243 244 245 246 247 248 249 250 251
    """
    # 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
252 253 254 255 256 257
    # 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
258 259 260 261 262 263 264 265 266 267 268 269 270 271
    #  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()
    profit_tester_list = self._getDivergenceTesterList()
    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:
272 273
      decision_movement_quantity = decision_movement.getQuantity()
      decision_quantity += decision_movement_quantity
Jean-Paul Smets's avatar
Jean-Paul Smets committed
274 275 276 277 278
      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
279
          # Frozen must be compensated
Jean-Paul Smets's avatar
Jean-Paul Smets committed
280
          if not _compare(profit_tester_list, prevision_movement, decision_movement):
281
            new_movement = decision_movement.asContext(quantity=-decision_movement_quantity)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
282
            movement_collection_diff.addNewMovement(new_movement)
283
            compensated_quantity += decision_movement_quantity
Jean-Paul Smets's avatar
Jean-Paul Smets committed
284 285 286 287 288
        else:
          updatable_compensation_movement = decision_movement
          # Not Frozen can be updated
          kw = {}
          for tester in profit_tester_list:
289
            if not tester.compare(prevision_movement, decision_movement):
Jean-Paul Smets's avatar
Jean-Paul Smets committed
290 291 292 293 294
              kw.update(tester.getUpdatablePropertyDict(prevision_movement, decision_movement))
          if kw:
            movement_collection_diff.addUpdatableMovement(decision_movement, kw)
      else:
        if decision_movement.isFrozen():
295
          # Frozen must be compensated
Jean-Paul Smets's avatar
Jean-Paul Smets committed
296
          if not _compare(divergence_tester_list, prevision_movement, decision_movement):
297
            new_movement = decision_movement.asContext(quantity=-decision_movement_quantity)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
298
            movement_collection_diff.addNewMovement(new_movement)
299
            compensated_quantity += decision_movement_quantity
Jean-Paul Smets's avatar
Jean-Paul Smets committed
300 301 302 303 304
        else:
          updatable_movement = decision_movement
          # Not Frozen can be updated
          kw = {}
          for tester in divergence_tester_list:
305
            if not tester.compare(prevision_movement, decision_movement): 
Jean-Paul Smets's avatar
Jean-Paul Smets committed
306
              kw.update(tester.getUpdatablePropertyDict(prevision_movement, decision_movement))
307
              # XXX-JPS - there is a risk here that quanity is wrongly updated
Jean-Paul Smets's avatar
Jean-Paul Smets committed
308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324
          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
325
        updatable_compensation_movement.setQuantity(updatable_compensation_movement.getQuantity()
Jean-Paul Smets's avatar
Jean-Paul Smets committed
326
                                                  + missing_quantity)
327
      else:
Jean-Paul Smets's avatar
Jean-Paul Smets committed
328 329 330
        # We must create a profit and loss movement
        new_movement = self._newProfitAndLossMovement(prevision_movement)
        movement_collection_diff.addNewMovement(new_movement)