rule.py 18.8 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.ERP5.MovementCollectionDiff import MovementCollectionDiff
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 90 91 92 93 94
  def test(self, *args, **kw):
    """
    If no test method is defined, return False, to prevent infinite loop
    """
    if not self.getTestMethodId():
      return False
    return Predicate.test(self, *args, **kw)

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

113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138
  # Implementation of IDivergenceController
  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 = []
139 140
    for divergence_tester in self._getDivergenceTesterList(
      exclude_quantity=False):
141 142 143 144 145 146 147
      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

148 149 150 151 152 153 154 155 156 157 158
  # Implementation of IMovementCollectionUpdater
  def getMovementCollectionDiff(self, context, rounding=False, movement_generator=None):
    """
    Return a IMovementCollectionDiff by comparing movements
    the list of movements of context and the list of movements
    generated by movement_generator on context.

    context -- an IMovementCollection usually, possibly
               an IMovementList or an IMovement

    movement_generator -- an optional IMovementGenerator
159
                          (if not specified, a context implicit
160 161 162 163
                          IMovementGenerator will be used)
    """
    # We suppose here that we have an IMovementCollection in hand
    decision_movement_list = context.getMovementList()
164 165 166
    prevision_movement_list = movement_generator.getAggregatedMovementList(
      self._getMovementGeneratorContext(context),
      movement_list=self._getMovementGeneratorMovementList(), rounding=rounding)
167

168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186
    # Prepare a mapping between prevision and decision
    #   The prevision_to_decision_map is a list of tuples
    #   of the form (prevision_movement_dict, list of decision_movement)
    prevision_to_decision_map = []

    # XXX First we try to match by 'order' value if possible.
    matched_prevision_list = []
    matched_decision_list = []
    prevision_order_dict = dict(
      (x.getOrder(), x) for x in prevision_movement_list)
    for decision_movement in decision_movement_list:
      prevision_movement = prevision_order_dict.get(
        decision_movement.getOrder(), None)
      if prevision_movement is not None:
        prevision_to_decision_map.append(
          (prevision_movement, [decision_movement]))
        matched_prevision_list.append(prevision_movement)
        matched_decision_list.append(decision_movement)

187 188 189 190 191 192 193 194
    # Get divergence testers
    tester_list = self._getMatchingTesterList()
    if len(tester_list) == 0:
      raise ValueError("It is not possible to match movements without divergence testers")

    # Create small groups of movements per hash keys
    decision_movement_dict = {}
    for movement in decision_movement_list:
195 196
      if movement in matched_decision_list:
        continue
197 198 199 200 201 202 203
      tester_key = []
      for tester in tester_list:
        if tester.test(movement):
          tester_key.append(tester.generateHashKey(movement))
        else:
          tester_key.append(None)
      tester_key = tuple(tester_key)
Kazuhiko Shiozaki's avatar
Kazuhiko Shiozaki committed
204
      decision_movement_dict.setdefault(tester_key, []).append(movement)
205 206
    prevision_movement_dict = {}
    for movement in prevision_movement_list:
207 208
      if movement in matched_prevision_list:
        continue
209 210 211 212 213 214 215
      tester_key = []
      for tester in tester_list:
        if tester.test(movement):
          tester_key.append(tester.generateHashKey(movement))
        else:
          tester_key.append(None)
      tester_key = tuple(tester_key)
Kazuhiko Shiozaki's avatar
Kazuhiko Shiozaki committed
216
      prevision_movement_dict.setdefault(tester_key, []).append(movement)
217

Jean-Paul Smets's avatar
Jean-Paul Smets committed
218 219 220 221 222 223 224 225 226 227 228 229
    # First find out all existing (decision) movements which belong to no group
    no_group_list = []
    for tester_key in decision_movement_dict.keys():
      if prevision_movement_dict.has_key(tester_key):
        for decision_movement in decision_movement_dict[tester_key]:
          no_match = True
          for prevision_movement in prevision_movement_dict[tester_key]:
            # Check if this movement belongs to an existing group
            if _compare(tester_list, prevision_movement, decision_movement):
              no_match = False
              break
          if no_match:
230
            # There is no matching.
