Movement.py 36.8 KB
Newer Older
1
# -*- coding: utf-8 -*-
Jean-Paul Smets's avatar
Jean-Paul Smets committed
2 3 4
##############################################################################
#
# Copyright (c) 2002 Nexedi SARL and Contributors. All Rights Reserved.
5
#                    Jean-Paul Smets-Solanes <jp@nexedi.com>
Jean-Paul Smets's avatar
Jean-Paul Smets committed
6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
#
# WARNING: This program as such is intended to be used by professional
# programmers who take the whole responsability 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
# garantees 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., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
#
##############################################################################

30
import zope.interface
31
from warnings import warn
Jean-Paul Smets's avatar
Jean-Paul Smets committed
32 33
from AccessControl import ClassSecurityInfo

34
from Products.ERP5Type import Permissions, PropertySheet, interfaces
35 36
from Products.ERP5Type.Base import Base

37
#from Products.ERP5.Core import MetaNode, MetaResource
Jean-Paul Smets's avatar
Jean-Paul Smets committed
38 39

from Products.ERP5Type.XMLObject import XMLObject
40
from Products.ERP5Type.TransactionalVariable import getTransactionalVariable
41
from Products.ERP5Type.UnrestrictedMethod import unrestricted_apply
42
from Products.ERP5.mixin.amount_generator import AmountGeneratorMixin
43
from Products.ERP5.mixin.composition import CompositionMixin
44
from Products.ERP5.Document.Amount import Amount
Jean-Paul Smets's avatar
Jean-Paul Smets committed
45

46
from zLOG import LOG, WARNING
47

48
class Movement(XMLObject, Amount, CompositionMixin, AmountGeneratorMixin):
Jean-Paul Smets's avatar
Jean-Paul Smets committed
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
  """
    The Movement class allows to implement ERP5 universal accounting model.

    Movement instances are used in different situations:

    - Orders: Movement instances are use as a documentary object
      to define quantities in orders

    - Deliveries: movements track the actual transfer of resources
      in the past (accounting) or in the future (planning / budgetting)

    For example, the following objects are Orders:

    - a purchase order (the document we send to a supplier
      when we need some goods)

    - a sales order (the document we ask our customer
      to sign to confirm a sale)

    - a production order (the document we send to the workshop
      to confirm we need some operation / service to be achieved)

    Orders allow to describe a target, but can not be used to account
    the reality of actual transfered quantities.

    This is not the case for Deliveries:

    - an invoice (a delivery of money between abstract accounts)

    - a packing list (ie. a delivery of goods shipped)

    - a delivery report (ie. a delivery of goods received)

    - a production report (ie. a delivery of service)

    - a T/T report (a delivery of money between reals accounts)


87
    For planning, the following approach is used:
Jean-Paul Smets's avatar
Jean-Paul Smets committed
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105

    1- Movements from an order are never modified once the order
       is confirmed. This is a rule. An Order is like a contract.
       It can only be amended, if all parties agree.

    2- Movements in a delivery may exist on their own
       (ex. an accounting transaction). Past movements
       can not be modified. Future movements may or may
       not be modified

    When an order is confirmed, the list of "order" movements
    it contains is copied into "delivery" movements. Each delivery
    movement contains a "causality" reference to the order
    it. This allows delivery to be completely different from order
    (ex. different resource, different date, different quantity)
    and allows to keep track of the causal relation between
    a delivery and an order.

106
    A delivery document (actually a delivery line) then points to one or more of
Jean-Paul Smets's avatar
Jean-Paul Smets committed
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124
    the "delivery" movements in the simulation. It is possible to know
    which items have been delivered by making sure each movement in the simulation
    is associated to a "delivery document".

    By looking at all "simulation"

    Delivery movements can be applied the following transformations:

    - split : one movement is cut into 2 or more movements

    - submovements : one movement "generates" many other movements.
      For example, a delivery of goods from the workshop to the stock,
      will result in a "pull" calculation which generates operations
      and sourcing based on a BOM document. The causality of each
      delivery is the "applied rule" which was used to generate submovements

    One should note that

125
    - movements are never joined (because it would break causality and
Jean-Paul Smets's avatar
Jean-Paul Smets committed
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177
      tracability)

    - movements acquire some context information from their causality

    Some submovements need no order to be "confirmed". Some submovements
    need an order to be "confirmed". For example

    - a submovement which allows to compute the CO2 emissions
      of a production facility needs no order confirmation
      (this kind of movement is mostly ised for reporting)

    - a submovement which takes some goods in a stock and
      brings them to a workshop needs some "stock movement"
      order to be "confirmed"

    - a submovement which requires someone to take his time
      for some operation nees a "service order" to be confirmed

    This means that the simulation process must make a distinction
    between different workflows applicable to a movement. For
    movements which require an order to be confirmed, the workflow
    involves the following steps:

    - an order is automaticaly generated, with "order movements"
      which become "causalities" for delivery movements (XXX
      this sound strange...)

    - each order movement is associated to one of the delivery

    As a result, a delivery movement which requires an order may
    have 2 causalities

    - one causality (ie. application of a rule)

    - another causality (ie. confirmation in an order)

    Each causality may define its own context. One context
    may be related for example to a customer request, another context
    may be related to an "internal customer" request (eg. the production manager).
    Context may eventually conflict each other.

    In a customer oriented company, movements should probably
    always be stored within the customer order and acquire
    from the order all context information.

    In a mass production facility, context may be defined in more
    than one location. This is an incentive for putting
    all movements in a simulation "box".

    The second approach is chosen for documentary consistency approach :
      in ERP5, documents rules, can be synchronized. Simulation can not be
      synchronized
178 179

    TODO:
180
    - consider creating a class GeneratedMovement
181
      and move some superfluous code to it
Jean-Paul Smets's avatar
Jean-Paul Smets committed
182 183 184
  """
  meta_type = 'ERP5 Movement'
  portal_type = 'Movement'
