diff --git a/product/ERP5/Document/BalanceTransaction.py b/product/ERP5/Document/BalanceTransaction.py new file mode 100644 index 0000000000000000000000000000000000000000..6045103d0e3a6b6fc42aaaa6ff5e6849dc6e40da --- /dev/null +++ b/product/ERP5/Document/BalanceTransaction.py @@ -0,0 +1,399 @@ +############################################################################## +# +# Copyright (c) 2007 Nexedi SA and Contributors. All Rights Reserved. +# Jerome Perrin <jerome@nexedi.com> +# +# 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. +# +############################################################################## + +from UserDict import UserDict + +from AccessControl import ClassSecurityInfo +from Products.ERP5Type import Permissions, PropertySheet, Constraint, Interface +from Products.ERP5.Document.Inventory import Inventory +from Products.ERP5.Document.AccountingTransaction import AccountingTransaction + + +class InventoryKey(UserDict): + """Class to use as a key when defining inventory dicts. + """ + def __init__(self, **kw): + self.data = {} + self.data.update(kw) + + def clear(self): + raise TypeError, 'InventoryKey are immutable' + + def pop(self, keys, *args): + raise TypeError, 'InventoryKey are immutable' + + def update(self, dict=None, **kwargs): + raise TypeError, 'InventoryKey are immutable' + + def __delitem__(self, key): + raise TypeError, 'InventoryKey are immutable' + + def __setitem__(self, key, item): + raise TypeError, 'InventoryKey are immutable' + + def setdefault(self, key, failobj=None): + if key in self.data: + return self.data[key] + raise TypeError, 'InventoryKey are immutable' + + def __hash__(self): + return hash(tuple(self.items())) + + +class BalanceTransaction(AccountingTransaction, Inventory): + """Balance Transaction + """ + + # CMF Type Definition + meta_type = 'ERP5 Balance Transaction' + portal_type = 'Balance Transaction' + add_permission = Permissions.AddPortalContent + isPortalContent = 1 + isRADContent = 1 + isDelivery = 1 + + #__implements__ = ( Interface.Inventory, ) + + # Declarative security + security = ClassSecurityInfo() + security.declareObjectProtected(Permissions.AccessContentsInformation) + + # Default Properties + property_sheets = ( PropertySheet.Base + , PropertySheet.XMLObject + , PropertySheet.CategoryCore + , PropertySheet.DublinCore + , PropertySheet.Task + , PropertySheet.Arrow + , PropertySheet.Movement + , PropertySheet.Delivery + , PropertySheet.Amount + , PropertySheet.Reference + , PropertySheet.PaymentCondition + ) + + def _getGroupByNodeMovementList(self): + """Returns movements that implies only grouping by node.""" + movement_list = [] + for movement in self.getMovementList(): + if not (movement.getSourceSection() or + movement.getDestinationPayment()): + movement_list.append(movement) + return movement_list + + def _getGroupByPaymentMovementList(self): + """Returns movements that implies grouping by node and payment""" + movement_list = [] + for movement in self.getMovementList(): + if movement.getDestinationPayment(): + movement_list.append(movement) + return movement_list + + def _getGroupByMirrorSectionMovementList(self): + """Returns movements that implies only grouping by node and mirror section""" + movement_list = [] + for movement in self.getMovementList(): + if movement.getSourceSection(): + movement_list.append(movement) + return movement_list + + + def _getCurrentStockDict(self): + """Looks the current stock by calling getInventoryList, and building a + dictionnary of InventoryKey + """ + current_stock = dict() + getInventoryList = self.getPortalObject()\ + .portal_simulation.getInventoryList + default_inventory_params = dict( + at_date=self.getStartDate(), + section_uid=self.getDestinationSectionUid(), + simulation_state=('delivered', )) + + # node + for movement in self._getGroupByNodeMovementList(): + node_uid = movement.getDestinationUid() + section_uid = movement.getDestinationSectionUid() + + stock_list = current_stock.setdefault( + InventoryKey(node_uid=node_uid, + section_uid=section_uid), []) + for inventory in getInventoryList( + node_uid=node_uid, + group_by_node=1, + group_by_resource=1, + **default_inventory_params): + stock_list.append( + dict(destination_uid=node_uid, + destination_section_uid=section_uid, + resource_uid=inventory.resource_uid, + quantity=inventory.total_quantity, + total_price=inventory.total_price, )) + + # mirror section + for movement in self._getGroupByMirrorSectionMovementList(): + node_uid = movement.getDestinationUid() + section_uid = movement.getDestinationSectionUid() + mirror_section_uid = movement.getSourceSectionUid() + + stock_list = current_stock.setdefault( + InventoryKey(node_uid=node_uid, + mirror_section_uid=mirror_section_uid, + section_uid=section_uid), []) + for inventory in getInventoryList( + node_uid=node_uid, + mirror_section_uid=mirror_section_uid, + group_by_node=1, + group_by_mirror_section=1, + group_by_resource=1, + **default_inventory_params): + stock_list.append( + dict(destination_uid=node_uid, + destination_section_uid=section_uid, + source_section_uid=mirror_section_uid, + resource_uid=inventory.resource_uid, + quantity=inventory.total_quantity, + total_price=inventory.total_price, )) + + # payment + for movement in self._getGroupByPaymentMovementList(): + node_uid = movement.getDestinationUid() + payment_uid = movement.getDestinationPaymentUid() + section_uid = movement.getDestinationSectionUid() + + stock_list = current_stock.setdefault( + InventoryKey(node_uid=node_uid, + section_uid=section_uid, + payment_uid=payment_uid), []) + for inventory in getInventoryList( + node_uid=node_uid, + group_by_node=1, + group_by_payment=1, + group_by_resource=1, + **default_inventory_params): + stock_list.append( + dict(destination_uid=node_uid, + destination_section_uid=section_uid, + destination_payment_uid=payment_uid, + resource_uid=inventory.resource_uid, + quantity=inventory.total_quantity, + total_price=inventory.total_price, )) + + return current_stock + + + def _getNewStockDict(self): + """Looks the new stock on lines in this inventory, and building a + dictionnary of InventoryKey + """ + new_stock = dict() + # node + for movement in self._getGroupByNodeMovementList(): + node_uid = movement.getDestinationUid() + section_uid = movement.getDestinationSectionUid() + + stock_list = new_stock.setdefault( + InventoryKey(node_uid=node_uid, + section_uid=section_uid), []) + stock_list.append( + dict(destination_uid=node_uid, + destination_section_uid=section_uid, + resource_uid=movement.getResourceUid(), + quantity=movement.getQuantity(), + total_price=movement\ + .getDestinationInventoriatedTotalAssetPrice(), )) + + # mirror section + for movement in self._getGroupByMirrorSectionMovementList(): + node_uid = movement.getDestinationUid() + section_uid = movement.getDestinationSectionUid() + mirror_section_uid = movement.getSourceSectionUid() + + stock_list = new_stock.setdefault( + InventoryKey(node_uid=node_uid, + mirror_section_uid=mirror_section_uid, + section_uid=section_uid), []) + stock_list.append( + dict(destination_uid=node_uid, + destination_section_uid=section_uid, + source_section_uid=mirror_section_uid, + resource_uid=movement.getResourceUid(), + quantity=movement.getQuantity(), + total_price=movement\ + .getDestinationInventoriatedTotalAssetPrice(), )) + + # payment + for movement in self._getGroupByPaymentMovementList(): + node_uid = movement.getDestinationUid() + section_uid = movement.getDestinationSectionUid() + payment_uid = movement.getDestinationPaymentUid() + + stock_list = new_stock.setdefault( + InventoryKey(node_uid=node_uid, + payment_uid=payment_uid, + section_uid=section_uid), []) + stock_list.append( + dict(destination_uid=node_uid, + destination_section_uid=section_uid, + destination_payment_uid=payment_uid, + resource_uid=movement.getResourceUid(), + quantity=movement.getQuantity(), + total_price=movement\ + .getDestinationInventoriatedTotalAssetPrice(), )) + + return new_stock + + + def _computeStockDifferenceList(self, current_stock_dict, new_stock_dict): + """Compute the difference between the result of _getCurrentStockDict and + _getNewStockDict. Returns a list of dictionnaries with similar keys that + the ones on inventory brains (node, section, mirror_section ...) + """ + def computeStockDifference(current_stock_list, new_stock_list): + # helper function to compute difference between two stock lists. + if not current_stock_list: + return new_stock_list + + stock_diff_list = current_stock_list[::] # deep copy ? + + for new_stock in new_stock_list: + matching_diff = None + for diff in stock_diff_list: + for prop in [k for k in diff.keys() if k not in ('quantity', + 'total_price')]: + if diff[prop] != new_stock.get(prop): + break + else: + matching_diff = diff + + # matching_diff are negated later + if matching_diff: + matching_diff['quantity'] -= new_stock['quantity'] + # Matching_diff and new_stock must be consistent. + # both with total price or none. + if matching_diff['total_price'] and new_stock['total_price']: + matching_diff['total_price'] -= new_stock['total_price'] + else: + stock_diff_list.append(new_stock) + + # we were doing with reversed calculation, so negate deltas again. + # Also we remove stocks that have 0 quantity and price. + return [negateStock(s) for s in stock_diff_list + if s['quantity'] and s['total_price']] + + def negateStock(stock): + negated_stock = stock.copy() + negated_stock['quantity'] = -stock['quantity'] + if stock['total_price']: + negated_stock['total_price'] = -stock['total_price'] + return negated_stock + + delta_list = [] + for current_stock_key, current_stock_value_list in \ + current_stock_dict.items(): + if current_stock_key in new_stock_dict: + delta_list.extend(computeStockDifference( + current_stock_value_list, + new_stock_dict[current_stock_key])) + else: + delta_list.extend( + [negateStock(s) for s in current_stock_value_list]) + + # now add every thing in new stock which was not in current stock + for new_stock_key, new_stock_value_list in \ + new_stock_dict.items(): + if new_stock_key not in current_stock_dict: + delta_list.extend(new_stock_value_list) + + return delta_list + + + def _getTempObjectFactory(self): + """Returns the factory method that will create temp object. + + This method must return a function that accepts properties keywords + arguments and returns a temp object edited with those properties. + """ + from Products.ERP5Type.Document import newTempBalanceTransactionLine + + def factory(*args, **kw): + doc = newTempBalanceTransactionLine(self, self.getId(), + uid=self.getUid()) + destination_total_asset_price = kw.pop('total_price', None) + if destination_total_asset_price is not None: + kw['destination_total_asset_price'] = destination_total_asset_price + doc._edit(*args, **kw) + return doc + + return factory + + + security.declarePrivate('alternateReindexObject') + def alternateReindexObject(self, **kw): + """This method is called when an inventory object is included in a + group of catalogged objects. + """ + return self.immediateReindexObject(**kw) + + + def immediateReindexObject(self, **kw): + """Reindexes the object. + This is different indexing that the default Inventory indexing, because + we want to take into account that lines in this balance transaction to + represent the balance of an account (node) with different parameters, + based on the account_type of those accounts: + - on standards accounts: it's simply the balance for node, section + (and maybe resource, like all of thoses) + - on payable / receivable accounts: for node, section and mirror + section + - on bank accounts: for node, section and payment + + Also this uses total_price (and quantity), and ignores variations and + subvariations as it does not exist in accounting. + """ + current_stock_dict = self._getCurrentStockDict() + new_stock_dict = self._getNewStockDict() + diff_list = self._computeStockDifferenceList( + current_stock_dict, + new_stock_dict) + + temp_object_factory = self._getTempObjectFactory() + stock_object_list = [] + add_obj = stock_object_list.append + for diff in diff_list: + add_obj(temp_object_factory(**diff)) + + # Catalog this transaction as a standard document + object_list = [self] + self.portal_catalog.catalogObjectList(object_list) + + # Catalog differences calculated from lines + self.portal_catalog.catalogObjectList(stock_object_list, + method_id_list=('z_catalog_stock_list',), + disable_cache=1, check_uid=0) + diff --git a/product/ERP5/Document/BalanceTransactionLine.py b/product/ERP5/Document/BalanceTransactionLine.py new file mode 100644 index 0000000000000000000000000000000000000000..8d45fb3ea1c75101200f5c5bffa43b434634f758 --- /dev/null +++ b/product/ERP5/Document/BalanceTransactionLine.py @@ -0,0 +1,58 @@ +############################################################################## +# +# Copyright (c) 2007 Nexedi SA and Contributors. All Rights Reserved. +# Jerome Perrin <jerome@nexedi.com> +# +# 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. +# +############################################################################## + +from AccessControl import ClassSecurityInfo + +from Products.ERP5Type import Permissions, PropertySheet, Interface +from Products.ERP5.Document.AccountingTransactionLine import \ + AccountingTransactionLine +from Products.ERP5.Document.InventoryLine import InventoryLine + + +class BalanceTransactionLine(AccountingTransactionLine, InventoryLine): + """A balance transaction line inherits price handling from accounting + transaction line, and indexing from inventory line. + """ + + meta_type = 'ERP5 Balance Transaction Line' + portal_type = 'Balance Transaction Line' + add_permission = Permissions.AddPortalContent + isPortalContent = 1 + isRADContent = 1 + isIndexable = 0 + + # Declarative security + security = ClassSecurityInfo() + security.declareObjectProtected(Permissions.AccessContentsInformation) + + reindexObject = InventoryLine.reindexObject + recursiveReindexObject = InventoryLine.recursiveReindexObject + immediateReindexObject = InventoryLine.immediateReindexObject + recursiveImmediateReindexObject = \ + InventoryLine.recursiveImmediateReindexObject + diff --git a/product/ERP5/tests/testAccounting.py b/product/ERP5/tests/testAccounting.py index eb3c3675e636c1f6d52610adb5a05f1c7b5b9fc0..44e96b6d7c362cfa8de5b9b988a6c1b324dc8196 100644 --- a/product/ERP5/tests/testAccounting.py +++ b/product/ERP5/tests/testAccounting.py @@ -36,7 +36,6 @@ from Products.ERP5Type.tests.utils import reindex from Products.DCWorkflow.DCWorkflow import ValidationFailed from AccessControl.SecurityManagement import newSecurityManager from Products.ERP5Type.tests.Sequence import Sequence, SequenceList -from Products.ERP5.Document.Delivery import Delivery from DateTime import DateTime SOURCE = 'source' @@ -169,7 +168,7 @@ class AccountingTestCase(ERP5TypeTestCase): self.person_module): for doc in module.objectValues(): doc.validate() - + # and the preference enabled preference = self.portal.portal_preferences.accounting_zuite_preference pref.manage_addLocalRoles(self.username, ('Auditor', )) @@ -223,7 +222,7 @@ class TestClosingPeriod(AccountingTestCase): def test_createBalanceOnNode(self): period = self.section.newContent(portal_type='Accounting Period') period.setStartDate(DateTime(2006, 1, 1)) - period.setStartDate(DateTime(2006, 12, 31)) + period.setStopDate(DateTime(2006, 12, 31)) transaction1 = self._makeOne( start_date=DateTime(2006, 1, 1), @@ -309,7 +308,7 @@ class TestClosingPeriod(AccountingTestCase): organisation_module = self.organisation_module period = self.section.newContent(portal_type='Accounting Period') period.setStartDate(DateTime(2006, 1, 1)) - period.setStartDate(DateTime(2006, 12, 31)) + period.setStopDate(DateTime(2006, 12, 31)) transaction1 = self._makeOne( start_date=DateTime(2006, 1, 1), @@ -403,7 +402,7 @@ class TestClosingPeriod(AccountingTestCase): organisation_module = self.organisation_module period = self.section.newContent(portal_type='Accounting Period') period.setStartDate(DateTime(2006, 1, 1)) - period.setStartDate(DateTime(2006, 12, 31)) + period.setStopDate(DateTime(2006, 12, 31)) bank1 = self.section.newContent( id='bank1', reference='bank1', @@ -529,7 +528,7 @@ class TestClosingPeriod(AccountingTestCase): organisation_module = self.organisation_module period = self.section.newContent(portal_type='Accounting Period') period.setStartDate(DateTime(2006, 1, 1)) - period.setStartDate(DateTime(2006, 12, 31)) + period.setStopDate(DateTime(2006, 12, 31)) transaction1 = self._makeOne( start_date=DateTime(2006, 1, 1), @@ -645,7 +644,7 @@ class TestClosingPeriod(AccountingTestCase): # open a period for our section period = self.section.newContent(portal_type='Accounting Period') period.setStartDate(DateTime(2006, 1, 1)) - period.setStartDate(DateTime(2006, 12, 31)) + period.setStopDate(DateTime(2006, 12, 31)) self.assertEquals('draft', period.getSimulationState()) self.portal.portal_workflow.doActionFor(period, 'start_action') self.assertEquals('started', period.getSimulationState()) @@ -702,7 +701,7 @@ class TestClosingPeriod(AccountingTestCase): """ period1 = self.section.newContent(portal_type='Accounting Period') period1.setStartDate(DateTime(2006, 1, 1)) - period1.setStartDate(DateTime(2006, 12, 31)) + period1.setStopDate(DateTime(2006, 12, 31)) period1.start() transaction1 = self._makeOne( @@ -733,17 +732,17 @@ class TestClosingPeriod(AccountingTestCase): period2 = self.section.newContent(portal_type='Accounting Period') period2.setStartDate(DateTime(2007, 1, 1)) - period2.setStartDate(DateTime(2007, 12, 31)) + period2.setStopDate(DateTime(2007, 12, 31)) period2.start() transaction2 = self._makeOne( start_date=DateTime(2007, 1, 2), portal_type='Accounting Transaction', simulation_state='delivered', - lines=(dict(destination_value=self.account_module.equity, - destination_debit=100), - dict(destination_value=pl_account, - destination_credit=100))) + lines=(dict(source_value=self.account_module.equity, + source_debit=100), + dict(source_value=pl_account, + source_credit=100))) transaction3 = self._makeOne( start_date=DateTime(2007, 1, 3), portal_type='Purchase Invoice Transaction', @@ -753,7 +752,7 @@ class TestClosingPeriod(AccountingTestCase): destination_debit=300), dict(destination_value=self.account_module.payable, destination_credit=300))) - period2.stop() + period2.AccountingPeriod_createBalanceTransaction( profit_and_loss_account=pl_account.getRelativeUrl()) balance_transaction_list = [tr for tr in @@ -798,7 +797,7 @@ class TestClosingPeriod(AccountingTestCase): """ period = self.section.newContent(portal_type='Accounting Period') period.setStartDate(DateTime(2006, 1, 1)) - period.setStartDate(DateTime(2006, 12, 31)) + period.setStopDate(DateTime(2006, 12, 31)) pl_account = self.portal.account_module.newContent( portal_type='Account', account_type='equity', @@ -824,6 +823,7 @@ class TestClosingPeriod(AccountingTestCase): portal_type='Balance Transaction') self.assertEquals(1, len(balance_transaction_list)) balance_transaction = balance_transaction_list[0] + balance_transaction.alternateReindexObject() movement_list = balance_transaction.getMovementList() self.assertEquals(2, len(movement_list)) @@ -838,6 +838,114 @@ class TestClosingPeriod(AccountingTestCase): self.assertEquals(500, stock_movement_list[0].getDestinationCredit()) + def test_InventoryIndexingNodeAndMirrorSection(self): + # Balance Transactions are indexed as Inventories. + transaction1 = self._makeOne( + start_date=DateTime(2006, 1, 1), + portal_type='Sale Invoice Transaction', + destination_section_value=self.organisation_module.client_1, + simulation_state='delivered', + lines=(dict(source_value=self.account_module.receivable, + source_debit=100), + dict(source_value=self.account_module.goods_sales, + source_credit=100))) + + balance = self.accounting_module.newContent( + portal_type='Balance Transaction', + destination_section_value=self.section, + start_date=DateTime(2006, 12, 31), + resource_value=self.currency_module.euro,) + balance.newContent( + portal_type='Balance Transaction Line', + destination_value=self.account_module.receivable, + source_section_value=self.organisation_module.client_1, + destination_debit=100,) + balance.newContent( + portal_type='Balance Transaction Line', + destination_value=self.account_module.stocks, + destination_credit=100,) + balance.stop() + balance.deliver() + balance.immediateReindexObject() + + # now check inventory + stool = self.getSimulationTool() + # the account 'receivable' has a balance of 100 + node_uid = self.account_module.receivable.getUid() + self.assertEquals(100, stool.getInventory( + section_uid=self.section.getUid(), + node_uid=node_uid)) + self.assertEquals(100, stool.getInventory( + section_uid=self.section.getUid(), + mirror_section_uid=self.organisation_module.client_1.getUid(), + node_uid=node_uid)) + self.assertEquals(100, stool.getInventoryAssetPrice( + section_uid=self.section.getUid(), + node_uid=node_uid)) + # and only one movement is returned by getMovementHistoryList + self.assertEquals(1, len(stool.getMovementHistoryList( + section_uid=self.section.getUid(), + node_uid=node_uid))) + + # the account 'goods_sales' has a balance of -100 + node_uid = self.account_module.goods_sales.getUid() + self.assertEquals(-100, stool.getInventory( + section_uid=self.section.getUid(), + node_uid=node_uid)) + + # the account 'stocks' has a balance of -100 + node_uid = self.account_module.stocks.getUid() + self.assertEquals(-100, stool.getInventory( + section_uid=self.section.getUid(), + node_uid=node_uid)) + + + def test_InventoryIndexingNodeDiffOnNode(self): + # Balance Transactions are indexed as Inventories. + transaction1 = self._makeOne( + start_date=DateTime(2006, 1, 1), + portal_type='Accounting Transaction', + simulation_state='delivered', + lines=(dict(source_value=self.account_module.receivable, + source_debit=100), + dict(source_value=self.account_module.stocks, + source_credit=100))) + + balance = self.accounting_module.newContent( + portal_type='Balance Transaction', + destination_section_value=self.section, + start_date=DateTime(2006, 12, 31), + resource_value=self.currency_module.euro,) + balance.newContent( + portal_type='Balance Transaction Line', + destination_value=self.account_module.receivable, + destination_debit=150,) + balance.newContent( + portal_type='Balance Transaction Line', + destination_value=self.account_module.stocks, + destination_credit=90,) + balance.stop() + get_transaction().commit() + self.tic() + + stool = self.portal.portal_simulation + # the account 'receivable' has a balance of 150 + node_uid = self.account_module.receivable.getUid() + self.assertEquals(150, stool.getInventory( + section_uid=self.section.getUid(), + node_uid=node_uid)) + # movement history list shows 2 movements, the initial with qty 100, and + # the balance with quantity 50 + + # the account 'stocks' has a balance of -100 + node_uid = self.account_module.stocks.getUid() + self.assertEquals(-90, stool.getInventory( + section_uid=self.section.getUid(), + node_uid=node_uid)) + + + + class TestAccounting(ERP5TypeTestCase): """The first test for Accounting """