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 @@ + + +
+