185
  add_permission = Permissions.AddPortalContent
Jean-Paul Smets's avatar
Jean-Paul Smets committed
186 187 188

  # Declarative security
  security = ClassSecurityInfo()
189
  security.declareObjectProtected(Permissions.AccessContentsInformation)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
190 191

  # Declarative interfaces
192 193 194
  zope.interface.implements(interfaces.IAmountGenerator,
                            interfaces.IVariated,
                            interfaces.IMovement)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
195 196 197 198

  # Declarative properties
  property_sheets = ( PropertySheet.Base
                    , PropertySheet.SimpleItem
199
                    , PropertySheet.CategoryCore
Jean-Paul Smets's avatar
Jean-Paul Smets committed
200
                    , PropertySheet.Amount
201
                    , PropertySheet.Reference
Jean-Paul Smets's avatar
Jean-Paul Smets committed
202 203 204 205
                    , PropertySheet.Task
                    , PropertySheet.Arrow
                    , PropertySheet.Movement
                    , PropertySheet.Price
206 207
                    , PropertySheet.Simulation  # XXX-JPS property should be moved to GeneratedMovement class

Jean-Paul Smets's avatar
Jean-Paul Smets committed
208
                    )
209 210
  def isPropertyRecorded(self, k): # XXX-JPS method should be moved to GeneratedMovement class
    return False
Jean-Paul Smets's avatar
Jean-Paul Smets committed
211

212 213 214 215
  security.declareProtected(Permissions.AccessContentsInformation, 'isMovement')
  def isMovement(self):
    return 1

216 217 218 219 220 221 222
  security.declareProtected(Permissions.AccessContentsInformation, 'isAccountable')
  def isAccountable(self):
    return True

  security.declareProtected(Permissions.AccessContentsInformation,
                      'isMovingItem')
  def isMovingItem(self, item):
223 224 225
    type_based_script = self._getTypeBasedMethod('isMovingItem')
    if type_based_script:
      return type_based_script(item)
226 227 228 229 230 231 232 233 234
    return False

  security.declareProtected(Permissions.AccessContentsInformation, 'getMovedItemUidList')
  def getMovedItemUidList(self):
    """This method returns an uid list of items
    """
    return [item.getUid() for item in self.getAggregateValueList() \
      if self.isMovingItem(item)]

Jean-Paul Smets's avatar
Jean-Paul Smets committed
235 236
  # Pricing methods
  def _getPrice(self, context):
237 238
    context = self.asContext(context=context,
                             quantity=self.getConvertedQuantity())
239 240
    operand_dict = self.getPriceCalculationOperandDict(context=context)
    if operand_dict is not None:
241 242 243 244 245 246 247 248
      price = operand_dict['price']
      resource = self.getResourceValue()
      quantity_unit = self.getQuantityUnit()
      if price is not None and quantity_unit and resource is not None:
        return resource.convertQuantity(price, quantity_unit,
                                        resource.getDefaultQuantityUnit(),
                                        self.getVariationCategoryList())
      return price
Jean-Paul Smets's avatar
Jean-Paul Smets committed
249

250
  def _getTotalPrice(self, default=None, context=None, fast=0, **kw):
Jean-Paul Smets's avatar
Jean-Paul Smets committed
251 252
    price = self.getPrice(context=context)
    quantity = self.getQuantity()
253 254
    if isinstance(price, (int, float)) and \
      isinstance(quantity, (int, float)):
Jean-Paul Smets's avatar
Jean-Paul Smets committed
255 256
      return quantity * price
    else:
257
      return default
Jean-Paul Smets's avatar
Jean-Paul Smets committed
258

Sebastien Robin's avatar
Sebastien Robin committed
259 260 261 262 263 264 265 266
  def _getBaseUnitPrice(self, context):
    # Override Amount._getBaseUnitPrice to use Movement's
    # getPriceCalculationOperandDict instead of Resource's.
    operand_dict = context.getPriceCalculationOperandDict(context=context)
    if operand_dict is not None:
      base_unit_price = operand_dict.get('base_unit_price', None)
      return base_unit_price