Jean-Paul Smets's avatar
Jean-Paul Smets committed
231 232 233 234 235 236
            # So, let us add the decision movements to no_group_list
            no_group_list.append(decision_movement)
      else:
        # The tester key does not even exist.
        # So, let us add all decision movements to no_group_list
        no_group_list.extend(decision_movement_dict[tester_key])
237 238
    if len(no_group_list) > 0:
      prevision_to_decision_map.append((None, no_group_list))
Jean-Paul Smets's avatar
Jean-Paul Smets committed
239 240 241 242 243 244 245

    # Second, let us create small groups of movements
    for tester_key in prevision_movement_dict.keys():
      for prevision_movement in prevision_movement_dict[tester_key]:
        map_list = []
        for decision_movement in decision_movement_dict.get(tester_key, ()):
          if _compare(tester_list, prevision_movement, decision_movement):
246
            # XXX is it OK to have more than 2 decision_movements?
Jean-Paul Smets's avatar
Jean-Paul Smets committed
247 248 249
            map_list.append(decision_movement)
        prevision_to_decision_map.append((prevision_movement, map_list))

250
    # Third, time to create the diff
Jean-Paul Smets's avatar
Jean-Paul Smets committed
251 252 253 254
    movement_collection_diff = MovementCollectionDiff()
    for (prevision_movement, decision_movement_list) in prevision_to_decision_map:
      self._extendMovementCollectionDiff(movement_collection_diff, prevision_movement,
                                         decision_movement_list)
255 256 257

    # Return result
    return movement_collection_diff
258

259 260
  def updateMovementCollection(self, context, rounding=False, movement_generator=None):
    """
261
    Invoke getMovementCollectionDiff and update context with
262 263 264 265 266 267
    the resulting IMovementCollectionDiff.

    context -- an IMovementCollection usually, possibly
               an IMovementList or an IMovement

    movement_generator -- an optional IMovementGenerator
268
                          (if not specified, a context implicit
269 270
                          IMovementGenerator will be used)
    """
271
    movement_diff = self.getMovementCollectionDiff(context,
272 273 274 275 276 277 278 279 280
                 rounding=rounding, movement_generator=movement_generator)

    # Apply Diff
    for movement in movement_diff.getDeletableMovementList():
      movement.getParentValue().deleteContent(movement.getId())
    for movement in movement_diff.getUpdatableMovementList():
      kw = movement_diff.getMovementPropertyDict(movement)
      movement.edit(**kw)
    for movement in movement_diff.getNewMovementList():
281
      # This case is easy, because it is an applied rule
282
      kw = movement_diff.getMovementPropertyDict(movement)
283
      movement = context.newContent(portal_type=self.movement_type, **kw)
284

285
  # Placeholder for methods to override
Kazuhiko Shiozaki's avatar
Kazuhiko Shiozaki committed
286
  def _getMovementGenerator(self):
287 288 289 290 291
    """
    Return the movement generator to use in the expand process
    """
    raise NotImplementedError

292
  def _getMovementGeneratorContext(self, applied_rule):
293 294 295 296 297
    """
    Return the movement generator context to use for expand
    """
    raise NotImplementedError

Kazuhiko Shiozaki's avatar
Kazuhiko Shiozaki committed
298
  def _getMovementGeneratorMovementList(self):
299 300 301 302 303
    """
    Return the movement lists to provide to the movement generator
    """
    raise NotImplementedError

304
  def _getDivergenceTesterList(self, exclude_quantity=True):
Jean-Paul Smets's avatar
Jean-Paul Smets committed
305
    """
306
    Return the applicable divergence testers which must
307 308
    be used to test movement divergence. (ie. not all
    divergence testers of the Rule)
309 310 311 312 313 314 315 316 317 318 319

     exclude_quantity -- if set to true, do not consider
                         quantity divergence testers
     """
    if exclude_quantity:
      return filter(lambda x:x.isTestingProvider() and \
                    x.getTestedProperty() != 'quantity', self.objectValues(
        portal_type=self.getPortalDivergenceTesterTypeList()))
    else:
      return filter(lambda x:x.isTestingProvider(), self.objectValues(
        portal_type=self.getPortalDivergenceTesterTypeList()))
