diff --git a/CHANGELOG b/CHANGELOG
index 32d734b8ef..83ea1aa778 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,5 @@
+* Add group lines wizard
+* Manage delegate reconciliation
* Remove starting wildcard when searching on codes and numbers
* Remove display balance
* Merge account kind and account type
diff --git a/__init__.py b/__init__.py
index f145c731eb..94478d9355 100644
--- a/__init__.py
+++ b/__init__.py
@@ -55,6 +55,7 @@ def register():
ReconcileLinesWriteOff,
ReconcileShow,
CancelMovesDefault,
+ GroupLinesStart,
PrintGeneralJournalStart,
tax.TaxGroup,
tax.TaxCodeTemplate,
@@ -93,6 +94,7 @@ def register():
UnreconcileLines,
Reconcile,
CancelMoves,
+ GroupLines,
PrintGeneralJournal,
CreateMove,
tax.OpenChartTaxCode,
diff --git a/doc/index.rst b/doc/index.rst
index 013aad8a26..556d199044 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -177,6 +177,9 @@ possible. The configuration `reconciliation_chunk` in `account` section allow
to define the length of lines that is allowed to search for proposal. The
default is 10.
+The *Group Lines* wizard allow to group payable and/or receivable lines in
+order to have a unique payable or receivable line.
+
Tax Code
********
diff --git a/exceptions.py b/exceptions.py
index 62bbaa5064..437690a9c2 100644
--- a/exceptions.py
+++ b/exceptions.py
@@ -75,3 +75,11 @@ class CancelWarning(UserWarning):
class ReconciliationError(ValidationError):
pass
+
+
+class DeleteDelegatedWarning(UserWarning):
+ pass
+
+
+class GroupLineError(UserError):
+ pass
diff --git a/message.xml b/message.xml
index 4a145e45a4..0a64f23a79 100644
--- a/message.xml
+++ b/message.xml
@@ -124,6 +124,10 @@ Use the current period to cancel the move?
You cannot modify any reconciliation.
+
+ The reconciliation "%(reconciliation)s" is delegated to the line "%(line)s".
+You may need to cancel its move "%(move)s".
+
You cannot reconcile non-valid line "%(line)s".
@@ -167,5 +171,24 @@ Use the current period to cancel the move?
To create a line on account "%(account)s", you must remove the party on line "%(line)s".
+
+
+ Line "%(line)s" can not be grouped.
+
+
+ You cannot group a single line.
+
+
+ You cannot group lines with different parties.
+
+
+ You cannot group lines of different companies.
+
+
+ You cannot group lines of different second currencies.
+
+
+ You cannot group lines from more than 2 different accounts.
+
diff --git a/move.py b/move.py
index 1eb8a8209e..905ce44940 100644
--- a/move.py
+++ b/move.py
@@ -2,7 +2,7 @@
# this repository contains the full copyright notices and license terms.
from decimal import Decimal
import datetime
-from itertools import groupby, combinations
+from itertools import groupby, combinations, chain
from operator import itemgetter
from collections import defaultdict
@@ -26,7 +26,7 @@ from trytond.tools import reduce_ids, grouped_slice
from trytond.config import config
from .exceptions import (PostError, MoveDatesError, CancelWarning,
- ReconciliationError)
+ ReconciliationError, DeleteDelegatedWarning, GroupLineError)
__all__ = ['Move', 'Reconciliation', 'Line', 'WriteOff', 'OpenJournalAsk',
'OpenJournal', 'OpenAccount',
@@ -34,6 +34,7 @@ __all__ = ['Move', 'Reconciliation', 'Line', 'WriteOff', 'OpenJournalAsk',
'UnreconcileLines',
'Reconcile', 'ReconcileShow',
'CancelMoves', 'CancelMovesDefault',
+ 'GroupLines', 'GroupLinesStart',
'PrintGeneralJournalStart', 'PrintGeneralJournal', 'GeneralJournal']
_MOVE_STATES = {
@@ -430,6 +431,9 @@ class Reconciliation(ModelSQL, ModelView):
'Lines')
date = fields.Date('Date', required=True, select=True,
help='Highest date of the reconciled lines')
+ delegate_to = fields.Many2One(
+ 'account.move.line', "Delegate To", ondelete="RESTRICT", select=True,
+ help="The line to which the reconciliation status is delegated.")
@classmethod
def __register__(cls, module_name):
@@ -479,6 +483,21 @@ class Reconciliation(ModelSQL, ModelView):
super(Reconciliation, cls).validate(reconciliations)
cls.check_lines(reconciliations)
+ @classmethod
+ def delete(cls, reconciliations):
+ pool = Pool()
+ Warning = pool.get('res.user.warning')
+ for reconciliation in reconciliations:
+ if reconciliation.delegate_to:
+ key = '%s.delete.delegated' % reconciliation
+ if Warning.check(key):
+ raise DeleteDelegatedWarning(key,
+ gettext('account.msg_reconciliation_delete_delegated',
+ reconciliation=reconciliation.rec_name,
+ line=reconciliation.delegate_to.rec_name,
+ move=reconciliation.delegate_to.move.rec_name))
+ super().delete(reconciliations)
+
@classmethod
def check_lines(cls, reconciliations):
Lang = Pool().get('ir.lang')
@@ -631,6 +650,9 @@ class Line(ModelSQL, ModelView):
], 'State', readonly=True, required=True, select=True)
reconciliation = fields.Many2One('account.move.reconciliation',
'Reconciliation', readonly=True, ondelete='SET NULL', select=True)
+ reconciliations_delegated = fields.One2Many(
+ 'account.move.reconciliation', 'delegate_to',
+ "Reconciliations Delegated", readonly=True)
tax_lines = fields.One2Many('account.tax.line', 'move_line', 'Tax Lines')
move_state = fields.Function(fields.Selection([
('draft', 'Draft'),
@@ -1158,13 +1180,15 @@ class Line(ModelSQL, ModelView):
@classmethod
def reconcile(
- cls, *lines_list, date=None, writeoff=None, description=None):
+ cls, *lines_list, date=None, writeoff=None, description=None,
+ delegate_to=None):
"""
Reconcile each list of lines together.
The writeoff keys are: date, method and description.
"""
pool = Pool()
Reconciliation = pool.get('account.move.reconciliation')
+ delegate_to = delegate_to.id if delegate_to else None
reconciliations = []
for lines in lines_list:
@@ -1200,6 +1224,7 @@ class Line(ModelSQL, ModelView):
reconciliations.append({
'lines': [('add', [x.id for x in lines])],
'date': max(l.date for l in lines),
+ 'delegate_to': delegate_to,
})
return Reconciliation.create(reconciliations)
@@ -1782,6 +1807,173 @@ class CancelMovesDefault(ModelView):
description = fields.Char('Description')
+class GroupLines(Wizard):
+ "Group Lines"
+ __name__ = 'account.move.line.group'
+ start = StateView('account.move.line.group.start',
+ 'account.move_line_group_start_view_form', [
+ Button("Cancel", 'end', 'tryton-cancel'),
+ Button("Group", 'group', 'tryton-ok', default=True),
+ ])
+ group = StateTransition()
+
+ def transition_group(self):
+ pool = Pool()
+ Line = pool.get('account.move.line')
+
+ lines = Line.browse(Transaction().context['active_ids'])
+ grouping = self.grouping(lines)
+
+ move, balance_line = self.get_move(lines, grouping)
+ move.save()
+
+ to_reconcile = defaultdict(list)
+ for line in chain(lines, move.lines):
+ if line.account.reconcile:
+ to_reconcile[line.account].append(line)
+
+ balance_line.move = move
+ balance_line.save()
+
+ for lines in to_reconcile.values():
+ Line.reconcile(lines, delegate_to=balance_line)
+
+ return 'end'
+
+ @classmethod
+ def grouping(cls, lines):
+ if len(lines) == 1:
+ raise GroupLineError(gettext('account.msg_group_line_single'))
+ companies = set()
+ parties = set()
+ accounts = set()
+ second_currencies = set()
+ for line in lines:
+ if not cls.allow_grouping(line.account):
+ raise GroupLineError(gettext('account.msg_group_line'))
+ companies.add(line.move.company)
+ parties.add(line.party)
+ accounts.add(line.account)
+ second_currencies.add(line.second_currency)
+ try:
+ company, = companies
+ except ValueError:
+ raise GroupLineError(
+ gettext('account.msg_group_line_same_company'))
+ try:
+ party, = parties
+ except ValueError:
+ raise GroupLineError(
+ gettext('account.msg_group_line_many_parties'))
+ try:
+ second_currency, = second_currencies
+ except ValueError:
+ raise GroupLineError(
+ gettext('account.msg_group_line_same_second_currency'))
+ if len(accounts) > 2:
+ raise GroupLineError(
+ gettext('account.msg_group_line_maximum_account'))
+ return {
+ 'company': company,
+ 'party': party,
+ 'second_currency': second_currency,
+ 'accounts': accounts,
+ }
+
+ @classmethod
+ def allow_grouping(cls, account):
+ return account.type.payable or account.type.receivable
+
+ def get_move(self, lines, grouping, date=None):
+ pool = Pool()
+ Date = pool.get('ir.date')
+ Move = pool.get('account.move')
+ Period = pool.get('account.period')
+ Line = pool.get('account.move.line')
+
+ if not date:
+ date = Date.today()
+ period = Period.find(grouping['company'].id, date=date)
+
+ move = Move()
+ move.date = date
+ move.period = period
+ move.journal = self.start.journal
+ move.description = self.start.description
+
+ accounts = {a: 0 for a in grouping['accounts']}
+ amount_second_currency = 0
+ maturity_dates = {a: None for a in grouping['accounts']}
+
+ counterpart_lines = []
+ for line in lines:
+ if maturity_dates[line.account]:
+ maturity_dates[line.account] = min(
+ maturity_dates[line.account], line.maturity_date)
+ else:
+ maturity_dates[line.account] = line.maturity_date
+ line = self._counterpart_line(line)
+ accounts[line.account] += line.debit - line.credit
+ if line.amount_second_currency:
+ amount_second_currency += line.amount_second_currency
+ counterpart_lines.append(line)
+ move.lines = counterpart_lines
+
+ balance_line = None
+ if len(accounts) == 1:
+ account, = grouping['accounts']
+ amount = -accounts[account]
+ if amount:
+ balance_line = Line()
+ balance_line.account = account
+ else:
+ first, second = grouping['accounts']
+ if accounts[first] != accounts[second]:
+ balance_line = Line()
+ amount = -(accounts[first] + accounts[second])
+ if abs(accounts[first]) > abs(accounts[second]):
+ balance_line.account = first
+ else:
+ balance_line.account = second
+ if balance_line:
+ if balance_line.account.party_required:
+ balance_line.party = grouping['party']
+ if amount > 0:
+ balance_line.debit, balance_line.credit = amount, 0
+ else:
+ balance_line.debit, balance_line.credit = 0, -amount
+ if grouping['second_currency']:
+ balance_line.second_currency = grouping['second_currency']
+ balance_line.amount_second_currency = (
+ -amount_second_currency)
+ balance_line.maturity_date = maturity_dates[balance_line.account]
+ return move, balance_line
+
+ def _counterpart_line(self, line):
+ pool = Pool()
+ Line = pool.get('account.move.line')
+
+ counterpart = Line()
+ counterpart.account = line.account
+ counterpart.debit = line.credit
+ counterpart.credit = line.debit
+ counterpart.party = line.party
+ counterpart.second_currency = line.second_currency
+ if line.second_currency:
+ counterpart.amount_second_currency = -line.amount_second_currency
+ else:
+ counterpart.amount_second_currency = None
+ return counterpart
+
+
+class GroupLinesStart(ModelView):
+ "Group Lines"
+ __name__ = 'account.move.line.group.start'
+
+ journal = fields.Many2One('account.journal', "Journal", required=True)
+ description = fields.Char("Description")
+
+
class PrintGeneralJournalStart(ModelView):
'Print General Journal'
__name__ = 'account.move.print_general_journal.start'
diff --git a/move.xml b/move.xml
index 647231f3be..ba08e6a27a 100644
--- a/move.xml
+++ b/move.xml
@@ -392,6 +392,22 @@ this repository contains the full copyright notices and license terms. -->
+
+ Group Lines
+ account.move.line.group
+
+
+ form_action
+ account.move.line,-1
+
+
+
+
+ account.move.line.group.start
+ form
+ move_line_group_start_form
+
+
account.move.print_general_journal.start
form
diff --git a/tests/scenario_move_line_group.rst b/tests/scenario_move_line_group.rst
new file mode 100644
index 0000000000..e8e00ced1c
--- /dev/null
+++ b/tests/scenario_move_line_group.rst
@@ -0,0 +1,131 @@
+====================
+Group Lines Scenario
+====================
+
+Imports::
+
+ >>> import datetime as dt
+ >>> from decimal import Decimal
+ >>> from proteus import Model, Wizard
+ >>> from trytond.tests.tools import activate_modules
+ >>> from trytond.modules.currency.tests.tools import get_currency
+ >>> from trytond.modules.company.tests.tools import create_company, \
+ ... get_company
+ >>> from trytond.modules.account.tests.tools import create_fiscalyear, \
+ ... create_chart, get_accounts
+
+Install account::
+
+ >>> config = activate_modules('account')
+
+Create company::
+
+ >>> usd = get_currency('USD')
+ >>> eur = get_currency('EUR')
+ >>> _ = create_company(currency=usd)
+ >>> company = get_company()
+
+Create fiscal year::
+
+ >>> fiscalyear = create_fiscalyear(company)
+ >>> fiscalyear.click('create_period')
+ >>> period = fiscalyear.periods[0]
+
+Create chart of accounts::
+
+ >>> _ = create_chart(company)
+ >>> accounts = get_accounts(company)
+ >>> payable = accounts['payable']
+ >>> receivable = accounts['receivable']
+ >>> revenue = accounts['revenue']
+ >>> expense = accounts['expense']
+
+Create parties::
+
+ >>> Party = Model.get('party.party')
+ >>> party = Party(name="Party")
+ >>> party.save()
+
+Create Lines to group::
+
+ >>> Journal = Model.get('account.journal')
+ >>> Move = Model.get('account.move')
+ >>> journal_revenue, = Journal.find([
+ ... ('code', '=', 'REV'),
+ ... ])
+ >>> journal_expense, = Journal.find([
+ ... ('code', '=', 'EXP'),
+ ... ])
+
+ >>> move = Move()
+ >>> move.journal = journal_revenue
+ >>> move.date = period.start_date
+ >>> line = move.lines.new()
+ >>> line.account = revenue
+ >>> line.credit = Decimal(100)
+ >>> line = move.lines.new()
+ >>> line.account = receivable
+ >>> line.party = party
+ >>> line.debit = Decimal(100)
+ >>> line.second_currency = eur
+ >>> line.amount_second_currency = Decimal(90)
+ >>> line.maturity_date = period.start_date + dt.timedelta(days=2)
+ >>> move.save()
+
+ >>> move = Move()
+ >>> move.journal = journal_expense
+ >>> move.date = period.start_date
+ >>> line = move.lines.new()
+ >>> line.account = expense
+ >>> line.debit = Decimal(50)
+ >>> line = move.lines.new()
+ >>> line.account = payable
+ >>> line.party = party
+ >>> line.credit = Decimal(50)
+ >>> line.second_currency = eur
+ >>> line.amount_second_currency = Decimal(45)
+ >>> line.maturity_date = period.start_date + dt.timedelta(days=4)
+ >>> move.save()
+
+ >>> receivable.reload()
+ >>> receivable.balance, receivable.amount_second_currency
+ (Decimal('100.00'), Decimal('90.00'))
+ >>> payable.reload()
+ >>> payable.balance, payable.amount_second_currency
+ (Decimal('-50.00'), Decimal('-45.00'))
+
+Group lines::
+
+ >>> journal_cash, = Journal.find([
+ ... ('code', '=', 'CASH'),
+ ... ])
+
+ >>> Line = Model.get('account.move.line')
+ >>> lines = Line.find([('account', 'in', [payable.id, receivable.id])])
+ >>> len(lines)
+ 2
+ >>> group = Wizard('account.move.line.group', lines)
+ >>> group.form.journal = journal_cash
+ >>> group.form.description = "Group lines"
+ >>> group.execute('group')
+
+ >>> receivable.reload()
+ >>> receivable.balance, receivable.amount_second_currency
+ (Decimal('50.00'), Decimal('45.00'))
+ >>> payable.reload()
+ >>> payable.balance, payable.amount_second_currency
+ (Decimal('0.00'), Decimal('0.00'))
+
+ >>> Reconciliation = Model.get('account.move.reconciliation')
+ >>> reconciliations = Reconciliation.find([])
+ >>> len(reconciliations)
+ 2
+ >>> all(r.delegate_to for r in reconciliations)
+ True
+ >>> delegate_to = reconciliations[0].delegate_to
+ >>> delegate_to.account == receivable
+ True
+ >>> delegate_to.debit
+ Decimal('50')
+ >>> delegate_to.maturity_date == period.start_date + dt.timedelta(days=2)
+ True
diff --git a/tests/test_account.py b/tests/test_account.py
index 60cc5847bb..a4aea87c31 100644
--- a/tests/test_account.py
+++ b/tests/test_account.py
@@ -1321,6 +1321,11 @@ def suite():
tearDown=doctest_teardown, encoding='utf-8',
checker=doctest_checker,
optionflags=doctest.REPORT_ONLY_FIRST_FAILURE))
+ suite.addTests(doctest.DocFileSuite(
+ 'scenario_move_line_group.rst',
+ tearDown=doctest_teardown, encoding='utf-8',
+ checker=doctest_checker,
+ optionflags=doctest.REPORT_ONLY_FIRST_FAILURE))
suite.addTests(doctest.DocFileSuite(
'scenario_move_template.rst',
tearDown=doctest_teardown, encoding='utf-8',
diff --git a/view/move_line_group_start_form.xml b/view/move_line_group_start_form.xml
new file mode 100644
index 0000000000..319b18e0e9
--- /dev/null
+++ b/view/move_line_group_start_form.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/view/move_reconciliation_form.xml b/view/move_reconciliation_form.xml
index cc7b17c784..1313108911 100644
--- a/view/move_reconciliation_form.xml
+++ b/view/move_reconciliation_form.xml
@@ -1,8 +1,12 @@
-
diff --git a/view/move_reconciliation_tree.xml b/view/move_reconciliation_tree.xml
index c4a7efd3e8..233621c708 100644
--- a/view/move_reconciliation_tree.xml
+++ b/view/move_reconciliation_tree.xml
@@ -3,4 +3,6 @@
this repository contains the full copyright notices and license terms. -->
+
+