267
  security.declareProtected(Permissions.AccessContentsInformation,
268 269 270 271 272 273 274
          'getPriceCalculationOperandDict')
  def getPriceCalculationOperandDict(self, default=None, context=None, **kw):
    """Return a dict object which contains operands used for price
    calculation. The returned items depend on a site configuration,
    because this will invoke a custom script at the end. The only
    assumption is that the dict must contain a key 'price'
    which represents the final result of the price calculation.
275

276 277 278 279 280 281
    The purpose is to obtain descriptive information to notify the user
    of how a price is calculated in details, in particular, for invoices
    and quotations. So a script which is eventually called should provide
    all values required for generating such reports (e.g. a price,
    a price without a discount, and a discount).
    """
282
    # First, try a type-based method, and if not present, use
283 284 285 286
    # the good-old-days way (which only returns a final result).
    if context is None:
      context = self
    method = context._getTypeBasedMethod('getPriceCalculationOperandDict')
287 288 289 290 291
    if method is None:
      # Try this, because when the context is an instance of a derived
      # class of Movement, Movement_getPriceCalculationOperandDict is
      # not searched.
      method = getattr(context, 'Movement_getPriceCalculationOperandDict', None)
292
    if method is not None:
293
      operand_dict = unrestricted_apply(method, kw=kw)
294 295
      if operand_dict is None:
        return default
296
      assert 'price' in operand_dict
297 298 299
      return operand_dict
    return {'price': context.Movement_lookupPrice()}

Jean-Paul Smets's avatar
Jean-Paul Smets committed
300
  security.declareProtected(Permissions.AccessContentsInformation, 'getPrice')
301
  def getPrice(self, default=None, context=None, evaluate=1, **kw):
Jean-Paul Smets's avatar
Jean-Paul Smets committed
302
    """
303 304 305
      Get the Price in the context.

      If price is not stored locally, lookup a price and store it.
306 307 308

      FIXME: Don't trust this docstring, this method is not at all using the
      passed context, but uses this movement as context.
Jean-Paul Smets's avatar
Jean-Paul Smets committed
309
    """
310 311 312 313 314 315 316
    # XXX As all accessors can recieve the default value as first positional
    # argument, so we changed the first positional argument from context to
    # default. Here we try to provide backward compatibility for scripts
    # passing the context as first positional argument, and advice them to use:
    #   context.getPrice(context=context)
    # instead of:
    #   context.getPrice(context)
Jérome Perrin's avatar
Jérome Perrin committed
317
    if isinstance(default, Base):
318 319 320 321 322 323 324 325
      msg = 'getPrice first argument is supposed to be the default value'\
            ' accessor, the context should be passed as with the context='\
            ' keyword argument'
      warn(msg, DeprecationWarning)
      LOG('ERP5', WARNING, msg)
      context = default
      default = None

326 327 328 329
    if len(kw):
      warn('Passing keyword arguments to Movement.getPrice has no effect',
           DeprecationWarning)

Jean-Paul Smets's avatar
Jean-Paul Smets committed
330
    local_price = self._baseGetPrice()
331
    if local_price is None and evaluate:
332
      # We must find a price for this movement
333
      local_price = self._getPrice(context=self)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
334 335
    return local_price

336 337
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getTotalPrice')
338
  def getTotalPrice(self, default=0.0, context=None, REQUEST=None, fast=None,
Yoshinori Okuji's avatar
Yoshinori Okuji committed
339 340 341 342
                    **kw):
    """Return the total price in the context.

    The optional parameter "fast" is for compatibility, and will be ignored.
Jean-Paul Smets's avatar
Jean-Paul Smets committed
343
    """
344 345 346 347 348 349 350 351 352
    # see getPrice
    if isinstance(default, Base) and context is None:
      msg = 'getTotalPrice first argument is supposed to be the default value'\
            ' accessor, the context should be passed as with the context='\
            ' keyword argument'
      warn(msg, DeprecationWarning)
      LOG('ERP5', WARNING, msg)
      context = default
      default = None
353

Yoshinori Okuji's avatar
Yoshinori Okuji committed
354
    tmp_context = self.asContext(context=context, REQUEST=REQUEST, **kw)
355
    result = self._getTotalPrice(default=default, context=tmp_context, fast=fast, **kw)
356
    method = self._getTypeBasedMethod('convertTotalPrice')
357 358 359
    if method is None:
      return result
    return method(result)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
360

361 362
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getTotalQuantity')
363
  def getTotalQuantity(self, default=0.0):
Jean-Paul Smets's avatar
Jean-Paul Smets committed
364 365 366
    """
      Returns the quantity if no cell or the total quantity if cells
    """
367
    return self.getQuantity(default=default)
Jean-Paul Smets's avatar
Jean-Paul Smets committed
368

369
  # Industrial price API
370 371
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getIndustrialPrice')
372 373 374 375 376 377 378 379 380
  def getIndustrialPrice(self):
    """
      Calculates industrial price in context of this movement
    """
    resource = self.getResourceValue()
    if resource is not None:
      return resource.getIndustrialPrice(context=self)
    return None