Jean-Paul Smets's avatar
Jean-Paul Smets committed
320

321 322
  def _getMatchingTesterList(self):
    """
323
    Return the applicable divergence testers which must
324 325 326
    be used to match movements and build the diff (ie.
    not all divergence testers of the Rule)
    """
327 328
    return filter(lambda x:x.isMatchingProvider(), self.objectValues(
      portal_type=self.getPortalDivergenceTesterTypeList()))
329 330 331

  def _getQuantityTesterList(self):
    """
Jean-Paul Smets's avatar
Jean-Paul Smets committed
332
    Return the applicable quantity divergence testers.
333
    """
334 335 336
    tester_list = self.objectValues(
      portal_type=self.getPortalDivergenceTesterTypeList())
    return [x for x in tester_list if x.getTestedProperty() == 'quantity']
337

Jean-Paul Smets's avatar
Jean-Paul Smets committed
338
  def _newProfitAndLossMovement(self, prevision_movement):
339
    """
Jean-Paul Smets's avatar
Jean-Paul Smets committed
340 341 342
    Returns a new temp simulation movement which can
    be used to represent a profit or loss in relation
    with prevision_movement
343

Jean-Paul Smets's avatar
Jean-Paul Smets committed
344
    prevision_movement -- a simulation movement
345
    """
346
    raise NotImplementedError
347

348 349 350 351 352 353
  def _isProfitAndLossMovement(movement):
    """
    Returns True if movement is a profit and loss movement.
    """
    raise NotImplementedError

Jean-Paul Smets's avatar
Jean-Paul Smets committed
354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374
  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
    """
    # 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
375 376 377 378 379 380
    # 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
381 382 383 384 385 386 387 388 389 390 391 392 393 394
    #  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:
395 396
      decision_movement_quantity = decision_movement.getQuantity()
      decision_quantity += decision_movement_quantity
Jean-Paul Smets's avatar
Jean-Paul Smets committed
397 398 399 400 401
      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
402
          # Frozen must be compensated
Jean-Paul Smets's avatar
Jean-Paul Smets committed
403
          if not _compare(profit_tester_list, prevision_movement, decision_movement):
404
            new_movement = decision_movement.asContext(quantity=-decision_movement_quantity)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
405
            movement_collection_diff.addNewMovement(new_movement)
406
            compensated_quantity += decision_movement_quantity
Jean-Paul Smets's avatar
Jean-Paul Smets committed
407 408 409 410 411 412 413 414 415 416 417
        else:
          updatable_compensation_movement = decision_movement
          # Not Frozen can be updated
          kw = {}
          for tester in profit_tester_list:
            if tester.compare(prevision_movement, decision_movement):
              kw.update(tester.getUpdatablePropertyDict(prevision_movement, decision_movement))
          if kw:
            movement_collection_diff.addUpdatableMovement(decision_movement, kw)
      else:
        if decision_movement.isFrozen():
418
          # Frozen must be compensated
Jean-Paul Smets's avatar
Jean-Paul Smets committed
419
          if not _compare(divergence_tester_list, prevision_movement, decision_movement):
420
            new_movement = decision_movement.asContext(quantity=-decision_movement_quantity)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
421
            movement_collection_diff.addNewMovement(new_movement)
422
            compensated_quantity += decision_movement_quantity
Jean-Paul Smets's avatar
Jean-Paul Smets committed
423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446
        else:
          updatable_movement = decision_movement
          # Not Frozen can be updated
          kw = {}
          for tester in divergence_tester_list:
            if tester.compare(prevision_movement, decision_movement):
              kw.update(tester.getUpdatablePropertyDict(prevision_movement, decision_movement))
          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
447
        updatable_compensation_movement.setQuantity(updatable_compensation_movement.getQuantity()
Jean-Paul Smets's avatar
Jean-Paul Smets committed
448
                                                  + missing_quantity)
449
      else:
Jean-Paul Smets's avatar
Jean-Paul Smets committed
450 451 452
        # We must create a profit and loss movement
        new_movement = self._newProfitAndLossMovement(prevision_movement)
        movement_collection_diff.addNewMovement(new_movement)