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">
+ Amounts to settle +
+ Cool, it looks like you don't have anything to pay either for vendor bills or for customer credit notes. +
+