381
  # Asset price calculation
382 383
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getSourceInventoriatedTotalAssetPrice')
384
  def getSourceInventoriatedTotalAssetPrice(self):
Jean-Paul Smets's avatar
Jean-Paul Smets committed
385
    """
386 387
      Returns a price which can be used to calculate stock value (asset)

Jean-Paul Smets's avatar
Jean-Paul Smets committed
388 389
      Asset price is used for calculation of inventory asset value
      and for accounting
390 391 392 393 394 395 396 397

      If the asset price is specified (as in accounting for multi-currency),
      then it is returned. If no asset price is specified, then we use
      the price as defined on the line, but only for incoming quantities
      (purchase price, industrial price, etc.).

      For outgoing quantities, it is the responsability of database
      to calculate asset prices based on calculation rules (FIFO,
398
      LIFO, AVERAGE, etc.).
Jean-Paul Smets's avatar
Jean-Paul Smets committed
399
    """
400 401 402 403
    # This is what we use for accounting
    result = self.getSourceTotalAssetPrice()
    if result is not None:
      return result
404
    quantity = self.getQuantity()
405 406 407
    if quantity :
      source_asset_price = self.getSourceAssetPrice()
      if source_asset_price :
408
        return source_asset_price * - quantity
409
    return None
410

411 412 413 414 415 416 417 418
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getSourceInventoriatedTotalAssetDebit')
  def getSourceInventoriatedTotalAssetDebit(self) :
    """
      Returns the debit part of inventoriated source total asset price.
    """
    result = self.getSourceInventoriatedTotalAssetPrice()
    if result is not None :
Sebastien Robin's avatar
Sebastien Robin committed
419
      if (result > 0) ^ bool(self.isCancellationAmount()):
420
        return result
421
    return 0.0
422 423 424 425 426 427 428 429 430

  security.declareProtected( Permissions.AccessContentsInformation,
                             'getSourceInventoriatedTotalAssetCredit')
  def getSourceInventoriatedTotalAssetCredit(self) :
    """
      Returns the credit part of inventoriated source total asset price.
    """
    result = self.getSourceInventoriatedTotalAssetPrice()
    if result is not None :
Sebastien Robin's avatar
Sebastien Robin committed
431
      if (result < 0) ^ bool(self.isCancellationAmount()):
432
        return -result
433
    return 0.0
434

435 436
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getDestinationInventoriatedTotalAssetPrice')
437
  def getDestinationInventoriatedTotalAssetPrice(self):
Jean-Paul Smets's avatar
Jean-Paul Smets committed
438
    """
439 440
      Returns a price which can be used to calculate stock value (asset)

Jean-Paul Smets's avatar
Jean-Paul Smets committed
441 442 443
      Asset price is used for calculation of inventory asset value
      and for accounting
    """
444 445 446 447
    # This is what we use for accounting
    result = self.getDestinationTotalAssetPrice()
    if result is not None:
      return result
448
    quantity = self.getQuantity()
449 450 451 452
    if quantity :
      destination_asset_price = self.getDestinationAssetPrice()
      if destination_asset_price :
        return destination_asset_price * quantity
453
    return None
Jean-Paul Smets's avatar
Jean-Paul Smets committed
454

455 456 457 458 459 460 461 462
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getDestinationInventoriatedTotalAssetDebit')
  def getDestinationInventoriatedTotalAssetDebit(self) :
    """
      Returns the debit part of inventoriated destination total asset price.
    """
    result = self.getDestinationInventoriatedTotalAssetPrice()
    if result is not None :
Sebastien Robin's avatar
Sebastien Robin committed
463
      if (result > 0) ^ bool(self.isCancellationAmount()):
464
        return result
465
    return 0.0
466 467 468 469 470 471 472 473 474

  security.declareProtected( Permissions.AccessContentsInformation,
                             'getDestinationInventoriatedTotalAssetCredit')
  def getDestinationInventoriatedTotalAssetCredit(self) :
    """
      Returns the credit part of inventoriated destination total asset price.
    """
    result = self.getDestinationInventoriatedTotalAssetPrice()
    if result is not None :
Sebastien Robin's avatar
Sebastien Robin committed
475
      if (result < 0) ^ bool(self.isCancellationAmount()):
476
        return -result
477
    return 0.0
478

479 480
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getSourceAssetPrice')
481 482 483 484
  def getSourceAssetPrice(self):
    """
      Returns the price converted to the currency of the source section

485
      This will be implemeted by calling currency conversion on currency resources
486
    """
487 488 489
    type_based_script = self._getTypeBasedMethod('getSourceAssetPrice')
    if type_based_script:
      return type_based_script()
490
    return self._getAssetPrice(section = self.getSourceSectionValue())
491

492 493
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getDestinationAssetPrice')
494 495 496 497
  def getDestinationAssetPrice(self):
    """
      Returns the price converted to the currency of the destination section
    """
