From f25ede851ec1bc8bab83e96f09664187cb22ed5b Mon Sep 17 00:00:00 2001 From: "Thomas Becquevort (thbe)" Date: Mon, 25 Sep 2023 13:33:38 +0200 Subject: [PATCH] [IMP] account: easier payment registration for bills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Description of the issue/feature this commit addresses: Currently, the payment registration for vendor bills is kinda hard to figure. Noticing which bills are overdue, which require a payment is not made so easy by the interface and could be improved. Also there is no way to register a payment for multiple lines at a time which could improve the UX. Desired behavior after the commit is merged : The buttons "Bills to Pay" and "Late Bills" redirect to a Journal Items view called Payment Items with specific filters to show only the necessary lines. The order of the columns in the Journal Items has been changed and their visibility has been changed too. The default filters of the Journal Items when coming from the dashboard buttons has been changed. Payable has been replaced by Purchases. Filters, Group by and Search by options have been added for the Due Date and the Discount Date fields in the Journal Items. task-3516496 closes odoo/odoo#138188 Related: odoo/enterprise#48679 Signed-off-by: William André (wan) --- addons/account/__manifest__.py | 1 + .../models/account_journal_dashboard.py | 68 ++++++++++-- addons/account/models/account_move.py | 16 +-- addons/account/models/account_move_line.py | 52 ++++++++- .../src/js/search/search_bar/search_bar.js | 15 +++ addons/account/tests/__init__.py | 1 + .../tests/test_account_journal_dashboard.py | 87 +++++++++++++++ .../tests/test_account_payment_items.py | 84 +++++++++++++++ .../views/account_journal_dashboard_view.xml | 14 +-- addons/account/views/account_menuitem.xml | 1 + addons/account/views/account_move_views.xml | 100 ++++++++++++++++++ .../src/search/search_bar/search_bar.js | 22 ++-- 12 files changed, 424 insertions(+), 37 deletions(-) create mode 100644 addons/account/static/src/js/search/search_bar/search_bar.js create mode 100644 addons/account/tests/test_account_payment_items.py diff --git a/addons/account/__manifest__.py b/addons/account/__manifest__.py index d6235979e7c..20085f21c18 100644 --- a/addons/account/__manifest__.py +++ b/addons/account/__manifest__.py @@ -92,6 +92,7 @@ You could use this simplified accounting in case you work with an (external) acc 'account/static/src/components/**/*', 'account/static/src/js/tours/account.js', 'account/static/src/views/**/*.js', + 'account/static/src/js/search/search_bar/search_bar.js', ], 'web.assets_frontend': [ 'account/static/src/js/account_portal_sidebar.js', diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py index 62f66cc73ae..158563b7ae6 100644 --- a/addons/account/models/account_journal_dashboard.py +++ b/addons/account/models/account_journal_dashboard.py @@ -426,9 +426,11 @@ class account_journal(models.Model): def _fill_sale_purchase_dashboard_data(self, dashboard_data): """Populate all sale and purchase journal's data dict with relevant information for the kanban card.""" sale_purchase_journals = self.filtered(lambda journal: journal.type in ('sale', 'purchase')) + purchase_journals = self.filtered(lambda journal: journal.type == 'purchase') + sale_journals = self.filtered(lambda journal: journal.type == 'sale') if not sale_purchase_journals: return - field_list = [ + bills_field_list = [ "account_move.journal_id", "(CASE WHEN account_move.move_type IN ('out_refund', 'in_refund') THEN -1 ELSE 1 END) * account_move.amount_residual AS amount_total", "(CASE WHEN account_move.move_type IN ('in_invoice', 'in_refund', 'in_receipt') THEN -1 ELSE 1 END) * account_move.amount_residual_signed AS amount_total_company", @@ -437,17 +439,45 @@ class account_journal(models.Model): "account_move.invoice_date", "account_move.company_id", ] - query, params = sale_purchase_journals._get_open_bills_to_pay_query().select(*field_list) - self.env.cr.execute(query, params) - query_results_to_pay = group_by_journal(self.env.cr.dictfetchall()) - - query, params = sale_purchase_journals._get_draft_bills_query().select(*field_list) + payment_field_list = [ + "account_move_line.journal_id", + "account_move_line.move_id", + "-account_move_line.amount_residual AS amount_total_company", + ] + # DRAFTS + query, params = sale_purchase_journals._get_draft_bills_query().select(*bills_field_list) self.env.cr.execute(query, params) query_results_drafts = group_by_journal(self.env.cr.dictfetchall()) - query, params = sale_purchase_journals._get_late_bills_query().select(*field_list) - self.env.cr.execute(query, params) - late_query_results = group_by_journal(self.env.cr.dictfetchall()) + # WAITING BILLS AND PAYMENTS + query_results_to_pay = {} + if purchase_journals: + query, params = purchase_journals._get_open_payments_query().select(*payment_field_list) + self.env.cr.execute(query, params) + query_results_payments_to_pay = group_by_journal(self.env.cr.dictfetchall()) + for journal in purchase_journals: + query_results_to_pay[journal.id] = query_results_payments_to_pay[journal.id] + if sale_journals: + query, params = sale_journals._get_open_bills_to_pay_query().select(*bills_field_list) + self.env.cr.execute(query, params) + query_results_bills_to_pay = group_by_journal(self.env.cr.dictfetchall()) + for journal in sale_journals: + query_results_to_pay[journal.id] = query_results_bills_to_pay[journal.id] + + # LATE BILLS AND PAYMENTS + late_query_results = {} + if purchase_journals: + query, params = purchase_journals._get_late_payment_query().select(*payment_field_list) + self.env.cr.execute(query, params) + late_payments_query_results = group_by_journal(self.env.cr.dictfetchall()) + for journal in purchase_journals: + late_query_results[journal.id] = late_payments_query_results[journal.id] + if sale_journals: + query, params = sale_journals._get_late_bills_query().select(*bills_field_list) + self.env.cr.execute(query, params) + late_bills_query_results = group_by_journal(self.env.cr.dictfetchall()) + for journal in sale_journals: + late_query_results[journal.id] = late_bills_query_results[journal.id] to_check_vals = { journal: (amount_total_signed_sum, count) @@ -529,6 +559,26 @@ class account_journal(models.Model): ('move_type', 'in', self.env['account.move'].get_invoice_types(include_receipts=True)), ]) + def _get_open_payments_query(self): + return self.env['account.move.line']._where_calc([ + *self.env['account.move.line']._check_company_domain(self.env.companies), + ('journal_id', 'in', self.ids), + ('move_id.payment_state', 'in', ('not_paid', 'partial')), + ('date_maturity', '!=', False), + ('amount_residual', '!=', False), + ('parent_state', '=', 'posted'), + ]) + + def _get_late_payment_query(self): + return self.env['account.move.line']._where_calc([ + *self.env['account.move.line']._check_company_domain(self.env.companies), + ('journal_id', 'in', self.ids), + ('move_id.payment_state', 'in', ('not_paid', 'partial')), + ('date_maturity', '<', fields.Date.context_today(self)), + ('amount_residual', '!=', False), + ('parent_state', '=', 'posted'), + ]) + def _count_results_and_sum_amounts(self, results_dict, target_currency): """ Loops on a query result to count the total number of invoices and sum their amount_total field (expressed in the given target currency). diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index fcf9efdab50..f5b5cd0486c 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -3835,21 +3835,7 @@ class AccountMove(models.Model): }) def action_register_payment(self): - ''' Open the account.payment.register wizard to pay the selected journal entries. - :return: An action opening the account.payment.register wizard. - ''' - return { - 'name': _('Register Payment'), - 'res_model': 'account.payment.register', - 'view_mode': 'form', - 'views': [[False, 'form']], - 'context': { - 'active_model': 'account.move', - 'active_ids': self.ids, - }, - 'target': 'new', - 'type': 'ir.actions.act_window', - } + return self.line_ids.action_register_payment() def action_duplicate(self): # offer the possibility to duplicate thanks to a button instead of a hidden menu, which is more visible diff --git a/addons/account/models/account_move_line.py b/addons/account/models/account_move_line.py index 7eff77e4d4d..fa02a11ef34 100644 --- a/addons/account/models/account_move_line.py +++ b/addons/account/models/account_move_line.py @@ -1,7 +1,7 @@ import ast from collections import defaultdict from contextlib import contextmanager -from datetime import date, timedelta +from datetime import date from odoo import api, fields, models, Command, _ from odoo.exceptions import ValidationError, UserError @@ -230,6 +230,7 @@ class AccountMoveLine(models.Model): amount_residual_currency = fields.Monetary( string='Residual Amount in Currency', compute='_compute_amount_residual', store=True, + group_operator=None, help="The residual amount on a journal item expressed in its currency (possibly not the " "company currency).", ) @@ -384,6 +385,7 @@ class AccountMoveLine(models.Model): string='Discount amount in Currency', store=True, currency_field='currency_id', + group_operator=None, ) # Discounted balance when the early payment discount is applied discount_balance = fields.Monetary( @@ -392,6 +394,14 @@ class AccountMoveLine(models.Model): currency_field='company_currency_id', ) + # === Payment Fields === # + # payment_date is the closest date to the date the aml was created between discount_date and date_maturity. + payment_date = fields.Date( + string='Payment Date', + compute='_compute_payment_date', + search='_search_payment_date', + ) + # === Misc Information === # blocked = fields.Boolean( string='No Follow-up', @@ -433,6 +443,14 @@ class AccountMoveLine(models.Model): ), ] + @api.model + def get_views(self, views, options=None): + res = super().get_views(views, options) + if res['views'].get('list') and self.env['ir.ui.view'].browse(res['views']['list']['id']).name == "account.move.line.payment.tree": + # We dont want any additionnal action in the "account.move.line.payment.tree" view toolbar + res['views']['list']['toolbar']['action'] = [] + return res + # ------------------------------------------------------------------------- # COMPUTE METHODS # ------------------------------------------------------------------------- @@ -1102,6 +1120,38 @@ class AccountMoveLine(models.Model): }) line.analytic_distribution = distribution or line.analytic_distribution + @api.depends('discount_date', 'date_maturity') + def _compute_payment_date(self): + for line in self: + line.payment_date = line.discount_date if line.discount_date and date.today() <= line.discount_date else line.date_maturity + + def _search_payment_date(self, operator, value): + assert operator == '=' + return [ + '|', + '|', + '&', ('discount_date', '>=', str(date.today())), ('discount_date', '<=', value), + '&', ('discount_date', '<', str(date.today())), ('date_maturity', '<=', value), + '&', ('discount_date', '=', False), ('date_maturity', '<=', value), + ] + + def action_register_payment(self): + ''' Open the account.payment.register wizard to pay the selected journal items. + :return: An action opening the account.payment.register wizard. + ''' + return { + 'name': _('Register Payment'), + 'res_model': 'account.payment.register', + 'view_mode': 'form', + 'views': [[False, 'form']], + 'context': { + 'active_model': 'account.move.line', + 'active_ids': self.ids, + }, + 'target': 'new', + 'type': 'ir.actions.act_window', + } + # ------------------------------------------------------------------------- # INVERSE METHODS # ------------------------------------------------------------------------- diff --git a/addons/account/static/src/js/search/search_bar/search_bar.js b/addons/account/static/src/js/search/search_bar/search_bar.js new file mode 100644 index 00000000000..9c10b41d218 --- /dev/null +++ b/addons/account/static/src/js/search/search_bar/search_bar.js @@ -0,0 +1,15 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { patch } from "@web/core/utils/patch"; +import { SearchBar } from "@web/search/search_bar/search_bar"; + +patch(SearchBar.prototype, { + getPreposition(searchItem) { + let preposition = super.getPreposition(searchItem); + if (this.fields[searchItem.fieldName].name == 'payment_date') { + preposition = _t("until"); + } + return preposition + } +}); diff --git a/addons/account/tests/__init__.py b/addons/account/tests/__init__.py index 8bb201b73d5..543026a0e00 100644 --- a/addons/account/tests/__init__.py +++ b/addons/account/tests/__init__.py @@ -33,6 +33,7 @@ from . import test_tax_report from . import test_transfer_wizard from . import test_account_incoming_supplier_invoice from . import test_payment_term +from . import test_account_payment_items from . import test_account_payment_register from . import test_tour from . import test_early_payment_discount diff --git a/addons/account/tests/test_account_journal_dashboard.py b/addons/account/tests/test_account_journal_dashboard.py index 83319996f9d..d9e491c7af7 100644 --- a/addons/account/tests/test_account_journal_dashboard.py +++ b/addons/account/tests/test_account_journal_dashboard.py @@ -164,3 +164,90 @@ class TestAccountJournalDashboard(AccountTestInvoicingCommon): dashboard_data = default_journal_sale._get_journal_dashboard_data_batched()[default_journal_sale.id] self.assertEqual(format_amount(self.env, 55, company_currency), dashboard_data['sum_waiting']) self.assertEqual(format_amount(self.env, 55, company_currency), dashboard_data['sum_late']) + + @freeze_time("2023-03-15") + def test_purchase_journal_numbers_and_sums(self): + company_currency = self.company_data['currency'] + journal = self.company_data['default_journal_purchase'] + + #Setup multiple payments term + twentyfive_now_term = self.env['account.payment.term'].create({ + 'name': '25% now, rest in 30 days', + 'note': 'Pay 25% on invoice date and 75% 30 days later', + 'line_ids': [ + (0, 0, { + 'value': 'percent', + 'value_amount': 25.00, + 'delay_type': 'days_after', + 'nb_days': 0, + }), + (0, 0, { + 'value': 'percent', + 'value_amount': 75.00, + 'delay_type': 'days_after', + 'nb_days': 30, + }), + ], + }) + + self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'journal_id': journal.id, + 'partner_id': self.partner_a.id, + 'invoice_date': '2023-04-01', + 'date': '2023-03-15', + 'invoice_payment_term_id': twentyfive_now_term.id, + 'invoice_line_ids': [(0, 0, { + 'product_id': self.product_a.id, + 'quantity': 1, + 'name': 'product test 1', + 'price_unit': 4000, + 'tax_ids': [], + })] + }).action_post() + # This bill has two amls of 10$. Both are waiting for payment and due in 16 and 46 days. + # number_waiting += 2, sum_waiting += -4000$, number_late += 0, sum_late += 0$ + + self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'journal_id': journal.id, + 'partner_id': self.partner_a.id, + 'invoice_date': '2023-03-01', + 'date': '2023-03-15', + 'invoice_payment_term_id': twentyfive_now_term.id, + 'invoice_line_ids': [(0, 0, { + 'product_id': self.product_a.id, + 'quantity': 1, + 'name': 'product test 1', + 'price_unit': 400, + 'tax_ids': [], + })] + }).action_post() + # This bill has two amls of 100$. One which is late and due 14 days prior and one which is waiting for payment and due in 15 days. + # number_waiting += 2, sum_waiting += -400$, number_late += 1, sum_late += -100$ + + self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'journal_id': journal.id, + 'partner_id': self.partner_a.id, + 'invoice_date': '2023-02-01', + 'date': '2023-03-15', + 'invoice_payment_term_id': twentyfive_now_term.id, + 'invoice_line_ids': [(0, 0, { + 'product_id': self.product_a.id, + 'quantity': 1, + 'name': 'product test 1', + 'price_unit': 40, + 'tax_ids': [], + })] + }).action_post() + # This bill has two amls of 1000$. Both of them are late and due 45 and 15 days prior. + # number_waiting += 2, sum_waiting += -40$, number_late += 2, sum_late += -40$ + + dashboard_data = journal._get_journal_dashboard_data_batched()[journal.id] + # Expected behavior is to have six amls waiting for payment for a total amount of 4440$ + # three of which would be late for a total amount of 140$ + self.assertEqual(6, dashboard_data['number_waiting']) + self.assertEqual(format_amount(self.env, 4440, company_currency), dashboard_data['sum_waiting']) + self.assertEqual(3, dashboard_data['number_late']) + self.assertEqual(format_amount(self.env, 140, company_currency), dashboard_data['sum_late']) diff --git a/addons/account/tests/test_account_payment_items.py b/addons/account/tests/test_account_payment_items.py new file mode 100644 index 00000000000..c76c040c930 --- /dev/null +++ b/addons/account/tests/test_account_payment_items.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +from datetime import date, datetime, timedelta +from freezegun import freeze_time + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged + +@tagged('post_install', '-at_install') +class TestAccountPaymentItems(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.bill = cls.create_bill(due_date='2023-03-20') + cls.late_bill = cls.create_bill(due_date='2023-03-01') + cls.discount_bill = cls.create_bill(due_date='2023-03-20', discount_days=19) + cls.late_discount_bill = cls.create_bill(due_date='2023-04-20', discount_days=9) + + @classmethod + def create_bill(cls, due_date, discount_days=None): + payment_term = cls.create_payment_term(due_date, discount_days) + bill = cls.env['account.move'].create({ + 'move_type': 'in_invoice', + 'journal_id': cls.company_data['default_journal_purchase'].id, + 'partner_id': cls.partner_a.id, + 'date': '2023-03-15', + 'invoice_date': '2023-03-01', + 'invoice_date_due': due_date, + 'invoice_payment_term_id': payment_term.id, + 'invoice_line_ids': [(0, 0, { + 'product_id': cls.product_a.id, + 'quantity': 1, + 'name': 'product_a', + 'discount': 10.00, + 'price_unit': 100, + 'tax_ids': [], + 'discount_date': date(2023, 3, 1) + timedelta(days=discount_days) if discount_days else False, + 'date_maturity': due_date, + })] + }) + bill.action_post() + return bill + + @classmethod + def create_payment_term(cls, due_date, discount_days=None): + due_days = (datetime.strptime(due_date, '%Y-%m-%d').date() - date(2023, 3, 1)).days + payment_term = cls.env['account.payment.term'].create({ + 'name': 'Payment Term For Testing', + 'early_discount': bool(discount_days), + 'discount_days': discount_days if discount_days else False, + 'discount_percentage': 5, + 'line_ids': [ + (0, 0, { + 'value': 'percent', + 'value_amount': 100, + 'delay_type': 'days_after', + 'nb_days': due_days, + }), + ], + }) + return payment_term + + @freeze_time("2023-03-15") + def test_payment_date(self): + self.assertEqual(str(self.bill.line_ids[0].payment_date), '2023-03-20') + self.assertEqual(str(self.late_bill.line_ids[0].payment_date), '2023-03-01') + self.assertEqual(str(self.discount_bill.line_ids[0].payment_date), '2023-03-20') + self.assertEqual(str(self.late_discount_bill.line_ids[0].payment_date), '2023-04-20') + + def test_search_payment_date(self): + for today, search, expected in [ + ('2023-03-05', '2023-03-01', self.late_bill), + ('2023-03-05', '2023-03-30', self.bill + self.late_bill + self.discount_bill + self.late_discount_bill), + ('2023-03-15', '2023-03-01', self.late_bill), + ('2023-03-15', '2023-03-30', self.bill + self.late_bill + self.discount_bill), + ('2023-03-25', '2023-03-01', self.late_bill), + ('2023-03-25', '2023-03-30', self.bill + self.late_bill + self.discount_bill), + ('2023-03-25', '2023-06-30', self.bill + self.late_bill + self.discount_bill + self.late_discount_bill), + ]: + with freeze_time(today): + self.assertEqual(self.env['account.move.line'].search([ + ('payment_date', '=', search), + ('partner_id', '=', self.partner_a.id), + ]).move_id, expected) diff --git a/addons/account/views/account_journal_dashboard_view.xml b/addons/account/views/account_journal_dashboard_view.xml index 75a31af4c4a..506d42368c2 100644 --- a/addons/account/views/account_journal_dashboard_view.xml +++ b/addons/account/views/account_journal_dashboard_view.xml @@ -334,9 +334,8 @@ context="{'search_default_open':1, 'search_default_posted':1, 'search_default_partial': 1}" id="account_dashboard_sale_pay_link"> Unpaid Invoices - - + Bills to Pay @@ -346,9 +345,12 @@
diff --git a/addons/account/views/account_menuitem.xml b/addons/account/views/account_menuitem.xml index 57670802354..c1f49e05843 100644 --- a/addons/account/views/account_menuitem.xml +++ b/addons/account/views/account_menuitem.xml @@ -19,6 +19,7 @@ + diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml index 7b5df31dbfd..d5de7c70593 100644 --- a/addons/account/views/account_move_views.xml +++ b/addons/account/views/account_move_views.xml @@ -305,6 +305,7 @@ account.move.line.search account.move.line + + + @@ -369,6 +372,86 @@ + + + + account.move.line.payment.tree + account.move.line + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + +