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