498 499 500
    type_based_script = self._getTypeBasedMethod('getDestinationAssetPrice')
    if type_based_script:
      return type_based_script()
501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524
    return self._getAssetPrice(section = self.getDestinationSectionValue())

  def _getAssetPrice(self,section):
    from Products.ERP5Type.Document import newTempAccountingTransactionLine
    price = self.getPrice()
    source_currency = self.getPriceCurrencyValue()
    section_source_currency = section.getPriceCurrency(base=True)
    if source_currency and section_source_currency:
      temp_transaction = newTempAccountingTransactionLine(
        self.getPortalObject(),
        "accounting_line",
        source_section=section.getRelativeUrl(),
        resource=source_currency.getRelativeUrl(),
        start_date=self.getStartDate(),
      )
      exchange_rate = source_currency.getPrice(
        context=temp_transaction.asContext(
            categories=[temp_transaction.getResource(base=True),
                        section_source_currency],
        )
      )
      if exchange_rate and price:
        return exchange_rate * price
    return price
525

Jean-Paul Smets's avatar
Jean-Paul Smets committed
526
  # Causality computation
527 528
  security.declareProtected( Permissions.AccessContentsInformation,
                             'isConvergent')
Jean-Paul Smets's avatar
Jean-Paul Smets committed
529 530
  def isConvergent(self):
    """
531
      Returns true if movement is not divergent
Jean-Paul Smets's avatar
Jean-Paul Smets committed
532
    """
533
    return bool(not self.isDivergent())
Jean-Paul Smets's avatar
Jean-Paul Smets committed
534

535 536
  security.declareProtected( Permissions.AccessContentsInformation,
                             'isDivergent')
Jean-Paul Smets's avatar
Jean-Paul Smets committed
537
  def isDivergent(self):
538
    """Return True if this movement diverges from the its simulation.
Jean-Paul Smets's avatar
Jean-Paul Smets committed
539
    """
Sebastien Robin's avatar
Sebastien Robin committed
540 541
    for simulation_movement in self.getDeliveryRelatedValueList():
      if simulation_movement.isDivergent():
542 543
        return True
    return False
Jean-Paul Smets's avatar
Jean-Paul Smets committed
544

545 546
  security.declareProtected(Permissions.AccessContentsInformation,
                            'getDivergenceList')
547 548
  def getDivergenceList(self):
    """
549
    Return a list of messages that contains the divergences
550
    """
551
    divergence_list = []
552
    for simulation_movement in self.getDeliveryRelatedValueList():
553 554 555 556
      divergence_list.extend(simulation_movement.getDivergenceList())

    return divergence_list

557 558
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getExplanation')
Jean-Paul Smets's avatar
Jean-Paul Smets committed
559
  def getExplanation(self):
560
    """
561
      Returns the relative_url of the explanation of this movement.
562
    """
563 564 565
    explanation = self.getExplanationValue()
    if explanation is not None:
      return explanation.getRelativeUrl()
Jean-Paul Smets's avatar
Jean-Paul Smets committed
566

567 568
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getExplanationUid')
Jean-Paul Smets's avatar
Jean-Paul Smets committed
569
  def getExplanationUid(self):
570
    """
571
      Returns the uid of the explanation of this movement.
572
    """
573 574 575
    explanation = self.getExplanationValue()
    if explanation is not None:
      return explanation.getUid()
Jean-Paul Smets's avatar
Jean-Paul Smets committed
576

577 578
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getExplanationValue')
Jean-Paul Smets's avatar
Jean-Paul Smets committed
579
  def getExplanationValue(self):
580
    """
581
      Returns the object explanation of this movement.
582
    """
583 584 585 586
    try:
      return self.getRootDeliveryValue()
    except AttributeError:
      return None
587

588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getExplanationTitle')
  def getExplanationTitle(self, default=''):
    """
      Returns the title of the explanation of this movement.
    """
    explanation_value = self.getExplanationValue()
    if explanation_value is not None:
      return explanation_value.getTitle()
    return default

  security.declareProtected( Permissions.AccessContentsInformation,
                             'getExplanationReference')
  def getExplanationReference(self, default=''):
    """
      Returns the reference of the explanation of this movement.
    """
    explanation_value = self.getExplanationValue()
    if explanation_value is not None:
      return explanation_value.getReference()
    return default

610
  security.declareProtected( Permissions.AccessContentsInformation,
611
                             'getRootCausalityValueList')
612 613 614 615 616 617 618
  def getRootCausalityValueList(self):
    """
      Returns the initial causality value for this movement.
      This method will look at the causality and check if the
      causality has already a causality
    """
    return self.getExplanationValue().getRootCausalityValueList()
619

620 621

  # Simulation
622 623
  security.declareProtected( Permissions.AccessContentsInformation,
                             'isSimulated')
Jean-Paul Smets's avatar
Jean-Paul Smets committed
624
  def isSimulated(self):
625
    # 'order' category is deprecated. it is kept for compatibility.
