diff --git a/CHANGELOG b/CHANGELOG index 74e88a368c..61994cb2b3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Add wizard to reschedule lines to pay of invoices * Create base tax line for manual taxes Version 6.0.0 - 2021-05-03 diff --git a/__init__.py b/__init__.py index f0d87444a0..1ee5246d01 100644 --- a/__init__.py +++ b/__init__.py @@ -17,6 +17,7 @@ def register(): payment_term.TestPaymentTermView, payment_term.TestPaymentTermViewResult, invoice.Invoice, + invoice.InvoiceAdditionalMove, invoice.InvoicePaymentLine, invoice.InvoiceLine, invoice.InvoiceLineTax, @@ -45,9 +46,11 @@ def register(): payment_term.TestPaymentTerm, invoice.PayInvoice, invoice.CreditInvoice, + invoice.RescheduleLinesToPay, party.Replace, party.Erase, account.RenewFiscalYear, + account.SplitLines, module='account_invoice', type_='wizard') Pool.register( invoice.InvoiceReport, diff --git a/account.py b/account.py index 91ef6e3779..238a6e9ab4 100644 --- a/account.py +++ b/account.py @@ -311,8 +311,10 @@ def _invoices_to_process(reconciliations): invoices = set() for sub_ids in grouped_slice(move_ids): - invoices.update(Invoice.search([ - ('move', 'in', list(sub_ids)), + sub_ids = list(sub_ids) + invoices.update(Invoice.search(['OR', + ('move', 'in', sub_ids), + ('additional_moves', 'in', sub_ids), ])) if others: invoices.update(_invoices_to_process(Reconciliation.browse(others))) @@ -404,3 +406,23 @@ class RenewFiscalYear(metaclass=PoolMeta): if to_write: InvoiceSequence.write(*to_write) return fiscalyear + + +class SplitLines(metaclass=PoolMeta): + __name__ = 'account.move.line.split' + + @classmethod + def split_lines(cls, lines, journal, terms): + pool = Pool() + Invoice = pool.get('account.invoice') + move, balance_line = super().split_lines(lines, journal, terms) + + move_ids = list({l.move.id for l in lines}) + invoices = Invoice.search(['OR', + ('move', 'in', move_ids), + ('additional_moves', 'in', move_ids), + ]) + Invoice.write(invoices, { + 'additional_moves': [('add', [move.id])], + }) + return move, balance_line diff --git a/doc/design.rst b/doc/design.rst index bf096e6791..28befcc89e 100644 --- a/doc/design.rst +++ b/doc/design.rst @@ -102,6 +102,15 @@ credited with a refund. When this is done the credit note is automatically posted and the invoice is cancelled. +.. _wizard-account.invoice.lines_to_pay.reschedule: + +Reschedule Lines to Pay +^^^^^^^^^^^^^^^^^^^^^^^ + +The *Reschedule Lines to Pay* wizard allows to modify the payment terms of the +remaining lines to pay using the `Split Lines ` +wizard. + Reports ------- diff --git a/invoice.py b/invoice.py index 26d880b610..72af61ec4b 100644 --- a/invoice.py +++ b/invoice.py @@ -2,7 +2,7 @@ # this repository contains the full copyright notices and license terms. from decimal import Decimal from collections import defaultdict, namedtuple -from itertools import combinations +from itertools import combinations, chain from sql import Null from sql.aggregate import Sum @@ -144,6 +144,16 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): ('company', '=', Eval('company', -1)), ], depends=['company']) + additional_moves = fields.Many2Many( + 'account.invoice-additional-account.move', 'invoice', 'move', + "Additional Moves", readonly=True, + domain=[ + ('company', '=', Eval('company', -1)), + ], + states={ + 'invisible': ~Eval('additional_moves'), + }, + depends=['company']) cancel_move = fields.Many2One('account.move', 'Cancel Move', readonly=True, domain=[ ('company', '=', Eval('company', -1)), @@ -259,7 +269,8 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): super(Invoice, cls).__setup__() cls._check_modify_exclude = { 'state', 'payment_lines', 'move', 'cancel_move', - 'invoice_report_cache', 'invoice_report_format', 'lines'} + 'additional_moves', 'invoice_report_cache', + 'invoice_report_format', 'lines'} cls._order = [ ('number', 'DESC NULLS FIRST'), ('id', 'DESC'), @@ -325,6 +336,11 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): 'invisible': Eval('state') != 'posted', 'depends': ['state'], }, + 'reschedule_lines_to_pay': { + 'invisible': ( + ~Eval('lines_to_pay') | Eval('reconciled', False)), + 'depends': ['lines_to_pay', 'reconciled'], + }, 'process': { 'invisible': ~Eval('state').in_( ['posted', 'paid']), @@ -696,19 +712,38 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): def get_lines_to_pay(cls, invoices, name): pool = Pool() MoveLine = pool.get('account.move.line') + AdditionalMove = pool.get('account.invoice-additional-account.move') line = MoveLine.__table__() invoice = cls.__table__() + additional_move = AdditionalMove.__table__() cursor = Transaction().connection.cursor() lines = defaultdict(list) for sub_ids in grouped_slice(invoices): red_sql = reduce_ids(invoice.id, sub_ids) - cursor.execute(*invoice.join(line, - condition=((invoice.move == line.move) - & (invoice.account == line.account))).select( - invoice.id, line.id, - where=red_sql, - order_by=(invoice.id, line.maturity_date.nulls_last))) + query = (invoice + .join(line, + condition=((invoice.move == line.move) + & (invoice.account == line.account))) + .select( + invoice.id.as_('invoice'), + line.id.as_('line'), + line.maturity_date.as_('maturity_date'), + where=red_sql)) + query |= (invoice + .join(additional_move, + condition=additional_move.invoice == invoice.id) + .join(line, + condition=((additional_move.move == line.move) + & (invoice.account == line.account))) + .select( + invoice.id.as_('invoice'), + line.id.as_('line'), + line.maturity_date.as_('maturity_date'), + where=red_sql)) + cursor.execute(*query.select( + query.invoice, query.line, + order_by=query.maturity_date.nulls_last)) for invoice_id, line_id in cursor: lines[invoice_id].append(line_id) return lines @@ -717,11 +752,12 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): if self.state != 'paid': return lines = set() - for line in self.move.lines: - if line.account == self.account and line.reconciliation: - for line in line.reconciliation.lines: - if line not in self.move.lines: - lines.add(line) + for move in chain([self.move], self.additional_moves): + for line in move.lines: + if line.account == self.account and line.reconciliation: + for line in line.reconciliation.lines: + if line not in self.lines_to_pay: + lines.add(line) return [l.id for l in sorted(lines, key=lambda l: l.date)] @classmethod @@ -1239,6 +1275,7 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): default.setdefault('number', None) default.setdefault('sequence') default.setdefault('move', None) + default.setdefault('additional_moves', None) default.setdefault('cancel_move', None) default.setdefault('invoice_report_cache', None) default.setdefault('invoice_report_cache_id', None) @@ -1465,6 +1502,8 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): for invoice in invoices: if invoice.move: moves.append(invoice.move) + if invoice.additional_moves: + moves.extend(invoice.additional_moves) if moves: Move.delete(moves) @@ -1539,6 +1578,12 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): def pay(cls, invoices): pass + @classmethod + @ModelView.button_action( + 'account_invoice.act_reschedule_lines_to_pay_wizard') + def reschedule_lines_to_pay(cls, invoices): + pass + @classmethod @ModelView.button def process(cls, invoices): @@ -1575,6 +1620,7 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): if invoice.move or invoice.number: if invoice.move.state == 'draft': delete_moves.append(invoice.move) + delete_moves.extend(invoice.additional_moves) elif not invoice.cancel_move: if (invoice.type == 'out' and not invoice.company.cancel_invoice_out): @@ -1583,8 +1629,12 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): '.msg_invoice_customer_cancel_move', invoice=invoice.rec_name)) invoice.cancel_move = invoice.move.cancel() + additional_cancel_moves = [ + m.cancel() for m in invoice.additional_moves] + invoice.additional_moves += tuple(additional_cancel_moves) to_save.append(invoice) cancel_moves.append(invoice.cancel_move) + cancel_moves.extend(additional_cancel_moves) if cancel_moves: Move.save(cancel_moves) cls.save(to_save) @@ -1601,9 +1651,13 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): if not invoice.move or not invoice.cancel_move: continue to_reconcile = [] - for line in invoice.move.lines + invoice.cancel_move.lines: - if line.account == invoice.account: - to_reconcile.append(line) + for move in chain( + [invoice.move, invoice.cancel_move], + invoice.additional_moves): + for line in move.lines: + if (not line.reconciliation + and line.account == invoice.account): + to_reconcile.append(line) Line.reconcile(to_reconcile) cls._clean_payments(invoices) @@ -1626,6 +1680,16 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): cls.write(*to_write) +class InvoiceAdditionalMove(ModelSQL): + "Invoice Additional Move" + __name__ = 'account.invoice-additional-account.move' + invoice = fields.Many2One( + 'account.invoice', "Invoice", ondelete='CASCADE', + select=True, required=True) + move = fields.Many2One( + 'account.move', "Additional Move", ondelete='CASCADE') + + class InvoicePaymentLine(ModelSQL): 'Invoice - Payment Line' __name__ = 'account.invoice-account.move.line' @@ -3141,3 +3205,17 @@ class CreditInvoice(Wizard): if len(credit_invoices) == 1: action['views'].reverse() return action, data + + +class RescheduleLinesToPay(Wizard): + "Reschedule Lines to Pay" + __name__ = 'account.invoice.lines_to_pay.reschedule' + start = StateAction('account.act_split_lines_wizard') + + def do_start(self, action): + return action, { + 'ids': [ + l.id for l in self.record.lines_to_pay + if not l.reconciliation], + 'model': 'account.move.line', + } diff --git a/invoice.xml b/invoice.xml index e29ab166d1..c0337a7d63 100644 --- a/invoice.xml +++ b/invoice.xml @@ -214,6 +214,16 @@ this repository contains the full copyright notices and license terms. --> + + reschedule_lines_to_pay + Reschedule + + + + + + process Process @@ -415,5 +425,11 @@ this repository contains the full copyright notices and license terms. --> pyson="1"/> + + + Reschedule Lines to Pay + account.invoice.lines_to_pay.reschedule + account.invoice + diff --git a/tests/scenario_invoice_split_lines.rst b/tests/scenario_invoice_split_lines.rst new file mode 100644 index 0000000000..b758d83648 --- /dev/null +++ b/tests/scenario_invoice_split_lines.rst @@ -0,0 +1,109 @@ +============================ +Invoice Split Lines Scenario +============================ + +Imports:: + + >>> from decimal import Decimal + >>> from proteus import Model, Wizard + >>> from trytond.tests.tools import activate_modules + >>> from trytond.modules.company.tests.tools import ( + ... create_company, get_company) + >>> from trytond.modules.account.tests.tools import ( + ... create_fiscalyear, create_chart, get_accounts) + >>> from trytond.modules.account_invoice.tests.tools import ( + ... set_fiscalyear_invoice_sequences) + +Activate modules:: + + >>> config = activate_modules('account_invoice') + + >>> Invoice = Model.get('account.invoice') + >>> Journal = Model.get('account.journal') + >>> PaymentMethod = Model.get('account.invoice.payment.method') + +Create company:: + + >>> _ = create_company() + >>> company = get_company() + +Create fiscal year:: + + >>> fiscalyear = set_fiscalyear_invoice_sequences( + ... create_fiscalyear(company)) + >>> fiscalyear.click('create_period') + >>> period = fiscalyear.periods[0] + +Create chart of accounts:: + + >>> _ = create_chart(company) + >>> accounts = get_accounts(company) + + >>> journal_cash, = Journal.find([ + ... ('code', '=', 'CASH'), + ... ]) + + >>> payment_method = PaymentMethod() + >>> payment_method.name = "Cash" + >>> payment_method.journal = journal_cash + >>> payment_method.credit_account = accounts['cash'] + >>> payment_method.debit_account = accounts['cash'] + >>> payment_method.save() + +Create party:: + + >>> Party = Model.get('party.party') + >>> party = Party(name='Party') + >>> party.save() + +Post customer invoice:: + + >>> invoice = Invoice() + >>> invoice.party = party + >>> line = invoice.lines.new() + >>> line.account = accounts['revenue'] + >>> line.quantity = 1 + >>> line.unit_price = Decimal(10) + >>> invoice.click('post') + >>> invoice.state + 'posted' + >>> len(invoice.lines_to_pay) + 1 + >>> invoice.amount_to_pay + Decimal('10.00') + +Reschedule line:: + + >>> reschedule = Wizard('account.invoice.lines_to_pay.reschedule', [invoice]) + >>> split_lines, = reschedule.actions + >>> split_lines.form.total_amount + Decimal('10.00') + >>> split_lines.form.start_date = period.end_date + >>> split_lines.form.frequency ='monthly' + >>> split_lines.form.number = 2 + >>> split_lines.execute('preview') + >>> split_lines.execute('split') + + >>> invoice.reload() + >>> invoice.state + 'posted' + >>> len(invoice.lines_to_pay) + 4 + >>> len([l for l in invoice.lines_to_pay if not l.reconciliation]) + 2 + >>> invoice.amount_to_pay + Decimal('10.00') + +Pay the invoice:: + + >>> pay = Wizard('account.invoice.pay', [invoice]) + >>> pay.form.amount + Decimal('10.00') + >>> pay.form.payment_method = payment_method + >>> pay.execute('choice') + >>> pay.state + 'end' + >>> invoice.state + 'paid' + >>> len(invoice.reconciliation_lines) + 1 diff --git a/tests/test_account_invoice.py b/tests/test_account_invoice.py index 0f493e0ff3..549effc80f 100644 --- a/tests/test_account_invoice.py +++ b/tests/test_account_invoice.py @@ -310,6 +310,11 @@ def suite(): tearDown=doctest_teardown, encoding='utf-8', checker=doctest_checker, optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) + suite.addTests(doctest.DocFileSuite( + 'scenario_invoice_split_lines.rst', + tearDown=doctest_teardown, encoding='utf-8', + checker=doctest_checker, + optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) suite.addTests(doctest.DocFileSuite( 'scenario_renew_fiscalyear.rst', tearDown=doctest_teardown, encoding='utf-8', diff --git a/view/invoice_form.xml b/view/invoice_form.xml index 48018f6f60..414e7d4f72 100644 --- a/view/invoice_form.xml +++ b/view/invoice_form.xml @@ -62,6 +62,7 @@ this repository contains the full copyright notices and license terms. -->