626 627
    return (len(self.getDeliveryRelatedValueList()) > 0) or\
           (len(self.getOrderRelatedValueList()) > 0)
628

629 630 631 632 633 634 635 636 637 638 639
  security.declareProtected(Permissions.AccessContentsInformation,
                            'isGeneratedBySimulation')
  def isGeneratedBySimulation(self):
    """
      Returns true if the movement is linked to a simulation movement whose
      parent is not a root applied rule, even if the movement is being built.

      Otherwise, this means the movement is or should be linked to a root
      simulation movement.
    """
    simulation_movement = self.getDeliveryRelatedValue()
640 641
    return simulation_movement is not None and \
       not simulation_movement.getParentValue().isRootAppliedRule()
642

643 644 645 646 647 648 649
  security.declareProtected(Permissions.AccessContentsInformation,
                            'getSimulationQuantity')
  def getSimulationQuantity(self):
    """Computes the quantities in the simulation.
    """
    return sum(m.getQuantity() for m in self.getDeliveryRelatedValueList())

650
  # Debit and credit methods
651 652
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getSourceDebit')
653 654 655 656 657 658 659
  def getSourceDebit(self):
    """
      Return the quantity
    """
    quantity = self.getQuantity()
    try:
      quantity = float(quantity)
Yoshinori Okuji's avatar
Yoshinori Okuji committed
660
    except TypeError:
661
      quantity = 0.0
Sebastien Robin's avatar
Sebastien Robin committed
662
    if (quantity < 0) ^ bool(self.isCancellationAmount()):
663
      return - quantity
664
    return 0.0
665

666 667
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getSourceCredit')
668 669 670 671 672 673 674
  def getSourceCredit(self):
    """
      Return the quantity
    """
    quantity = self.getQuantity()
    try:
      quantity = float(quantity)
Yoshinori Okuji's avatar
Yoshinori Okuji committed
675
    except TypeError:
676
      quantity = 0.0
Sebastien Robin's avatar
Sebastien Robin committed
677
    if (quantity < 0) ^ bool(self.isCancellationAmount()):
678
      return 0.0
679
    return quantity
680

681 682
  security.declareProtected( Permissions.AccessContentsInformation,
                    'getDestinationDebit', 'getDestinationCredit')
683 684 685 686 687 688 689 690 691
  getDestinationDebit = getSourceCredit
  getDestinationCredit = getSourceDebit

  security.declareProtected(Permissions.ModifyPortalContent, 'setSourceDebit')
  def setSourceDebit(self, source_debit):
    """
      Set the quantity
    """
    if source_debit in (None, ''):
692
      return
693 694
    try:
      source_debit = float(source_debit)
Yoshinori Okuji's avatar
Yoshinori Okuji committed
695
    except TypeError:
696
      source_debit = 0.0
697
    self.setCancellationAmount(source_debit < 0)
698 699 700 701 702 703 704 705
    self.setQuantity(- source_debit)

  security.declareProtected(Permissions.ModifyPortalContent, 'setSourceCredit')
  def setSourceCredit(self, source_credit):
    """
      Set the quantity
    """
    if source_credit in (None, ''):
706
      return
707 708
    try:
      source_credit = float(source_credit)
Yoshinori Okuji's avatar
Yoshinori Okuji committed
709
    except TypeError:
710
      source_credit = 0.0
711
    self.setCancellationAmount(source_credit < 0)
712 713
    self.setQuantity(source_credit)

714 715 716 717
  security.declareProtected( Permissions.ModifyPortalContent,
                             'setDestinationDebit', 'setDestinationCredit' )
  setDestinationDebit = setSourceCredit
  setDestinationCredit = setSourceDebit
718

719
  security.declarePrivate('_edit')
720
  def _edit(self, edit_order=(), **kw):
721
    """Overloaded _edit to support setting debit and credit at the same time,
722
    which is required for the GUI.
723 724 725
    Also sets the variation category list and property dict at the end, because
    _setVariationCategoryList and _setVariationPropertyDict needs the resource
    to be set.
726 727
    """
    quantity = 0
728
    if 'source_debit' in kw and 'source_credit' in kw:
729 730 731
      source_credit = kw.pop('source_credit') or 0
      source_debit = kw.pop('source_debit') or 0
      quantity += (source_credit - source_debit)
732
      kw['quantity'] = quantity
733
      kw['cancellation_amount'] = (source_credit < 0 or source_debit < 0)
734
    if 'destination_debit' in kw and 'destination_credit' in kw:
735 736 737
      destination_credit = kw.pop('destination_credit') or 0
      destination_debit = kw.pop('destination_debit') or 0
      quantity += (destination_debit - destination_credit)
738
      kw['quantity'] = quantity
739
      kw['cancellation_amount'] = (destination_credit < 0 or destination_debit < 0)
740 741 742 743 744 745 746 747 748 749 750 751 752 753

    # If both asset debit and asset credit are passed, we have to take care not
    # to erase the asset price if one of them is unset.
    if kw.get('source_asset_debit') or kw.get('source_asset_credit'):
      if kw.get('source_asset_debit') in (None, ''):
        kw.pop('source_asset_debit', None)
      if kw.get('source_asset_credit') in (None, ''):
        kw.pop('source_asset_credit', None)
    if kw.get('destination_asset_debit') or kw.get('destination_asset_credit'):
      if kw.get('destination_asset_debit') in (None, ''):
        kw.pop('destination_asset_debit', None)
      if kw.get('destination_asset_credit') in (None, ''):
        kw.pop('destination_asset_credit', None)

754
    if not edit_order:
755
      edit_order = ('variation_category_list', 'variation_property_dict',)
756
    return XMLObject._edit(self, edit_order=edit_order, **kw)
757

758
  # Debit and credit methods for asset
759 760
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getSourceAssetDebit' )
761 762 763 764 765
  def getSourceAssetDebit(self):
    """
      Return the debit part of the source total asset price.

      This is the same as getSourceDebit where quantity is replaced
766 767
      by source_total_asset_price.
      This method returns 0 if the total asset price is not set.
768 769 770 771 772 773
    """
    quantity = self.getSourceTotalAssetPrice()
    try:
      quantity = float(quantity)
    except TypeError:
      quantity = 0.0
Sebastien Robin's avatar
Sebastien Robin committed
774
    if (quantity < 0) ^ bool(self.isCancellationAmount()):
775
      return 0.0
776
    return quantity
777

778 779
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getSourceAssetCredit' )
780 781
  def getSourceAssetCredit(self):
    """
782 783 784
      Return the credit part of the source total asset price.

      This is the same as getSourceCredit where quantity is replaced
785 786
      by source_total_asset_price.
      This method returns 0 if the total asset price is not set.
787 788 789 790 791 792
    """
    quantity = self.getSourceTotalAssetPrice()
    try:
      quantity = float(quantity)
    except TypeError:
      quantity = 0.0
Sebastien Robin's avatar
Sebastien Robin committed
793
    if (quantity < 0) ^ bool(self.isCancellationAmount()):
794 795
      return - quantity
    return 0.0
796

797 798 799 800 801
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getDestinationAssetDebit' )
  def getDestinationAssetDebit(self):
    """
      Return the debit part of the destination total asset price.
802

803
      This is the same as getDestinationDebit where quantity is replaced
804 805
      by destination_total_asset_price.
      This method returns 0 if the total asset price is not set.
806 807 808 809 810 811
    """
    quantity = self.getDestinationTotalAssetPrice()
    try:
      quantity = float(quantity)
    except TypeError:
      quantity = 0.0
Sebastien Robin's avatar
Sebastien Robin committed
812
    if (quantity < 0) ^ bool(self.isCancellationAmount()):
813
      return 0.0
814
    return quantity
815

816 817 818 819 820 821 822
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getDestinationAssetCredit' )
  def getDestinationAssetCredit(self):
    """
      Return the credit part of the destination total asset price.

      This is the same as getDestinationCredit where quantity is replaced
823 824
      by destination_total_asset_price.
      This method returns 0 if the total asset price is not set.
825 826 827 828 829 830
    """
    quantity = self.getDestinationTotalAssetPrice()
    try:
      quantity = float(quantity)
    except TypeError:
      quantity = 0.0
Sebastien Robin's avatar
Sebastien Robin committed
831
    if (quantity < 0) ^ bool(self.isCancellationAmount()):
832 833
      return - quantity
    return 0.0
834

835 836
  security.declareProtected( Permissions.ModifyPortalContent,
                             'setSourceAssetDebit' )
837 838
  def setSourceAssetDebit(self, source_debit):
    """
839
      Set the source total asset price
840 841
    """
    if source_debit in (None, ''):
842
      self.setSourceTotalAssetPrice(None)
843
      return
844 845 846 847
    try:
      source_debit = float(source_debit)
    except TypeError:
      source_debit = 0.0
848
    self.setCancellationAmount(source_debit < 0)
849
    self.setSourceTotalAssetPrice(source_debit)
850

851 852 853 854
  security.declareProtected( Permissions.ModifyPortalContent,
                             'setSourceAssetCredit' )
  def setSourceAssetCredit(self, source_credit):
    """
855
      Set the source total asset price
856 857
    """
    if source_credit in (None, ''):
858
      self.setSourceTotalAssetPrice(None)
859
      return
860 861 862 863
    try:
      source_credit = float(source_credit)
    except TypeError:
      source_credit = 0.0
864
    self.setCancellationAmount(source_credit < 0)
865
    self.setSourceTotalAssetPrice( - source_credit)
866 867 868 869 870

  security.declareProtected( Permissions.ModifyPortalContent,
                             'setDestinationAssetDebit' )
  def setDestinationAssetDebit(self, destination_debit):
    """
871
      Set the destination total asset price
872 873
    """
    if destination_debit in (None, ''):
874
      self.setDestinationTotalAssetPrice(None)
875
      return
876 877 878 879
    try:
      destination_debit = float(destination_debit)
    except TypeError:
      destination_debit = 0.0
880
    self.setCancellationAmount(destination_debit < 0)
881 882 883 884 885 886
    self.setDestinationTotalAssetPrice(destination_debit)

  security.declareProtected( Permissions.ModifyPortalContent,
                             'setDestinationAssetCredit' )
  def setDestinationAssetCredit(self, destination_credit):
    """
887
      Set the destination total asset price
888 889
    """
    if destination_credit in (None, ''):
890
      self.setDestinationTotalAssetPrice(None)
891
      return
892 893 894 895
    try:
      destination_credit = float(destination_credit)
    except TypeError:
      destination_credit = 0.0
896
    self.setCancellationAmount(destination_credit < 0)
897
    self.setDestinationTotalAssetPrice( - destination_credit)
898

Jean-Paul Smets's avatar
Jean-Paul Smets committed
899
  # Item Access (tracking)
900 901
  security.declareProtected(Permissions.AccessContentsInformation,
      'getTrackedItemUidList')
Jean-Paul Smets's avatar
Jean-Paul Smets committed
902
  def getTrackedItemUidList(self):
903
    """
Jean-Paul Smets's avatar
Jean-Paul Smets committed
904
      Return a list of uid for related items
905
    """
Jean-Paul Smets's avatar
Jean-Paul Smets committed
906 907 908
    ### XXX We should filter by portal type here
    return self.getAggregateUidList()

909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927
  # Helper methods to display total quantities as produced / consumed
  security.declareProtected(Permissions.AccessContentsInformation,
      'getProductionTotalQuantity')
  def getProductionTotalQuantity(self):
    """
      Return the produced quantity
    """
    quantity = self.getTotalQuantity()
    return self.getProductionQuantity(quantity=quantity)

  security.declareProtected(Permissions.AccessContentsInformation,
      'getConsumptionTotalQuantity')
  def getConsumptionTotalQuantity(self):
    """
      Return the produced quantity
    """
    quantity = self.getTotalQuantity()
    return self.getConsumptionQuantity(quantity=quantity)

928 929 930 931 932 933 934 935 936 937 938 939 940 941 942
  security.declareProtected(Permissions.AccessContentsInformation,
      'getSubVariationText')
  def getSubVariationText(self,**kw):
    """
    Provide a string representation of XXX
    """
    base_category_list = self.getPortalSubVariationBaseCategoryList()
    portal_type_list = self.getPortalSubVariationTypeList()
    return_list = []
    for base_category in base_category_list:
      variation_list = self.getAcquiredCategoryMembershipList(base_category,
          portal_type=portal_type_list,base=1)
      return_list.extend(variation_list)
    return "\n".join(return_list)

943 944 945 946 947 948 949 950 951 952
  security.declareProtected( Permissions.AccessContentsInformation,
                             'getParentExplanationValue')
  def getParentExplanationValue(self):
    """
      This method should be removed as soon as movement groups
      will be rewritten. It is a temp hack
    """
    return self.getParentValue().getExplanationValue()


Jean-Paul Smets's avatar
Jean-Paul Smets committed
953 954 955 956 957
  # SKU vs. CU
#   security.declareProtected(Permissions.AccessContentsInformation, 'getSourceStandardInventoriatedQuantity')
#   def getSourceStandardInventoriatedQuantity(self):
#     """
#       The inventoriated quantity converted in a default unit
958
#
Jean-Paul Smets's avatar
Jean-Paul Smets committed
959 960
#       For assortments, returns the inventoriated quantity in terms of number of items
#       in the assortemnt.
961
#
Jean-Paul Smets's avatar
Jean-Paul Smets committed
962 963
#       For accounting, returns the quantity converted in a default unit
#     """
964 965
#     return self.getStandardInventoriatedQuantity()

Jean-Paul Smets's avatar
Jean-Paul Smets committed
966 967 968 969
#   security.declareProtected(Permissions.AccessContentsInformation, 'getDestinationStandardInventoriatedQuantity')
#   def getDestinationStandardInventoriatedQuantity(self):
#     """
#       The inventoriated quantity converted in a default unit
970
#
Jean-Paul Smets's avatar
Jean-Paul Smets committed
971 972
#       For assortments, returns the inventoriated quantity in terms of number of items
#       in the assortemnt.
973
#
Jean-Paul Smets's avatar
Jean-Paul Smets committed
974 975
#       For accounting, returns the quantity converted in a default unit
#     """
976
#     return self.getStandardInventoriatedQuantity()
977 978 979 980 981 982 983

  security.declareProtected( Permissions.AccessContentsInformation,
                             'asMovementList')
  def asMovementList(self):
    """
    Placeholder method called when indexing a movement.

984
    It can be overloaded to generate multiple movements
985
    from a single one.
986
    It is used for cataloging a movement multiple time in
987 988 989
    the movement/stock tables.

    Ex: a movement have multiple destinations.
990
    asMovementList returns a list a movement context with different
991 992 993
    single destination.
    """
    return (self, )
994