+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ hr_employee.extend1.form2
+ hr.employee
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_accountant/__init__.py b/dev_odex30_accounting/odex30_account_accountant/__init__.py
new file mode 100644
index 0000000..a31a691
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/__init__.py
@@ -0,0 +1,46 @@
+
+from . import models
+from . import wizard
+
+from odoo import Command
+
+import logging
+
+_logger = logging.getLogger(__name__)
+
+
+def _odex30_account_accountant_post_init(env):
+ country_code = env.company.country_id.code
+ if country_code:
+ module_list = []
+
+ # SEPA zone countries will be using SEPA
+ sepa_zone = env.ref('base.sepa_zone', raise_if_not_found=False)
+ sepa_zone_country_codes = sepa_zone and sepa_zone.mapped('country_ids.code') or []
+
+ if country_code in sepa_zone_country_codes:
+ module_list.extend(['account_iso20022', 'account_bank_statement_import_camt'])
+
+ module_ids = env['ir.module.module'].search([('name', 'in', module_list), ('state', '=', 'uninstalled')])
+ if module_ids:
+ module_ids.sudo().button_install()
+
+ for company in env['res.company'].search([('chart_template', '!=', False)], order="parent_path"):
+ ChartTemplate = env['account.chart.template'].with_company(company)
+ ChartTemplate._load_data({
+ 'res.company': ChartTemplate._get_account_accountant_res_company(company.chart_template),
+ })
+
+
+def uninstall_hook(env):
+ # Disable the basic group to remove access menus defined in account
+ group_basic = env.ref('account.group_account_basic')
+ group_manager = env.ref('account.group_account_manager')
+ if group_basic:
+ group_basic.write({
+ 'users': [Command.clear()],
+ 'category_id': env.ref("base.module_category_hidden").id,
+ })
+ group_manager.write({
+ 'implied_ids': [Command.unlink(group_basic.id)],
+ })
diff --git a/dev_odex30_accounting/odex30_account_accountant/__manifest__.py b/dev_odex30_accounting/odex30_account_accountant/__manifest__.py
new file mode 100644
index 0000000..9506e4b
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/__manifest__.py
@@ -0,0 +1,63 @@
+{
+ 'name': 'Invoicing',
+ 'version': '1.1',
+ 'category': 'Odex30-Accounting/Odex30-Accounting',
+ 'sequence': 30,
+ 'summary': 'Manage financial and analytic accounting',
+ 'description': """
+ Accounting Access Rights
+ ========================
+ It gives the Administrator user access to all accounting features such as journal items and the chart of accounts.
+
+ It assigns manager and user access rights to the Administrator for the accounting application and only user rights to the Demo user.
+ """,
+ 'author': "Expert Co. Ltd.",
+ 'website': "http://www.exp-sa.com",
+ 'depends': ['account', 'web_tour'],
+ 'data': [
+ 'data/ir_cron.xml',
+ 'data/digest_data.xml',
+ 'data/odex30_account_accountant_tour.xml',
+
+ 'security/ir.model.access.csv',
+ 'security/odex30_account_accountant_security.xml',
+
+ 'views/odex30_account_account_views.xml',
+ 'views/account_fiscal_year_view.xml',
+ 'views/account_journal_dashboard_views.xml',
+ 'views/account_move_views.xml',
+ 'views/account_payment_views.xml',
+ 'views/odex30_account_reconcile_views.xml',
+ 'views/account_reconcile_model_views.xml',
+ 'views/odex30_account_accountant_menuitems.xml',
+ 'views/digest_views.xml',
+ 'views/res_config_settings_views.xml',
+ 'views/product_views.xml',
+ 'views/bank_rec_widget_views.xml',
+ 'views/report_invoice.xml',
+
+ 'wizard/account_change_lock_date.xml',
+ 'wizard/account_auto_reconcile_wizard.xml',
+ 'wizard/account_reconcile_wizard.xml',
+ 'wizard/reconcile_model_wizard.xml',
+ ],
+
+ 'installable': True,
+ 'auto_install': True,
+ 'post_init_hook': '_odex30_account_accountant_post_init',
+ 'uninstall_hook': "uninstall_hook",
+ 'assets': {
+ 'web.assets_backend': [
+ 'odex30_account_accountant/static/src/js/tours/account_accountant.js',
+ 'odex30_account_accountant/static/src/components/**/*',
+ 'odex30_account_accountant/static/src/**/*.xml',
+ ],
+ 'web.assets_unit_tests': [
+ 'odex30_account_accountant/static/tests/**/*',
+ ('remove', 'odex30_account_accountant/static/tests/tours/**/*'),
+ ],
+ 'web.assets_tests': [
+ 'odex30_account_accountant/static/tests/tours/**/*',
+ ],
+ }
+}
diff --git a/dev_odex30_accounting/odex30_account_accountant/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..c547485
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/__pycache__/__init__.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/data/digest_data.xml b/dev_odex30_accounting/odex30_account_accountant/data/digest_data.xml
new file mode 100644
index 0000000..666229e
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/data/digest_data.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ True
+
+
+
+
+ Tip: Bulk update journal items
+ 900
+
+
+
+ Tip: Bulk update journal items
+
From any list view, select multiple records and the list becomes editable. If you update a cell, selected records are updated all at once. Use this feature to update multiple journal entries from the General Ledger, or any Journal view.
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_accountant/data/ir_cron.xml b/dev_odex30_accounting/odex30_account_accountant/data/ir_cron.xml
new file mode 100644
index 0000000..3398c92
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/data/ir_cron.xml
@@ -0,0 +1,11 @@
+
+
+
+ Try to reconcile automatically your statement lines
+
+ code
+ model._cron_try_auto_reconcile_statement_lines(batch_size=100)
+ 1
+ days
+
+
diff --git a/dev_odex30_accounting/odex30_account_accountant/data/odex30_account_accountant_tour.xml b/dev_odex30_accounting/odex30_account_accountant/data/odex30_account_accountant_tour.xml
new file mode 100644
index 0000000..4346f60
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/data/odex30_account_accountant_tour.xml
@@ -0,0 +1,11 @@
+
+
+
+ odex30_account_accountant_tour
+ 50
+ Good job! You went through all steps of this tour.
+ See how to manage your customer invoices in the Customers/Invoices menu
+ ]]>
+
+
diff --git a/dev_odex30_accounting/odex30_account_accountant/i18n/ar.po b/dev_odex30_accounting/odex30_account_accountant/i18n/ar.po
new file mode 100644
index 0000000..580292e
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/i18n/ar.po
@@ -0,0 +1,2950 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * odex30_account_accountant
+#
+# Translators:
+# Martin Trigaux, 2024
+# Mustafa J. Kadhem , 2024
+# Wil Odoo, 2025
+# Malaz Abuidris , 2025
+# Weblate , 2025.
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 18.0+e\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2025-10-16 18:48+0000\n"
+"PO-Revision-Date: 2025-11-10 12:44+0000\n"
+"Last-Translator: Weblate \n"
+"Language-Team: Arabic \n"
+"Language: ar\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
+"&& n%100<=10 ? 3 : n%100>=11 ? 4 : 5;\n"
+"X-Generator: Weblate 5.12.2\n"
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/bank_rec_widget_line.py:0
+msgid ""
+"%(display_name_html)s with an open amount of %(open_amount)s will be fully "
+"reconciled by the transaction."
+msgstr ""
+"%(display_name_html)s مع مبلغ مفتوح قدره %(open_amount)s ستتم تسويته تماماً "
+"بواسطة المعاملة. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/bank_rec_widget_line.py:0
+msgid ""
+"%(display_name_html)s with an open amount of %(open_amount)s will be reduced "
+"by %(amount)s."
+msgstr ""
+"%(display_name_html)s مع مبلغ مفتوح قدره %(open_amount)s سيتم تقليله بـ %"
+"(amount)s. "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_form
+msgid "-> Reconcile"
+msgstr "-> تسوية "
+
+#. module: odex30_account_accountant
+#: model_terms:digest.tip,tip_description:odex30_account_accountant.digest_tip_account_accountant_0
+msgid "Tip: Bulk update journal items"
+msgstr "نصيحة: قم بتحديث بنود اليومية بالجملة"
+
+#. module: odex30_account_accountant
+#: model_terms:digest.tip,tip_description:odex30_account_accountant.digest_tip_account_accountant_1
+msgid ""
+"Tip: Find an Accountant or register your Accounting "
+"Firm"
+msgstr ""
+"نصيحة: اعثر على محاسب ليقوم بتسجيل شركة المحاسبة "
+"الخاصة بك"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.account_reconcile_model_form_inherit_account_accountant
+msgid ""
+"\n"
+" Run manually"
+msgstr ""
+"\n"
+" التشغيل يدوياً "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+msgid "Lock transactions up to specific dates, inclusive"
+msgstr "قفل المعاملات حتى تواريخ محددة، شاملة التواريخ نفسها "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_form_inherit
+msgid "1 Bank Transaction"
+msgstr "معاملة بنكية واحدة 1"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_form_inherit
+msgid "Bank Statement"
+msgstr "كشف حساب بنكي "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_wizard
+msgid ""
+" in "
+msgstr ""
+" في "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_wizard
+msgid ""
+" in "
+msgstr ""
+" في "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+msgid ""
+"\n"
+" This change is irreversible"
+"i>\n"
+" "
+msgstr ""
+"\n"
+" لا يمكن التراجع عن هذا "
+"التغيير\n"
+" "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+msgid ""
+"\n"
+" ; "
+"span>\n"
+" "
+msgstr ""
+"\n"
+" ; "
+"span>\n"
+" "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+msgid ""
+"\n"
+" ; "
+"span>\n"
+" "
+msgstr ""
+"\n"
+" ; "
+"span>\n"
+" "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+msgid ""
+"\n"
+" ; "
+"span>\n"
+" "
+msgstr ""
+"\n"
+" ; "
+"span>\n"
+" "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+msgid ""
+"\n"
+" ; "
+"span>\n"
+" "
+msgstr ""
+"\n"
+" ; "
+"span>\n"
+" "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+msgid ""
+"\n"
+" but allow exceptions\n"
+" "
+msgstr ""
+"\n"
+" ولكن يُسمَح بالاستثناءات\n"
+" "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+msgid ""
+"\n"
+" after a tax closing\n"
+" "
+msgstr ""
+"\n"
+" بعد الإقفال الضريبي\n"
+" "
+
+#. module: odex30_account_accountant
+#: model_terms:digest.tip,tip_description:odex30_account_accountant.digest_tip_account_accountant_1
+msgid "Find an Accountant"
+msgstr "اعثر على محاسب"
+
+#. module: odex30_account_accountant
+#: model_terms:digest.tip,tip_description:odex30_account_accountant.digest_tip_account_accountant_1
+msgid "Register your Accounting Firm"
+msgstr ""
+"قم بتسجيل شركة المحاسبة الخاصة بك"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_wizard
+msgid " ("
+msgstr " ("
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_wizard
+msgid ")"
+msgstr ")"
+
+#. module: odex30_account_accountant
+#: model_terms:web_tour.tour,rainbow_man_message:odex30_account_accountant.odex30_account_accountant_tour
+msgid ""
+"Good job! You went through all steps of this tour."
+"strong>\n"
+" See how to manage your customer invoices in the Customers/"
+"Invoices menu\n"
+" "
+msgstr ""
+"عمل رائع! لقد اجتزت كافة خطوات هذه الجولة.\n"
+" تعرّف على كيفية إدارة فواتير العملاء من قائمة العملاء/"
+"فواتير العملاء\n"
+" "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+msgid "Exception"
+msgstr "استثناء "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0
+#: model:ir.model,name:odex30_account_accountant.model_account_account
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__account_id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__account_id
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+msgid "Account"
+msgstr "الحساب "
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_account_chart_template
+msgid "Account Chart Template"
+msgstr "نموذج مخطط الحساب "
+
+#. module: odex30_account_accountant
+#: model:ir.actions.act_window,name:odex30_account_accountant.action_account_group_tree
+#: model:ir.ui.menu,name:odex30_account_accountant.menu_account_group
+msgid "Account Groups"
+msgstr "مجموعات الحساب"
+
+#. module: odex30_account_accountant
+#: model:ir.actions.act_window,name:odex30_account_accountant.account_tag_action
+#: model:ir.ui.menu,name:odex30_account_accountant.account_tag_menu
+msgid "Account Tags"
+msgstr "علامات تصنيف الحساب "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__transfer_from_account_id
+msgid "Account Transfer From"
+msgstr "استمارة تحويل الحساب "
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_account_auto_reconcile_wizard
+msgid "Account automatic reconciliation wizard"
+msgstr "معالج تسوية الحساب الآلية "
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_account_reconcile_wizard
+msgid "Account reconciliation wizard"
+msgstr "معالج تسوية الحساب "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__deferred_expense_account_id
+msgid "Account used for deferred expenses"
+msgstr "الحساب المستخدم للنفقات المؤجلة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__deferred_revenue_account_id
+msgid "Account used for deferred revenues"
+msgstr "الحساب المستخدم للإيرادات المؤجلة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__account_ids
+msgid "Accounts"
+msgstr "الحسابات"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_needaction
+msgid "Action Needed"
+msgstr "إجراء مطلوب"
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_quick_create.xml:0
+msgid "Add & Close"
+msgstr "إضافة وإغلاق "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_quick_create.xml:0
+msgid "Add & New"
+msgstr "إضافة وجديد "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.actions.act_window,help:odex30_account_accountant.account_tag_action
+msgid "Add a new tag"
+msgstr "إضافة علامة تصنيف جديدة "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0
+msgid ""
+"After the data extraction, check and validate the bill. If no vendor has "
+"been found, add one before validating."
+msgstr ""
+"بعد استخلاص البيانات، تحقق من الفاتورة وقم بتصديقها. في حال عدم إيجادك "
+"لمورّد، قم بإضافة واحد قبل التصديق. "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/finish_buttons.xml:0
+msgid "All Transactions"
+msgstr "كافة المعاملات "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__reco_model_autocomplete_ids
+msgid "All reconciliation models"
+msgstr "كافة نماذج التسوية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__allow_partials
+msgid "Allow partials"
+msgstr "السماج بالأجزاء "
+
+#. module: odex30_account_accountant
+#: model:res.groups,name:odex30_account_accountant.group_fiscal_year
+msgid "Allow to define fiscal years of more or less than a year"
+msgstr "السماح بإنشاء سنوات مالية أطول أو أقصر من عام"
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__amount_currency
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_wizard
+msgid "Amount"
+msgstr "مبلغ"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__amount_currency
+msgid "Amount Currency"
+msgstr "عملة المبلغ"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_list_bank_rec_widget
+msgid "Amount Due"
+msgstr "المبلغ المستحق"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_list_bank_rec_widget
+msgid "Amount Due (in currency)"
+msgstr "المبلغ المستحق (بالعملة) "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__amount_transaction_currency
+msgid "Amount in Currency"
+msgstr "المبلغ بالعملة"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__amount
+msgid "Amount in company currency"
+msgstr "المبلغ بعملة الشركة "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0
+msgid ""
+"An entry will transfer %(amount)s from %(from_account)s to %(to_account)s."
+msgstr "سيقوم القيد بتحويل %(amount)s من %(from_account)s إلى %(to_account)s. "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0
+msgid "Analytic"
+msgstr "تحليلي"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__analytic_distribution
+msgid "Analytic Distribution"
+msgstr "التوزيع التحليلي"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__analytic_precision
+msgid "Analytic Precision"
+msgstr "الدقة التحليلية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__use_anglo_saxon
+msgid "Anglo-Saxon Accounting"
+msgstr "المحاسبة الأنجلو-ساكسونية"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_change_lock_date__current_hard_lock_date
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_change_lock_date__hard_lock_date
+msgid ""
+"Any entry up to and including that date will be postponed to a later time, "
+"in accordance with its journal sequence. This lock date is irreversible and "
+"does not allow any exception."
+msgstr ""
+"سيتم تأجيل أي قيد حتى ذلك التاريخ إلى وقت لاحق، وفقًا لتسلسل دفتر اليومية "
+"الخاص به. لا يمكن التراجع عن تاريخ القفل هذا ولا يسمح بأي استثناء. "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_change_lock_date__fiscalyear_lock_date
+msgid ""
+"Any entry up to and including that date will be postponed to a later time, "
+"in accordance with its journal's sequence."
+msgstr ""
+"أي قيد إلى ذلك التاريخ سيتم تأجيله إلى وقت لاحق، وفقًا لتسلسله في دفتر "
+"اليومية. "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_change_lock_date__tax_lock_date
+msgid ""
+"Any entry with taxes up to and including that date will be postponed to a "
+"later time, in accordance with its journal's sequence. The tax lock date is "
+"automatically set when the tax closing entry is posted."
+msgstr ""
+"سيتم تأجيل أي قيد يتضمن ضرائب حتى ذلك التاريخ إلى وقت لاحق، وفقاً لتسلسل دفتر "
+"اليومية الخاص به. يتم تعيين تاريخ قفل الضرائب تلقائياً عند ترحيل قيد الإقفال "
+"الضريبي. "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_change_lock_date__purchase_lock_date
+msgid ""
+"Any purchase entry prior to and including this date will be postponed to a "
+"later date, in accordance with its journal's sequence."
+msgstr ""
+"أي قيد شراء قبل ذلك التاريخ سيتم تأجيله إلى تاريخ لاحق، وفقاً لتسلسله في دفتر "
+"اليومية. "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_change_lock_date__sale_lock_date
+msgid ""
+"Any sales entry prior to and including this date will be postponed to a "
+"later date, in accordance with its journal's sequence."
+msgstr ""
+"أي قيد بيع قبل ذلك التاريخ سيتم تأجيله إلى تاريخ لاحق، وفقاً لتسلسله في دفتر "
+"اليومية. "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_attachment_count
+msgid "Attachment Count"
+msgstr "عدد المرفقات"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__sign_invoice
+msgid "Authorized Signatory on invoice"
+msgstr "الموقِّع المصرّح له في الفاتورة "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.report_invoice_document
+msgid "Authorized signatory"
+msgstr "الموقِّع المصرّح له "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/move_line_list_reconcile/move_line_list_reconcile.xml:0
+msgid "Auto-reconcile"
+msgstr "التسوية التلقائية "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/wizard/account_auto_reconcile_wizard.py:0
+msgid "Automatically Reconciled Entries"
+msgstr "القيود المسواة آلياً "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__available_reco_model_ids
+msgid "Available Reco Model"
+msgstr "نموذج التسوية المتاح "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/finish_buttons.xml:0
+msgid "Back to"
+msgstr "العودة إلى "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/global_info.xml:0
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__balance
+msgid "Balance"
+msgstr "الرصيد"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_digest_digest__kpi_account_bank_cash
+msgid "Bank & Cash Moves"
+msgstr "تحركات النقد والبنوك "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__bank_account
+msgid "Bank Account"
+msgstr "الحساب البنكي"
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_bank_statement.py:0
+#: model:ir.actions.act_window,name:odex30_account_accountant.action_bank_statement_line_transactions
+#: model:ir.actions.act_window,name:odex30_account_accountant.action_bank_statement_line_transactions_kanban
+msgid "Bank Reconciliation"
+msgstr "التسوية البنكية"
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_account_bank_statement
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_form_bank_rec_widget
+msgid "Bank Statement"
+msgstr "كشف الحساب البنكي "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_bank_statement.py:0
+msgid "Bank Statement %s.pdf"
+msgstr "كشف الحساب البنكي %s.pdf "
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_account_bank_statement_line
+msgid "Bank Statement Line"
+msgstr "بند كشف الحساب البنكي"
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_bank_statement.py:0
+msgid "Bank Statement.pdf"
+msgstr "كشف الحساب البنكي.pdf"
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_bank_rec_widget
+msgid "Bank reconciliation widget for a single statement line"
+msgstr "أداة التسوية البنكية لبند كشف حساب واحد "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form
+msgid "Based on"
+msgstr "بناءً على"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_model_widget_wizard
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_form_bank_rec_widget
+msgid "Cancel"
+msgstr "إلغاء"
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_account_change_lock_date
+msgid "Change Lock Date"
+msgstr "تغيير تاريخ الإقفال"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_reconcile_wizard__to_check
+msgid "Check if you are not certain of all the information of the counterpart."
+msgstr "تحقق من تأكدك من كافة المعلومات المقابلة. "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__st_line_checked
+msgid "Checked"
+msgstr "تم تحديده "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/move_line_list/move_line_list.xml:0
+msgid "Choose a line to preview its attachments."
+msgstr "قم بتحديد بند لمعاينة مرفقاته. "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_auto_reconcile_wizard__search_mode__zero_balance
+msgid "Clear Account"
+msgstr "تصفية الحساب "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.actions.act_window,help:odex30_account_accountant.actions_account_fiscal_year
+msgid "Click here to create a new fiscal year."
+msgstr "انقر هنا لإنشاء سنة مالية جديدة."
+
+#. module: odex30_account_accountant
+#: model_terms:digest.tip,tip_description:odex30_account_accountant.digest_tip_account_accountant_1
+msgid ""
+"Click here to find an accountant or if you want to list out your accounting "
+"services on Odoo"
+msgstr ""
+"انقر هنا لإيجاد محاسب أو إذا كنت ترغب في إدراج خدماتك المحاسبية على أودو "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0
+msgid ""
+"Click on a fetched bank transaction to start the reconciliation process."
+msgstr "انقر على المعاملة المصرفية التي تم جلبها لبدء عملية التسوية. "
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_res_company
+msgid "Companies"
+msgstr "الشركات"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__company_id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__company_id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__company_id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__company_id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__company_id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__company_id
+msgid "Company"
+msgstr "الشركة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__company_currency_id
+msgid "Company currency"
+msgstr "عملة الشركة "
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_res_config_settings
+msgid "Config Settings"
+msgstr "تهيئة الإعدادات "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0
+msgid "Confirm the transaction."
+msgstr "قم بتأكيد المعاملة. "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/rainbowman_content.xml:0
+msgid "Congrats, you're all done!"
+msgstr "تهانينا، لقد انتهيت!"
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0
+msgid "Connect your bank and get your latest transactions."
+msgstr "قم بربط مصرفك لتتمكن من رؤية أحدث معاملاتك. "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_model_widget_wizard
+msgid "Counterpart Values"
+msgstr "قيم مقابلة"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__country_code
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__country_code
+msgid "Country Code"
+msgstr "رمز الدولة "
+
+#. module: odex30_account_accountant
+#: model:ir.actions.act_window,name:odex30_account_accountant.action_bank_statement_form_bank_rec_widget
+msgid "Create Statement"
+msgstr "إنشاء كشف الحساب"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.actions.act_window,help:odex30_account_accountant.action_account_group_tree
+msgid "Create a new account group"
+msgstr "إنشاء مجموعة حساب جديدة "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0
+msgid "Create a new transaction."
+msgstr "إنشاء معاملة جديدة. "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+msgid "Create model"
+msgstr "إنشاء نموذج "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0
+msgid ""
+"Create your first vendor bill.
Tip: If you don’t have one on "
+"hand, use our sample bill."
+msgstr ""
+"أنشئ فاتورة المورّد الأولى الخاصة بك.
نصيحة: إذا لم تكن لديك "
+"فاتورة في متناول اليد، فبإمكانك الاستعانة بنموذج الفاتورة لدينا. "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__create_uid
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__create_uid
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__create_uid
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__create_uid
+msgid "Created by"
+msgstr "أنشئ بواسطة"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__create_date
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__create_date
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__create_date
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__create_date
+msgid "Created on"
+msgstr "أنشئ في"
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__credit
+msgid "Credit"
+msgstr "الدائن"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement_line__cron_last_check
+msgid "Cron Last Check"
+msgstr "آخر فحص Cron "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__currency_id
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree
+msgid "Currency"
+msgstr "العملة"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__reco_currency_id
+msgid "Currency to use for reconciliation"
+msgstr "العملة لاستخدامها في التسوية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__current_hard_lock_date
+msgid "Current Hard Lock"
+msgstr "تاريخ القفل الثابت الحالي "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/bank_rec_widget.py:0
+msgid "Customer/Vendor"
+msgstr "العميل/المورد"
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__date
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__date
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_search_bank_rec_widget
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget
+msgid "Date"
+msgstr "التاريخ"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_move_line__deferred_end_date
+msgid "Date at which the deferred expense/revenue ends"
+msgstr "التاريخ الذي تنتهي فيه النفقات/الإيرادات المؤجلة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_move_line__deferred_start_date
+msgid "Date at which the deferred expense/revenue starts"
+msgstr "التاريخ الذي تبدأ فيه النفقات/الإيرادات المؤجلة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__deferred_expense_amount_computation_method__day
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__deferred_revenue_amount_computation_method__day
+msgid "Days"
+msgstr "أيام "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__debit
+msgid "Debit"
+msgstr "المدين"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__journal_default_account_id
+msgid "Default Account"
+msgstr "الحساب الافتراضي "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_move.py:0
+msgid "Deferral of %s"
+msgstr "تأجيل %s "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_move.py:0
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement_line__deferred_move_ids
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move__deferred_move_ids
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_form_inherit
+msgid "Deferred Entries"
+msgstr "القيم المؤجلة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement_line__deferred_entry_type
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move__deferred_entry_type
+msgid "Deferred Entry Type"
+msgstr "نوع القيد المؤجل "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_move__deferred_entry_type__expense
+msgid "Deferred Expense"
+msgstr "النفقات المؤجلة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__deferred_expense_account_id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__deferred_expense_account_id
+msgid "Deferred Expense Account"
+msgstr "حساب النفقات المؤجلة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__deferred_expense_amount_computation_method
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__deferred_expense_amount_computation_method
+msgid "Deferred Expense Based on"
+msgstr "النفقات المؤجلة بناءً على "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__deferred_expense_journal_id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__deferred_expense_journal_id
+msgid "Deferred Expense Journal"
+msgstr "حساب الإيرادات المؤجلة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_move__deferred_entry_type__revenue
+msgid "Deferred Revenue"
+msgstr "الإيرادات المؤجلة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__deferred_revenue_account_id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__deferred_revenue_account_id
+msgid "Deferred Revenue Account"
+msgstr "حساب الإيرادات المؤجلة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__deferred_revenue_amount_computation_method
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__deferred_revenue_amount_computation_method
+msgid "Deferred Revenue Based on"
+msgstr "الإيرادات المؤجلة بناءً على "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__deferred_revenue_journal_id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__deferred_revenue_journal_id
+msgid "Deferred Revenue Journal"
+msgstr "دفتر يومية الإيرادات المؤجلة "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form
+msgid "Deferred expense"
+msgstr "النفقة المؤجلة "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form
+msgid "Deferred expense entries:"
+msgstr "قيود النفقات المؤجلة: "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form
+msgid "Deferred revenue"
+msgstr "الإيرادات المؤجلة "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form
+msgid "Deferred revenue entries:"
+msgstr "قيود الإيرادات المؤجلة: "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form
+msgid "Define fiscal years of more or less than one year"
+msgstr "تحديد السنوات المالية التي تزيد أو تقل عن السنة."
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget
+msgid "Deposits"
+msgstr "الدفعات المقدّمة"
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_digest_digest
+msgid "Digest"
+msgstr "الموجز "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_auto_reconcile_wizard
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_wizard
+msgid "Discard"
+msgstr "إهمال "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree
+msgid "Discount Amount"
+msgstr "مبلغ الخصم "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree
+msgid "Discount Date"
+msgstr "تاريخ الخصم"
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+msgid "Discuss"
+msgstr "المناقشة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__display_allow_partials
+msgid "Display Allow Partials"
+msgstr "عرض خيار السماح بعمليات التسوية الجزئية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__display_name
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__display_name
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__display_name
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__display_name
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__display_name
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__display_name
+msgid "Display Name"
+msgstr "اسم العرض "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__display_stroked_amount_currency
+msgid "Display Stroked Amount Currency"
+msgstr "عرض عملة المبلغ المشطوب "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__display_stroked_balance
+msgid "Display Stroked Balance"
+msgstr "عرض الرصيد المشطوب "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__sign_invoice
+msgid "Display signing field on invoices"
+msgstr "عرض حقل التوقيع على الفاتورة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__distribution_analytic_account_ids
+msgid "Distribution Analytic Account"
+msgstr "حساب التوزيع التحليلي "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/digest.py:0
+msgid "Do not have access, skip this data for user's digest email"
+msgstr "لا تملك صلاحيات الوصول. تخط هذه البيانات لبريد الملخص للمستخدم. "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_list_bank_rec_widget
+msgid "Document"
+msgstr "المستند "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/wizard/account_change_lock_date.py:0
+msgid "Draft Entries"
+msgstr "القيود في حالة المسودة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__edit_mode
+msgid "Edit Mode"
+msgstr "وضع التحرير "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__edit_mode_amount
+msgid "Edit Mode Amount"
+msgstr "وضع التحرير للمبلغ "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__edit_mode_reco_currency_id
+msgid "Edit Mode Reco Currency"
+msgstr "وضع التحرير لعملة التسوية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__edit_mode_amount_currency
+msgid "Edit mode amount"
+msgstr "وضع التحرير للمبلغ "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__date_to
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move_line__deferred_end_date
+msgid "End Date"
+msgstr "تاريخ الانتهاء"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_fiscal_year__date_to
+msgid "Ending Date, included in the fiscal year."
+msgstr "تاريخ الانتهاء، ضمن السنة المالية. "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_res_company__invoicing_switch_threshold
+#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__invoicing_switch_threshold
+msgid ""
+"Every payment and invoice before this date will receive the 'From Invoicing' "
+"status, hiding all the accounting entries related to it. Use this option "
+"after installing Accounting if you were using only Invoicing before, before "
+"importing all your actual accounting data in to Odoo."
+msgstr ""
+"سوف يكون لكل فاتورة وعملية دفع قبل هذا التاريخ حالة ’من تطبيق الفوترة‘ والتي "
+"ستخفي كافة القيود المحاسبية المتعلقة بها. استخدم هذا الخيار بعد تثبيت تطبيق "
+"المحاسبة إذا كنت تستخدم تطبيق الفوترة وحده من قبل، قبل إدخالك لكافة بياناتك "
+"المحاسبية الفعلية في أودو. "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__exception_duration
+msgid "Exception Duration"
+msgstr "مدة الاستثناء "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__exception_needed_fields
+msgid "Exception Needed Fields"
+msgstr "الحقول التي تحتاج إلى استثناء "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__exception_reason
+msgid "Exception Reason"
+msgstr "سبب الاستثناء "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__exception_applies_to
+msgid "Exception applies"
+msgstr "يمكن تطبيق الاستثناء "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__exception_needed
+msgid "Exception needed"
+msgstr "بحاجة إلى استثناء "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/bank_rec_widget.py:0
+msgid "Exchange Difference: %s"
+msgstr "فرق سعر الصرف: %s"
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_account_fiscal_year
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form
+msgid "Fiscal Year"
+msgstr "سنة مالية"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.action_account_fiscal_year_form
+msgid "Fiscal Year 2018"
+msgstr "السنة المالية 2018"
+
+#. module: odex30_account_accountant
+#: model:ir.actions.act_window,name:odex30_account_accountant.actions_account_fiscal_year
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__group_fiscal_year
+#: model:ir.ui.menu,name:odex30_account_accountant.menu_account_fiscal_year
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form
+msgid "Fiscal Years"
+msgstr "السنوات المالية"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__fiscalyear_last_day
+msgid "Fiscalyear Last Day"
+msgstr "آخر أيام السنة المالية"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__fiscalyear_last_month
+msgid "Fiscalyear Last Month"
+msgstr "آخر شهور السنة المالية"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__flag
+msgid "Flag"
+msgstr "إبلاغ"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_follower_ids
+msgid "Followers"
+msgstr "المتابعين"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_partner_ids
+msgid "Followers (Partners)"
+msgstr "المتابعين (الشركاء) "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+msgid "For everyone:"
+msgstr "للجميع: "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+msgid "For me:"
+msgstr "لي: "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__force_partials
+msgid "Force Partials"
+msgstr "فرض عمليات التسوية الجزئية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__force_price_included_taxes
+msgid "Force Price Included Taxes"
+msgstr "فرض الأسعار شاملة الضريبة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__transaction_currency_id
+msgid "Foreign Currency"
+msgstr "عملة أجنبية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__form_index
+msgid "Form Index"
+msgstr "فهرس النموذج"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__from_date
+msgid "From"
+msgstr "من"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+msgid "From Trade Payable accounts"
+msgstr "من الحسابات الدائنة التجارية "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+msgid "From Trade Receivable accounts"
+msgstr "من الحسابات المدينة التجارية "
+
+#. module: odex30_account_accountant
+#: model_terms:digest.tip,tip_description:odex30_account_accountant.digest_tip_account_accountant_0
+msgid ""
+"From any list view, select multiple records and the list becomes editable. "
+"If you update a cell, selected records are updated all at once. Use this "
+"feature to update multiple journal entries from the General Ledger, or any "
+"Journal view."
+msgstr ""
+"قم باختيار سجلات متعددة من أي نافذة عرض القائمة، وستصبح القائمة قابلة "
+"للتحرير. إذا قمت بتحديث إحدى الخلايا، يتم تحديث كافة السجلات المختارة دفعة "
+"واحدة. استخدم هذه الخاصية لتحديث عدة بنود في اليومية من دفتر الأستاذ العام "
+"أو أي نافذة عرض لليومية. "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__deferred_expense_amount_computation_method__full_months
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__deferred_revenue_amount_computation_method__full_months
+msgid "Full Months"
+msgstr "أشهر كاملة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__generate_deferred_expense_entries_method
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__generate_deferred_expense_entries_method
+msgid "Generate Deferred Expense Entries"
+msgstr "إنشاء قيود النفقات المؤجلة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__generate_deferred_revenue_entries_method
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__generate_deferred_revenue_entries_method
+msgid "Generate Deferred Revenue Entries"
+msgstr "إنشاء قيود الإيرادات المؤجلة "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form
+msgid "Generate Entries"
+msgstr "إنشاء القيود"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+msgid "Group By"
+msgstr "تجميع حسب"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__group_tax_id
+msgid "Group Tax"
+msgstr "مجموعة الضريبة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__hard_lock_date
+msgid "Hard Lock"
+msgstr "تاريخ القفل الثابت "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move_line__has_abnormal_deferred_dates
+msgid "Has Abnormal Deferred Dates"
+msgstr "يحتوي على تواريخ مؤجلة غير معتادة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move_line__has_deferred_moves
+msgid "Has Deferred Moves"
+msgstr "يحتوي على حركات مؤجلة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__has_message
+msgid "Has Message"
+msgstr "يحتوي على رسالة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__id
+msgid "ID"
+msgstr "المُعرف"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_bank_statement__message_needaction
+msgid "If checked, new messages require your attention."
+msgstr "إذا كان محددًا، فهناك رسائل جديدة عليك رؤيتها. "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_bank_statement__message_has_error
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_bank_statement__message_has_sms_error
+msgid "If checked, some messages have a delivery error."
+msgstr "إذا كان محددًا، فقد حدث خطأ في تسليم بعض الرسائل."
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_bank_rec_widget__st_line_checked
+msgid ""
+"If this checkbox is not ticked, it means that the user was not sure of all "
+"the related information at the time of the creation of the move and that the "
+"move needs to be checked again."
+msgstr ""
+"إذا لم يكن هذا المربع محدداً، هذا يعني أن المستخدم لم يكن متأكداً من كافة "
+"المعلومات ذات الصلة في الوقت الذي أُنشئت فيه الحركة، وأنه يجب التحقق من "
+"الحركة مجدداً. "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+msgid "In Company Currency"
+msgstr "بعملة الشركة "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree
+msgid "In Currency"
+msgstr "بالعملة "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+msgid "In Foreign Currency"
+msgstr "بالعملة الأجنبية "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_search_bank_rec_widget
+msgid "Incoming"
+msgstr "واردة "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/res_config_settings.py:0
+msgid ""
+"Incorrect fiscal year date: day is out of range for month. Month: %(month)s; "
+"Day: %(day)s"
+msgstr ""
+"تاريخ السنة المالية غير صحيح: اليوم المدخل غير موجود في هذا الشهر. الشهر: %"
+"(month)s;اليوم:%(day)s "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__index
+msgid "Index"
+msgstr "الفهرس "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/demo/odex30_account_accountant_demo.py:0
+msgid "Insurance 12 months"
+msgstr "ضمان لمدة 12 شهر "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget__state__invalid
+msgid "Invalid"
+msgstr "غير صالح "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget
+msgid "Invalid statements"
+msgstr "كشوفات الحساب غير صالحة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_bank_rec_widget__state
+msgid ""
+"Invalid: The bank transaction can't be validate since the suspense account "
+"is still involved\n"
+"Valid: The bank transaction can be validated.\n"
+"Reconciled: The bank transaction has already been processed. Nothing left to "
+"do."
+msgstr ""
+"غير صالح: لا يمكن تصديق المعاملة البنكية بما أن الحساب المعلق لا يزال "
+"موجوداً\n"
+"صالح: يمكن تصديق المعاملة البنكية.\n"
+"تمت التسوية: لقد تمت تسوية المعاملة البنكية بالفعل. لم يتبقَّ شيء للقيام به. "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_list_bank_rec_widget
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_search_bank_rec_widget
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree
+msgid "Invoice Date"
+msgstr "تاريخ الفاتورة"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__invoicing_switch_threshold
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__invoicing_switch_threshold
+msgid "Invoicing Switch Threshold"
+msgstr "الحد الأدنى لتبديل الفوترة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_is_follower
+msgid "Is Follower"
+msgstr "متابع"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__is_multi_currency
+msgid "Is Multi Currency"
+msgstr "متعدد العملات "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__is_rec_pay_account
+msgid "Is Rec Pay Account"
+msgstr "حساب دفع التسوية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__st_line_is_reconciled
+msgid "Is Reconciled"
+msgstr "تمت تسويته "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__is_write_off_required
+msgid "Is a write-off move required to reconcile"
+msgstr "حركة الشطب مطلوبة للمساواة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__is_transfer_required
+msgid "Is an account transfer required"
+msgstr "تحويل الحساب مطلوب "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__transfer_warning_message
+msgid "Is an account transfer required to reconcile"
+msgstr "تحويل الحساب مطلوب للتسوية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__lock_date_violated_warning_message
+msgid "Is the date violating the lock date of moves"
+msgstr "التاريخ يتعدى على تاريخ إقفال الحركات "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/wizard/account_change_lock_date.py:0
+msgid "It is not possible to decrease or remove the Hard Lock Date."
+msgstr "لا يمكن تقديم تاريخ القفل الثابت أو إزالته. "
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_account_journal
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__journal_id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__st_line_journal_id
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget
+msgid "Journal"
+msgstr "دفتر اليومية"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__journal_currency_id
+msgid "Journal Currency"
+msgstr "عملة اليومية "
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_account_move
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__move_id
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_search_bank_rec_widget
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree
+msgid "Journal Entry"
+msgstr "قيد اليومية"
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_account_move_line
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_search_bank_rec_widget
+msgid "Journal Item"
+msgstr "عنصر اليومية"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree
+msgid "Journal Items"
+msgstr "عناصر اليومية"
+
+#. module: odex30_account_accountant
+#: model:ir.actions.act_window,name:odex30_account_accountant.action_move_line_posted_unreconciled
+msgid "Journal Items to reconcile"
+msgstr "عناصر دفتر اليومية المُراد تسويتها "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+msgid "Journal items where matching number isn't set"
+msgstr "عناصر دفتر اليومية التي لم يتم تعيين رقم مطابق لها "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+msgid ""
+"Journal items where the account allows reconciliation no matter the residual "
+"amount"
+msgstr ""
+"بنود دفتر اليومية حيث يسمح الحساب بالتسوية بغض النظر عن المبلغ المتبقي "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__deferred_expense_journal_id
+#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__deferred_revenue_journal_id
+msgid "Journal used for deferred entries"
+msgstr "دفتر اليومية المستخدم للقيود المؤجلة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_digest_digest__kpi_account_bank_cash_value
+msgid "Kpi Account Bank Cash Value"
+msgstr "حساب المؤشر الرئيسي للأداء للقيمة النقدية للبنك "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__label
+msgid "Label"
+msgstr "بطاقة عنوان"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form
+msgid "Last Day"
+msgstr "اليوم الأخير"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.account_journal_dashboard_kanban_view
+msgid "Last Statement"
+msgstr "آخر كشف حساب "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__write_uid
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__write_uid
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__write_uid
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__write_uid
+msgid "Last Updated by"
+msgstr "آخر تحديث بواسطة"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__write_date
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__write_date
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__write_date
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__write_date
+msgid "Last Updated on"
+msgstr "آخر تحديث في"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.account_journal_dashboard_kanban_view
+msgid "Latest Statement"
+msgstr "أحدث كشف حساب "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form
+msgid "Legal signatory"
+msgstr "الطرف الموقِّع القانوني "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0
+msgid "Let’s go back to the dashboard."
+msgstr "فلنعد إلى لوحة البيانات. "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__line_ids
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__line_ids
+msgid "Line"
+msgstr "البند "
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_bank_rec_widget_line
+msgid "Line of the bank reconciliation widget"
+msgstr "بند أداة التسوية البنكية "
+
+#. module: odex30_account_accountant
+#: model:ir.ui.menu,name:odex30_account_accountant.menu_action_change_lock_date
+msgid "Lock Dates"
+msgstr "تواريخ الإقفال"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__fiscalyear_lock_date
+msgid "Lock Everything"
+msgstr "قفل الكل "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__fiscalyear_lock_date_for_everyone
+msgid "Lock Everything For Everyone"
+msgstr "إقفال كل شيء للجميع "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__fiscalyear_lock_date_for_me
+msgid "Lock Everything For Me"
+msgstr "إقفال كل شيء بالنسبة لي "
+
+#. module: odex30_account_accountant
+#: model:ir.actions.act_window,name:odex30_account_accountant.action_view_account_change_lock_date
+msgid "Lock Journal Entries"
+msgstr "إقفال قيود اليومية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__purchase_lock_date
+msgid "Lock Purchases"
+msgstr "إقفال المشتريات "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__purchase_lock_date_for_everyone
+msgid "Lock Purchases For Everyone"
+msgstr "إقفال المشتريات للجميع "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__purchase_lock_date_for_me
+msgid "Lock Purchases For Me"
+msgstr "إقفال المشتريات بالنسبة لي "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__sale_lock_date
+msgid "Lock Sales"
+msgstr "إقفال المبيعات "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__sale_lock_date_for_everyone
+msgid "Lock Sales For Everyone"
+msgstr "إقفال المبيعات للجميع "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__sale_lock_date_for_me
+msgid "Lock Sales For Me"
+msgstr "إقفال المبيعات بالنسبة لي "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__tax_lock_date
+msgid "Lock Tax Return"
+msgstr "إقفال الإقرار الضريبي "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__tax_lock_date_for_everyone
+msgid "Lock Tax Return For Everyone"
+msgstr "إقفال الإقرار الضريبي للجميع "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__tax_lock_date_for_me
+msgid "Lock Tax Return For Me"
+msgstr "إقفال الإقرار الضريبي بالنسبة لي "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_main_attachment_id
+msgid "Main Attachment"
+msgstr "المرفق الرئيسي"
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+msgid "Manual Operations"
+msgstr "العمليات اليدوية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__generate_deferred_expense_entries_method__manual
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__generate_deferred_revenue_entries_method__manual
+msgid "Manually & Grouped"
+msgstr "يدوياً ومجمع "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__manually_modified
+msgid "Manually Modified"
+msgstr "تم التعديل يدوياً "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/list_view_switcher.js:0
+msgid "Match"
+msgstr "مطابقة"
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+msgid "Match Existing Entries"
+msgstr "مطابقة القيود الموجودة "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_kanban_bank_rec_widget
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget
+msgid "Matched"
+msgstr "تمت المطابقة "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_payment.py:0
+msgid "Matched Transactions"
+msgstr "المعاملات المتطابقة "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree
+msgid "Matching"
+msgstr "مطابقة"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__matching_rules_allow_auto_reconcile
+msgid "Matching Rules Allow Auto Reconcile"
+msgstr "تسمح قواعد المطابقة بالتسوية التلقائية "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_quick_create_form_bank_rec_widget
+msgid "Memo"
+msgstr "مذكرة "
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_ir_ui_menu
+msgid "Menu"
+msgstr "القائمة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_has_error
+msgid "Message Delivery error"
+msgstr "خطأ في تسليم الرسائل"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_ids
+msgid "Messages"
+msgstr "الرسائل"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__deferred_expense_amount_computation_method
+#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__deferred_revenue_amount_computation_method
+msgid "Method used to compute the amount of deferred entries"
+msgstr "الطريقة المستخدمة لاحتساب مبلغ القيود المؤجلة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__generate_deferred_expense_entries_method
+#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__generate_deferred_revenue_entries_method
+msgid "Method used to generate deferred entries"
+msgstr "الطريقة المستخدمة لإنشاء القيود المؤجلة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__min_fiscalyear_lock_date_exception_for_everyone_id
+msgid "Min Fiscalyear Lock Date Exception For Everyone"
+msgstr "الحد الأدنى لاستثناء تاريخ إقفال السنة المالية للجميع "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__min_fiscalyear_lock_date_exception_for_me_id
+msgid "Min Fiscalyear Lock Date Exception For Me"
+msgstr "الحد الأدنى لاستثناء تاريخ إقفال السنة المالية بالنسبة لي "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__min_purchase_lock_date_exception_for_everyone_id
+msgid "Min Purchase Lock Date Exception For Everyone"
+msgstr "الحد الأدنى لاستثناء تاريخ إقفال المشتريات للجميع "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__min_purchase_lock_date_exception_for_me_id
+msgid "Min Purchase Lock Date Exception For Me"
+msgstr "الحد الأدنى لاستثناء تاريخ إقفال المشتريات بالنسبة لي "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__min_sale_lock_date_exception_for_everyone_id
+msgid "Min Sale Lock Date Exception For Everyone"
+msgstr "الحد الأدنى لاستثناء تاريخ إقفال المبيعات للجميع "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__min_sale_lock_date_exception_for_me_id
+msgid "Min Sale Lock Date Exception For Me"
+msgstr "الحد الأدنى لاستثناء تاريخ إقفال المبيعات بالنسبة لي "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__min_tax_lock_date_exception_for_everyone_id
+msgid "Min Tax Lock Date Exception For Everyone"
+msgstr "الحد الأدنى لاستثناء تاريخ الإقفال الضريبي للجميع "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__min_tax_lock_date_exception_for_me_id
+msgid "Min Tax Lock Date Exception For Me"
+msgstr "الحد الأدنى لاستثناء تاريخ الإقفال الضريبي بالنسبة لي "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/bank_rec_widget.py:0
+msgid "Misc"
+msgstr "متنوعات"
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_ir_model
+msgid "Models"
+msgstr "النماذج"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__deferred_expense_amount_computation_method__month
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__deferred_revenue_amount_computation_method__month
+msgid "Months"
+msgstr "شهور "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+msgid "More"
+msgstr "المزيد "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move_line__move_attachment_ids
+msgid "Move Attachment"
+msgstr "نقل المرفق "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__move_line_ids
+msgid "Move lines to reconcile"
+msgstr "بنود الحركات بانتظار التسوية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__name
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__name
+msgid "Name"
+msgstr "الاسم"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__narration
+msgid "Narration"
+msgstr ""
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+msgid "Need an irreversible lock to ensure inalterability, for all users?"
+msgstr "هل تحتاج إلى قفل دائم لضمان عدم قابلية التغيير لكافة المستخدمين؟ "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+msgid "New"
+msgstr "جديد"
+
+#. module: odex30_account_accountant
+#: model:ir.actions.act_window,name:odex30_account_accountant.action_bank_statement_line_form_bank_rec_widget
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_form_bank_rec_widget
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_quick_create_form_bank_rec_widget
+msgid "New Transaction"
+msgstr "معاملة جديدة "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/move_line_list/move_line_list.xml:0
+msgid "No attachments linked."
+msgstr "لم يتم ربط أي مرفقات. "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget
+msgid "No statement"
+msgstr "لا يوجد كشف حساب "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.actions.act_window,help:odex30_account_accountant.action_bank_statement_line_transactions
+#: model_terms:ir.actions.act_window,help:odex30_account_accountant.action_bank_statement_line_transactions_kanban
+msgid "No transactions matching your filters were found."
+msgstr "لم يتم العثور على أي معاملات تطابق عوامل التصفية الخاصة بك. "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget
+msgid "Not Matched"
+msgstr "غير مطابق "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+msgid "Not locked"
+msgstr "لم يتم إقفاله "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_tree_bank_rec_widget
+msgid "Notes"
+msgstr "الملاحظات"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.actions.act_window,help:odex30_account_accountant.action_bank_statement_line_transactions
+#: model_terms:ir.actions.act_window,help:odex30_account_accountant.action_bank_statement_line_transactions_kanban
+msgid "Nothing to do here!"
+msgstr "لا شيء لتفعله هنا! "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0
+msgid "Now, we'll create your first invoice (accountant)"
+msgstr "والآن، سوف نقوم بإنشاء فاتورتك الأولى (محاسب) "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_needaction_counter
+msgid "Number of Actions"
+msgstr "عدد الإجراءات"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_has_error_counter
+msgid "Number of errors"
+msgstr "عدد الأخطاء "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_bank_statement__message_needaction_counter
+msgid "Number of messages requiring action"
+msgstr "عدد الرسائل التي تتطلب اتخاذ إجراء"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_bank_statement__message_has_error_counter
+msgid "Number of messages with delivery error"
+msgstr "عدد الرسائل الحادث بها خطأ في التسليم"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__generate_deferred_expense_entries_method__on_validation
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__generate_deferred_revenue_entries_method__on_validation
+msgid "On bill validation"
+msgstr "عند تصديق فاتورة المورّد "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/wizard/account_change_lock_date.py:0
+msgid "Only Billing Administrators are allowed to change lock dates!"
+msgstr "مديرو الفوترة وحدهم المصرح لهم بتغيير تواريخ الإقفال! "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_wizard
+msgid ""
+"Only partial reconciliation is possible. Proceed in multiple steps if you "
+"want to full reconcile."
+msgstr ""
+"يُسمَح بالتسوية الجزئية فقط. يمكنك الاستمرار بعدة خطوات إذا أردت التسوية "
+"الكلية. "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/move_line_list/move_line_list.xml:0
+msgid "Open attachment in pop out"
+msgstr "فتح المرفق في نافذة منبثقة "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/bank_rec_widget.py:0
+msgid "Open balance of %(amount)s"
+msgstr "رصيد مفتوح بقيمة %(amount)s "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_move.py:0
+msgid "Original Deferred Entries"
+msgstr "القيود المؤجلة الأصلية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement_line__deferred_original_move_ids
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move__deferred_original_move_ids
+msgid "Original Invoices"
+msgstr "الفواتير الأصلية "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree
+msgid "Originator Tax"
+msgstr "ضريبة المُنشئ "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_search_bank_rec_widget
+msgid "Outgoing"
+msgstr "الصادرة "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__to_partner_id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__partner_id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__partner_id
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_quick_create_form_bank_rec_widget
+msgid "Partner"
+msgstr "الشريك"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__partner_currency_id
+msgid "Partner Currency"
+msgstr "عملة الشريك "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__partner_name
+msgid "Partner Name"
+msgstr "اسم الشريك"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__partner_payable_account_id
+msgid "Partner Payable Account"
+msgstr "حساب الشريك الدائن "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__partner_payable_amount
+msgid "Partner Payable Amount"
+msgstr "مبلغ الشريك الدائن "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__partner_receivable_account_id
+msgid "Partner Receivable Account"
+msgstr "حساب الشريك المدين "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__partner_receivable_amount
+msgid "Partner Receivable Amount"
+msgstr "مبلغ الشريك المدين "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__partner_ids
+msgid "Partners"
+msgstr "الشركاء"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+msgid "Payable"
+msgstr "الدائن"
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+msgid "Payable:"
+msgstr "الدائن: "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_payment_form_inherit_account_accountant
+msgid "Payment Matching"
+msgstr "مطابقة المدفوعات"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement_line__payment_state_before_switch
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move__payment_state_before_switch
+msgid "Payment State Before Switch"
+msgstr "حالة الدفع قبل التحويل "
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_account_payment
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget
+msgid "Payments"
+msgstr "الدفعات"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.account_journal_dashboard_kanban_view
+msgid "Payments Matching"
+msgstr "مطابقة المدفوعات"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_auto_reconcile_wizard__search_mode__one_to_one
+msgid "Perfect Match"
+msgstr "مطابق تماماً "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_move.py:0
+msgid "Please set the deferred accounts in the accounting settings."
+msgstr "يرجى تعيين الحسابات المؤجلة في إعدادات المحاسبة. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_move.py:0
+msgid "Please set the deferred journal in the accounting settings."
+msgstr "يرجى إعداد دفتر اليومية المؤجل في إعدادات المحاسبة. "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__predict_bill_product
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__predict_bill_product
+msgid "Predict Bill Product"
+msgstr "توقع منتج الفاتورة "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form
+msgid "Predict vendor bill product"
+msgstr "توقع منتج فاتورة المورّد "
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_account_reconcile_model
+msgid ""
+"Preset to create journal entries during a invoices and payments matching"
+msgstr "الإعداد المسبق لإنشاء قيود يومية خلال مطابقة الفواتير والدفعات"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__rating_ids
+msgid "Ratings"
+msgstr "التقييمات "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+msgid "Reason..."
+msgstr "السبب..."
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+msgid "Receivable"
+msgstr "المدين"
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+msgid "Receivable:"
+msgstr "المدين: "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__search_mode
+#: model:ir.ui.menu,name:odex30_account_accountant.menu_account_reconcile
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_auto_reconcile_wizard
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_wizard
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_payment_tree
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_tree
+msgid "Reconcile"
+msgstr "تسوية"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_wizard
+msgid "Reconcile & open"
+msgstr "تسوية وفتح "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__reco_account_id
+msgid "Reconcile Account"
+msgstr "تسوية الحساب "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__reconcile_model_id
+msgid "Reconcile Model"
+msgstr "نموذج التسوية "
+
+#. module: odex30_account_accountant
+#: model:ir.actions.act_window,name:odex30_account_accountant.action_open_auto_reconcile_wizard
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_auto_reconcile_wizard
+msgid "Reconcile automatically"
+msgstr "التسوية تلقائياً "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_auto_reconcile_wizard__search_mode
+msgid ""
+"Reconcile journal items with opposite balance or clear accounts with a zero "
+"balance"
+msgstr ""
+"تسوية بنود دفتر اليومية ذات الرصيد المعاكس أو تصفية الحسابات ذات الرصيد "
+"الصفري "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget__state__reconciled
+msgid "Reconciled"
+msgstr "تمت التسوية"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__reco_model_id
+msgid "Reconciliation model"
+msgstr "نموذج التسوية "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form
+msgid "Record cost of goods sold in your journal entries"
+msgstr "سجّل تكاليف البضاعة المباعة في قيود اليومية الخاصة بك "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__ref
+msgid "Ref"
+msgstr "المرجع "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+msgid "Reference"
+msgstr "الرقم المرجعي "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_form_inherit
+msgid "Related Purchase(s)"
+msgstr "عمليات الشراء ذات الصلة "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_form_inherit
+msgid "Related Sale(s)"
+msgstr "المبيعات ذات الصلة "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+msgid "Reset"
+msgstr "إعادة الضبط "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree
+msgid "Residual"
+msgstr "المتبقي"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree
+msgid "Residual in Currency"
+msgstr "المتبقي بالعملة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__return_todo_command
+msgid "Return Todo Command"
+msgstr "إرجاع أمر قائمة المهام "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+msgid "Review"
+msgstr "مراجعة"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+msgid "Revoke"
+msgstr "إلغاء "
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_account_reconcile_model_line
+msgid "Rules for the reconciliation model"
+msgstr "قواعد نموذج التسوية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_has_sms_error
+msgid "SMS Delivery error"
+msgstr "خطأ في تسليم الرسائل النصية القصيرة "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+msgid "Save"
+msgstr "حفظ"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_form_bank_rec_widget
+msgid "Save & Close"
+msgstr "حفظ وإغلاق"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_form_bank_rec_widget
+msgid "Save & New"
+msgstr "حفظ و جديد"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+msgid "Search Journal Items to Reconcile"
+msgstr "البحث عن القيود اليومية المُراد تسويتها "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__signing_user
+msgid ""
+"Select a user here to override every signature on invoice by this user's "
+"signature"
+msgstr ""
+"قم بتحديد مستخدم هنا لاستبدال كل توقيع في الفاتورة بتوقيع هذا المستخدم "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__selected_aml_ids
+msgid "Selected Aml"
+msgstr "Aml المحددة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__selected_reco_model_id
+msgid "Selected Reco Model"
+msgstr "تحديد نموذج التسوية "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0
+msgid "Set an amount."
+msgstr "قم بتعيين مبلغ. "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+msgid "Set as Checked"
+msgstr "التعيين كتمّ التحقق منه "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0
+msgid "Set the payment reference."
+msgstr "قم بتعيين مرجع الدفع. "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__show_draft_entries_warning
+msgid "Show Draft Entries Warning"
+msgstr "إظهار تحذير القيود بحالة المسودة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement_line__show_signature_area
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move__show_signature_area
+msgid "Show Signature Area"
+msgstr "إظهار مكان التوقيع "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__module_sign
+msgid "Sign"
+msgstr "توقيع"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement_line__signature
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move__signature
+msgid "Signature"
+msgstr "التوقيع "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__signing_user
+msgid "Signature used to sign all the invoice"
+msgstr "التوقيع المستَخدَم للتوقيع على الفاتورة بأكملها "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement_line__signing_user
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move__signing_user
+msgid "Signer"
+msgstr "الطرف الموقِّع "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__signing_user
+msgid "Signing User"
+msgstr "المُستخدِم الموقِّع "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__single_currency_mode
+msgid "Single Currency Mode"
+msgstr "وضع العملة الواحدة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__source_aml_id
+msgid "Source Aml"
+msgstr "Aml المصدرية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__source_aml_move_id
+msgid "Source Aml Move"
+msgstr "حركة Aml المصدرية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__source_aml_move_name
+msgid "Source Aml Move Name"
+msgstr "اسم حركة Aml المصدرية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__source_amount_currency
+msgid "Source Amount Currency"
+msgstr "عملة المبلغ المصدري "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__source_balance
+msgid "Source Balance"
+msgstr "الرصيد المصدري "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__source_credit
+msgid "Source Credit"
+msgstr "مصدر الائتمان"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__source_debit
+msgid "Source Debit"
+msgstr "مصدر الخصم"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__source_rate
+msgid "Source Rate"
+msgstr ""
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__st_line_id
+msgid "St Line"
+msgstr "بند كشف الحساب "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__st_line_transaction_details
+msgid "St Line Transaction Details"
+msgstr "تفاصيل معاملة بند كشف الحساب "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__date_from
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move_line__deferred_start_date
+msgid "Start Date"
+msgstr "تاريخ البدء "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_fiscal_year__date_from
+msgid "Start Date, included in the fiscal year."
+msgstr "تاريخ البداية، ضمن السنة المالية. "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__state
+msgid "State"
+msgstr "الحالة"
+
+#. module: odex30_account_accountant
+#: model:ir.actions.server,name:odex30_account_accountant.action_bank_statement_attachment
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_kanban_bank_rec_widget
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget
+msgid "Statement"
+msgstr "كشف الحساب"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget
+msgid "Statement Line"
+msgstr "بند كشف الحساب"
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/demo/odex30_account_accountant_demo.py:0
+msgid "Subscription 12 months"
+msgstr "اشتراك لمدة 12 شهر "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__suggestion_amount_currency
+msgid "Suggestion Amount Currency"
+msgstr "عملة المبلغ المقترح "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__suggestion_balance
+msgid "Suggestion Balance"
+msgstr "الرصيد المقترح "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__suggestion_html
+msgid "Suggestion Html"
+msgstr "Html المقترح "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_list_bank_rec_widget
+msgid "Suggestions"
+msgstr "الاقتراحات "
+
+#. module: odex30_account_accountant
+#: model:ir.model,name:odex30_account_accountant.model_account_tax
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__tax_id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__tax_ids
+msgid "Tax"
+msgstr "الضريبة"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__tax_base_amount_currency
+msgid "Tax Base Amount Currency"
+msgstr "عملة المبلغ الأساسي للضريبة "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree
+msgid "Tax Grids"
+msgstr "شبكات الضرائب"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__tax_repartition_line_id
+msgid "Tax Repartition Line"
+msgstr "بند التوزيع الضريبي"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__tax_tag_ids
+msgid "Tax Tag"
+msgstr "علامة تصنيف الضريبة "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0
+msgid "Taxes"
+msgstr "الضرائب"
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/rainbowman_content.xml:0
+msgid "That's on average"
+msgstr "هذا في المتوسط"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_bank_rec_widget__country_code
+#: model:ir.model.fields,help:odex30_account_accountant.field_bank_rec_widget_line__country_code
+msgid ""
+"The ISO country code in two chars. \n"
+"You can use this field for quick search."
+msgstr ""
+"كود الدولة حسب المعيار الدولي أيزو المكون من حرفين.\n"
+"يمكنك استخدام هذا الحقل لإجراء بحث سريع."
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_bank_rec_widget_line__amount_transaction_currency
+msgid ""
+"The amount expressed in an optional other currency if it is a multi-currency "
+"entry."
+msgstr "يتم عرض المبلغ بعملة اختيارية أخرى إذا كان قيداً متعدد العملات. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0
+msgid ""
+"The amount of the write-off of a single credit line should be strictly "
+"negative."
+msgstr "يجب أن يكون مبلغ الشطب لبند ائتماني واحد قيمة سالبة فقط. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0
+msgid ""
+"The amount of the write-off of a single debit line should be strictly "
+"positive."
+msgstr "يجب أن يكون مبلغ الشطب لبند خصم واحد قيمة موجبة فقط. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0
+msgid "The amount of the write-off of a single line cannot be 0."
+msgstr "لا يمكن أن يكون مبلغ الشطب لبند واحد 0. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0
+msgid ""
+"The date you set violates the lock date of one of your entry. It will be "
+"overriden by the following date : %(replacement_date)s"
+msgstr ""
+"التاريخ الذي قمت بتحديده يتضارب مع تاريخ الإقفال لإحدى قيودك. سيتم تجاوزه من "
+"قِبَل التاريخ التالي: %(replacement_date)s"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_bank_statement_line__deferred_move_ids
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_move__deferred_move_ids
+msgid "The deferred entries created by this invoice"
+msgstr "القيود المؤجلة التي تم إنشاؤها من قِبَل هذه الفاتورة "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_fiscal_year.py:0
+msgid "The ending date must not be prior to the starting date."
+msgstr "يجب ألا يقع تاريخ الانتهاء قبل تاريخ البداية. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/bank_rec_widget_line.py:0
+msgid ""
+"The invoice %(display_name_html)s with an open amount of %(open_amount)s "
+"will be entirely paid by the transaction."
+msgstr ""
+"الفاتورة %(display_name_html)s التي بها مبلغ مفتوح قيمته %(open_amount)s "
+"سيتم دفعها كاملة بواسطة المعاملة. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/bank_rec_widget_line.py:0
+msgid ""
+"The invoice %(display_name_html)s with an open amount of %(open_amount)s "
+"will be reduced by %(amount)s."
+msgstr ""
+"الفاتورة %(display_name_html)s مع مبلغ مفتوح قدره %(open_amount)s سيتم "
+"تقليله بـ %(amount)s. "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form
+msgid ""
+"The invoices before this date will not be taken into account as accounting "
+"entries"
+msgstr "لن يتم اعتبار الفواتير قبل هذا التاريخ كقيود محاسبية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_bank_rec_widget_line__transaction_currency_id
+msgid "The optional other currency if it is a multi-currency entry."
+msgstr "العملة الاختيارية الأخرى إذا كان القيد متعدد العملات."
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_bank_statement_line__deferred_original_move_ids
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_move__deferred_original_move_ids
+msgid "The original invoices that created the deferred entries"
+msgstr "الفواتير الأصلية التي قامت بإنشاء القيود المؤجلة "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form
+msgid ""
+"The system will try to predict the product on vendor bill lines based on the "
+"label of the line"
+msgstr "سيحاول النظام توقع المنتج في بنود فاتورة المورد بناءً على عنوان البند. "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date
+msgid ""
+"There are still draft entries in the period you want to lock.\n"
+" You should either post or delete them."
+msgstr ""
+"لا تزال هناك قيود بحالة المسودة في الفترة التي تريد إقفالها.\n"
+" عليك إما ترحيلها أو حذفها. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_bank_statement.py:0
+msgid ""
+"This bank transaction has been automatically validated using the "
+"reconciliation model '%s'."
+msgstr ""
+"هذه المعاملة البنكية قد تم تصديقها تلقائياً باستخدام نموذج التسوية '%s'. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/bank_rec_widget.py:0
+msgid ""
+"This bank transaction is locked up tighter than a squirrel in a nut factory! "
+"You can't hit the reset button on it. So, do you want to \"unreconcile\" it "
+"instead?"
+msgstr ""
+"هذه المعاملة البنكية مغلقة بإحكام أكثر من السنجاب في مصنع الجوز! لا يمكنك "
+"الضغط على زر إعادة ضبطها. لذا، أترغب في \"إلغاءها\" عوضاً عن ذلك؟ "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0
+msgid "This can only be used on journal items"
+msgstr "يمكن استخدام ذلك فقط في عناصر اليومية "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_reconcile_model_line.py:0
+msgid ""
+"This reconciliation model can't be used in the manual reconciliation widget "
+"because its configuration is not adapted"
+msgstr ""
+"لا يمكن استخدام نموذج التسوية في أداة التسوية اليدوية لعدم اعتماد تهيئته "
+
+#. module: odex30_account_accountant
+#: model:digest.tip,name:odex30_account_accountant.digest_tip_account_accountant_0
+msgid "Tip: Bulk update journal items"
+msgstr "نصيحة: قم بتحديث قيود اليومية بالجملة "
+
+#. module: odex30_account_accountant
+#: model:digest.tip,name:odex30_account_accountant.digest_tip_account_accountant_1
+msgid "Tip: Find an Accountant or register your Accounting Firm"
+msgstr "نصيحة: ابحث عن محاسب أو قم بتسجيل شركتك المحاسبة الخاصة بك "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__to_date
+msgid "To"
+msgstr "إلى"
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__to_check
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget
+msgid "To Check"
+msgstr "للتحقق منه "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_kanban_bank_rec_widget
+msgid "To check"
+msgstr "للتحقق منه "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form
+msgid "To enhance authenticity, add a signature to your invoices"
+msgstr "لتعزيز صحة مستنداتك، أضف توقيعاً إلى فواتيرك "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__todo_command
+msgid "Todo Command"
+msgstr "أمر قائمة المهام "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree
+msgid "Total Balance"
+msgstr "الرصيد الكلي "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree
+msgid "Total Credit"
+msgstr "إجمالي الائتمان"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree
+msgid "Total Debit"
+msgstr "إجمالي الدين"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree
+msgid "Total Residual"
+msgstr "إجمالي المتبقي "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree
+msgid "Total Residual in Currency"
+msgstr "إجمالي المتبقي بالعملة "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget
+msgid "Transaction"
+msgstr "معاملة"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__transaction_currency_id
+msgid "Transaction Currency"
+msgstr "عملة المعاملة "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+msgid "Transaction Details"
+msgstr "تفاصيل المعاملة "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.account_journal_dashboard_kanban_view
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_tree
+msgid "Transactions"
+msgstr "المعاملات "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0
+msgid "Transfer from %s"
+msgstr "التحويل من %s"
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0
+msgid "Transfer to %s"
+msgstr "التحويل إلى %s"
+
+#. module: odex30_account_accountant
+#: model:ir.actions.server,name:odex30_account_accountant.auto_reconcile_bank_statement_line_ir_actions_server
+msgid "Try to reconcile automatically your statement lines"
+msgstr "حاول تسوية بنود كشف حسابك تلقائياً "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+msgid "Unreconciled"
+msgstr "غير المسواة"
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/res_company.py:0
+msgid "Unreconciled statements lines"
+msgstr "بنود كشف الحساب غير المسواة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget__state__valid
+msgid "Valid"
+msgstr "صالح"
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_model_widget_wizard
+msgid "Validate"
+msgstr "تصديق "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/list_view_switcher.js:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_list_bank_rec_widget
+msgid "View"
+msgstr "أداة العرض"
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/bank_rec_widget.py:0
+msgid "View Reconciled Entries"
+msgstr "عرض القيود المسواة "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+msgid "View models"
+msgstr "عرض النماذج "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__website_message_ids
+msgid "Website Messages"
+msgstr "رسائل الموقع الإلكتروني "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,help:odex30_account_accountant.field_account_bank_statement__website_message_ids
+msgid "Website communication history"
+msgstr "سجل تواصل الموقع الإلكتروني "
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search
+msgid "With residual"
+msgstr "مع متبقي "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__wizard_id
+msgid "Wizard"
+msgstr "المعالج"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__company_currency_id
+#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__company_currency_id
+msgid "Wizard Company Currency"
+msgstr "معالج عملة الشركة "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0
+msgid "Write-Off"
+msgstr "شطب"
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0
+msgid "Write-Off Entry"
+msgstr "شطب القيد "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_fiscal_year.py:0
+msgid ""
+"You can not have an overlap between two fiscal years, please correct the "
+"start and/or end dates of your fiscal years."
+msgstr ""
+"لا يمكن أن يكون هناك تداخل بين سنتين ماليتين، الرجاء تصحيح تواريخ بدء و/أو "
+"انتهاء سنواتك المالية. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0
+msgid "You can only reconcile entries with up to two different accounts: %s"
+msgstr "يمكنك فقط تسوية القيود حتى حسابين مختلفين: %s"
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/bank_rec_widget.py:0
+msgid "You can't hit the reset button on a secured bank transaction."
+msgstr "لا يمكنك الضغط على زر إعادة الضبط في معاملة بنكية مؤمَّنة. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_move.py:0
+msgid ""
+"You cannot change the account for a deferred line in %(move_name)s if it has "
+"already been deferred."
+msgstr ""
+"لا يمكنك تغيير الحساب للبند المؤجل %(move_name)s إذا كان مؤجلاً بالفعل. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_move.py:0
+msgid "You cannot create a deferred entry with a start date but no end date."
+msgstr "لا يمكنك إنشاء قيد مؤجل مع تاريخ بدء ودون تاريخ انتهاء. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_move.py:0
+msgid ""
+"You cannot create a deferred entry with a start date later than the end date."
+msgstr "لا يمكنك إنشاء قيد مؤجل مع تاريخ بدء أبعد من تاريخ انتهاء. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_move.py:0
+msgid "You cannot generate deferred entries for a miscellaneous journal entry."
+msgstr "لا يمكنك إنشاء قيود مؤجلة لقيد يومية من المتفرقات. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_fiscal_year.py:0
+msgid "You cannot have a fiscal year on a child company."
+msgstr "لا يمكن أن يكون لديك عام مالي في شركة تابعة. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/account_move.py:0
+msgid ""
+"You cannot reset to draft an invoice that is grouped in deferral entry. You "
+"can create a credit note instead."
+msgstr ""
+"لا يمكنك إعادة تعيين فاتورة قد تم تجميعها في قيد مؤجل إلى حالة المسودة. "
+"يمكنك إنشاء إشعار دائن عوضاً عن ذلك. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/wizard/account_change_lock_date.py:0
+msgid "You cannot set a Lock Date in the future."
+msgstr "لا يمكنك تعيين تاريخ إقفال في المستقبل. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/bank_rec_widget_line.py:0
+msgid "You might want to %(btn_start)sfully reconcile%(btn_end)s the document."
+msgstr "قد ترغب بإجراء %(btn_start)sالتسوية الكلية%(btn_end)s للمستند. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/bank_rec_widget_line.py:0
+msgid ""
+"You might want to make a %(btn_start)spartial reconciliation%(btn_end)s "
+"instead."
+msgstr "قد ترغب بإجراء %(btn_start)sالتسوية الجزئية%(btn_end)s عوضاً عن ذلك. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/bank_rec_widget_line.py:0
+msgid "You might want to record a %(btn_start)spartial payment%(btn_end)s."
+msgstr "قد ترغب بتسجيل %(btn_start)sالتسوية الجزئية%(btn_end)s. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/models/bank_rec_widget_line.py:0
+msgid ""
+"You might want to set the invoice as %(btn_start)sfully paid%(btn_end)s."
+msgstr "قد ترغب بتعيين الفاتورة كـ %(btn_start)sمدفوعة بالكامل%(btn_end)s. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/wizard/account_change_lock_date.py:0
+msgid "You need to select a duration for the exception."
+msgstr "عليك تحديد مدة للاستثناء. "
+
+#. module: odex30_account_accountant
+#. odoo-python
+#: code:addons/odex30_account_accountant/wizard/account_change_lock_date.py:0
+msgid "You need to select who the exception applies to."
+msgstr "عليك تحديد الأفراد الذين ينطبق عليهم الاستثناء. "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/rainbowman_content.xml:0
+msgid "You reconciled"
+msgstr "لقد قمت بتسوية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget_line__flag__aml
+msgid "aml"
+msgstr "aml"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget_line__flag__auto_balance
+msgid "auto_balance"
+msgstr "auto_balance"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_model_widget_wizard
+msgid "e.g. Bank Fees"
+msgstr "مثال: الرسوم البنكية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget_line__flag__early_payment
+msgid "early_payment"
+msgstr "early_payment"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget_line__flag__exchange_diff
+msgid "exchange_diff"
+msgstr "exchange_diff"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_change_lock_date__exception_duration__1h
+msgid "for 1 hour"
+msgstr "لمدة ساعة واحدة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_change_lock_date__exception_duration__15min
+msgid "for 15 minutes"
+msgstr "لمدة 15 دقيقة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_change_lock_date__exception_duration__24h
+msgid "for 24 hours"
+msgstr "لمدة 24 ساعة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_change_lock_date__exception_duration__5min
+msgid "for 5 minutes"
+msgstr "لمدة 5 دقائق "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_change_lock_date__exception_applies_to__everyone
+msgid "for everyone"
+msgstr "للجميع "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_change_lock_date__exception_applies_to__me
+msgid "for me"
+msgstr "لي "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_change_lock_date__exception_duration__forever
+msgid "forever"
+msgstr "للأبد "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0
+msgid "in"
+msgstr "في"
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget_line__flag__liquidity
+msgid "liquidity"
+msgstr "السيولة "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget_line__flag__manual
+msgid "manual"
+msgstr "اليدوية "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget_line__flag__new_aml
+msgid "new_aml"
+msgstr "new_aml"
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/rainbowman_content.xml:0
+msgid "seconds per transaction."
+msgstr "ثوان لكل معاملة. "
+
+#. module: odex30_account_accountant
+#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget_line__flag__tax_line
+msgid "tax_line"
+msgstr "tax_line"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.account_journal_dashboard_kanban_view
+msgid "to check"
+msgstr "للتفقد"
+
+#. module: odex30_account_accountant
+#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.account_journal_dashboard_kanban_view
+msgid "to reconcile"
+msgstr "بانتظار التسوية "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/rainbowman_content.xml:0
+msgid "transaction in"
+msgstr "معاملة في "
+
+#. module: odex30_account_accountant
+#. odoo-javascript
+#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/rainbowman_content.xml:0
+msgid "transactions in"
+msgstr "معاملات في "
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__init__.py b/dev_odex30_accounting/odex30_account_accountant/models/__init__.py
new file mode 100644
index 0000000..ca01d9a
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/models/__init__.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import account_account
+from . import account_bank_statement
+from . import account_chart_template
+from . import account_fiscal_year
+from . import account_journal_dashboard
+from . import account_move
+from . import account_payment
+from . import account_reconcile_model
+from . import account_reconcile_model_line
+from . import account_tax
+from . import digest
+from . import res_config_settings
+from . import res_company
+from . import bank_rec_widget
+from . import bank_rec_widget_line
+from . import ir_ui_menu
+from . import ir_model
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..79af41b
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/__init__.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_account.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_account.cpython-311.pyc
new file mode 100644
index 0000000..7111947
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_account.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_bank_statement.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_bank_statement.cpython-311.pyc
new file mode 100644
index 0000000..7c50e6a
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_bank_statement.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_chart_template.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_chart_template.cpython-311.pyc
new file mode 100644
index 0000000..a080f33
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_chart_template.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_fiscal_year.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_fiscal_year.cpython-311.pyc
new file mode 100644
index 0000000..e83ec3c
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_fiscal_year.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_journal_dashboard.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_journal_dashboard.cpython-311.pyc
new file mode 100644
index 0000000..3031d95
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_journal_dashboard.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_move.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_move.cpython-311.pyc
new file mode 100644
index 0000000..3f915e0
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_move.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_payment.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_payment.cpython-311.pyc
new file mode 100644
index 0000000..554b4f6
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_payment.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_reconcile_model.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_reconcile_model.cpython-311.pyc
new file mode 100644
index 0000000..382e5c7
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_reconcile_model.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_reconcile_model_line.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_reconcile_model_line.cpython-311.pyc
new file mode 100644
index 0000000..08eb771
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_reconcile_model_line.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_tax.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_tax.cpython-311.pyc
new file mode 100644
index 0000000..c2663ab
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_tax.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/bank_rec_widget.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/bank_rec_widget.cpython-311.pyc
new file mode 100644
index 0000000..2ab1c38
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/bank_rec_widget.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/bank_rec_widget_line.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/bank_rec_widget_line.cpython-311.pyc
new file mode 100644
index 0000000..44ad487
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/bank_rec_widget_line.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/digest.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/digest.cpython-311.pyc
new file mode 100644
index 0000000..57baa46
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/digest.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/ir_model.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/ir_model.cpython-311.pyc
new file mode 100644
index 0000000..e8af2c8
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/ir_model.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/ir_ui_menu.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/ir_ui_menu.cpython-311.pyc
new file mode 100644
index 0000000..e820a3e
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/ir_ui_menu.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/res_company.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/res_company.cpython-311.pyc
new file mode 100644
index 0000000..aedf37f
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/res_company.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/res_config_settings.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/res_config_settings.cpython-311.pyc
new file mode 100644
index 0000000..8a54df3
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/res_config_settings.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_account.py b/dev_odex30_accounting/odex30_account_accountant/models/account_account.py
new file mode 100644
index 0000000..d60cbf1
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/models/account_account.py
@@ -0,0 +1,14 @@
+import ast
+from odoo import models
+
+
+class AccountAccount(models.Model):
+ _inherit = "account.account"
+
+ def action_open_reconcile(self):
+ self.ensure_one()
+ action_values = self.env['ir.actions.act_window']._for_xml_id('odex30_account_accountant.action_move_line_posted_unreconciled')
+ domain = ast.literal_eval(action_values['domain'])
+ domain.append(('account_id', '=', self.id))
+ action_values['domain'] = domain
+ return action_values
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_bank_statement.py b/dev_odex30_accounting/odex30_account_accountant/models/account_bank_statement.py
new file mode 100644
index 0000000..934c889
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/models/account_bank_statement.py
@@ -0,0 +1,223 @@
+import logging
+
+from odoo import _, api, fields, models
+from odoo.addons.base.models.res_bank import sanitize_account_number
+from odoo.exceptions import UserError
+from odoo.tools import html2plaintext
+
+from dateutil.relativedelta import relativedelta
+from itertools import product
+from lxml import etree
+from markupsafe import Markup
+
+_logger = logging.getLogger(__name__)
+
+class AccountBankStatement(models.Model):
+ _name = "account.bank.statement"
+ _inherit = ['mail.thread.main.attachment', 'account.bank.statement']
+
+ def action_open_bank_reconcile_widget(self):
+ self.ensure_one()
+ return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
+ name=self.name,
+ default_context={
+ 'search_default_statement_id': self.id,
+ 'search_default_journal_id': self.journal_id.id,
+ },
+ extra_domain=[('statement_id', '=', self.id)]
+ )
+
+ def action_generate_attachment(self):
+ ir_actions_report_sudo = self.env['ir.actions.report'].sudo()
+ statement_report_action = self.env.ref('account.action_report_account_statement')
+ for statement in self:
+ statement_report = statement_report_action.sudo()
+ content, _content_type = ir_actions_report_sudo._render_qweb_pdf(statement_report, res_ids=statement.ids)
+ statement.attachment_ids |= self.env['ir.attachment'].create({
+ 'name': _("Bank Statement %s.pdf", statement.name) if statement.name else _("Bank Statement.pdf"),
+ 'type': 'binary',
+ 'mimetype': 'application/pdf',
+ 'raw': content,
+ 'res_model': statement._name,
+ 'res_id': statement.id,
+ })
+ return statement_report_action.report_action(docids=self)
+
+class AccountBankStatementLine(models.Model):
+ _inherit = 'account.bank.statement.line'
+
+
+ cron_last_check = fields.Datetime()
+
+ def action_save_close(self):
+ return {'type': 'ir.actions.act_window_close'}
+
+ def action_save_new(self):
+ action = self.env['ir.actions.act_window']._for_xml_id('odex30_account_accountant.action_bank_statement_line_form_bank_rec_widget')
+ action['context'] = {'default_journal_id': self._context['default_journal_id']}
+ return action
+
+
+ @api.model
+ def _action_open_bank_reconciliation_widget(self, extra_domain=None, default_context=None, name=None, kanban_first=True):
+ action_reference = 'odex30_account_accountant.action_bank_statement_line_transactions' + ('_kanban' if kanban_first else '')
+ action = self.env['ir.actions.act_window']._for_xml_id(action_reference)
+
+ action.update({
+ 'name': name or _("Bank Reconciliation"),
+ 'context': default_context or {},
+ 'domain': [('state', '!=', 'cancel')] + (extra_domain or []),
+ })
+
+ return action
+
+ def action_open_recon_st_line(self):
+ self.ensure_one()
+ return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
+ name=self.name,
+ default_context={
+ 'default_statement_id': self.statement_id.id,
+ 'default_journal_id': self.journal_id.id,
+ 'default_st_line_id': self.id,
+ 'search_default_id': self.id,
+ },
+ )
+
+ def _cron_try_auto_reconcile_statement_lines(self, batch_size=None, limit_time=0):
+
+ def _compute_st_lines_to_reconcile(configured_company):
+
+ remaining_line_id = None
+ limit = batch_size + 1 if batch_size else None
+ domain = [
+ ('is_reconciled', '=', False),
+ ('create_date', '>', start_time.date() - relativedelta(months=3)),
+ ('company_id', 'in', configured_company.ids),
+ ]
+ st_lines = self.search(domain, limit=limit, order="cron_last_check ASC NULLS FIRST, id")
+ if batch_size and len(st_lines) > batch_size:
+ remaining_line_id = st_lines[batch_size].id
+ st_lines = st_lines[:batch_size]
+ return st_lines, remaining_line_id
+
+ start_time = fields.Datetime.now()
+
+ configured_company = children_company = self.env['account.reconcile.model'].search_fetch([
+ ('auto_reconcile', '=', True),
+ ('rule_type', 'in', ('writeoff_suggestion', 'invoice_matching')),
+ ], ['company_id']).company_id
+ if not configured_company:
+ return
+ while children_company := children_company.child_ids:
+ configured_company += children_company
+
+ st_lines, remaining_line_id = (self, None) if self else _compute_st_lines_to_reconcile(configured_company)
+
+ if not st_lines:
+ return
+
+
+ self.env.cr.execute("SELECT 1 FROM account_bank_statement_line WHERE id in %s FOR UPDATE", [tuple(st_lines.ids)])
+
+ nb_auto_reconciled_lines = 0
+ for index, st_line in enumerate(st_lines):
+ if limit_time and fields.Datetime.now().timestamp() - start_time.timestamp() > limit_time:
+ remaining_line_id = st_line.id
+ st_lines = st_lines[:index]
+ break
+ wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({})
+ wizard._action_trigger_matching_rules()
+ if wizard.state == 'valid' and wizard.matching_rules_allow_auto_reconcile:
+ try:
+ wizard._action_validate()
+ if st_line.is_reconciled:
+ st_line.move_id.message_post(body=_(
+ "This bank transaction has been automatically validated using the reconciliation model '%s'.",
+ ', '.join(st_line.move_id.line_ids.reconcile_model_id.mapped('name')),
+ ))
+ nb_auto_reconciled_lines += 1
+ except UserError as e:
+ _logger.info("Failed to auto reconcile statement line %s due to user error: %s",
+ st_line.id,
+ str(e)
+ )
+ continue
+
+ st_lines.write({'cron_last_check': start_time})
+
+ # If the next statement line has never been auto reconciled yet, force the trigger.
+ if remaining_line_id:
+ remaining_st_line = self.env['account.bank.statement.line'].browse(remaining_line_id)
+ if nb_auto_reconciled_lines or not remaining_st_line.cron_last_check:
+ self.env.ref('odex30_account_accountant.auto_reconcile_bank_statement_line')._trigger()
+
+ def _retrieve_partner(self):
+ self.ensure_one()
+
+ # Retrieve the partner from the statement line.
+ if self.partner_id:
+ return self.partner_id
+
+ # Retrieve the partner from the bank account.
+ if self.account_number:
+ account_number_nums = sanitize_account_number(self.account_number)
+ if account_number_nums:
+ domain = [('sanitized_acc_number', 'ilike', account_number_nums)]
+ for extra_domain in ([('company_id', 'parent_of', self.company_id.id)], [('company_id', '=', False)]):
+ bank_accounts = self.env['res.partner.bank'].search(extra_domain + domain)
+ if len(bank_accounts.partner_id) == 1:
+ return bank_accounts.partner_id
+ else:
+ # We have several partner with same account, possibly some archived partner
+ # so try to filter out inactive partner and if one remains, select this one
+ bank_accounts = bank_accounts.filtered(lambda bacc: bacc.partner_id.active)
+ if len(bank_accounts) == 1:
+ return bank_accounts.partner_id
+
+ # Retrieve the partner from the partner name.
+ if self.partner_name:
+ # using 'complete_name' instead of 'name',
+ # as 'complete_name' is the first search criteria in _rec_names_search,
+ # and trigram indexed accordingly.
+ domains = product(
+ [
+ ('complete_name', '=ilike', self.partner_name),
+ ('complete_name', 'ilike', self.partner_name),
+ ],
+ [
+ ('company_id', 'parent_of', self.company_id.id),
+ ('company_id', '=', False),
+ ],
+ )
+ for domain in domains:
+ partner = self.env['res.partner'].search(list(domain) + [('parent_id', '=', False)], limit=2)
+ if len(partner) == 1:
+ return partner
+ # Retrieve the partner from the 'reconcile models'.
+ rec_models = self.env['account.reconcile.model'].search([
+ *self.env['account.reconcile.model']._check_company_domain(self.company_id),
+ ('rule_type', '!=', 'writeoff_button'),
+ ])
+ for rec_model in rec_models:
+ partner = rec_model._get_partner_from_mapping(self)
+ if partner and rec_model._is_applicable_for(self, partner):
+ return partner
+
+ return self.env['res.partner']
+
+ def _get_st_line_strings_for_matching(self, allowed_fields=None):
+
+ self.ensure_one()
+
+ st_line_text_values = []
+ if not allowed_fields or 'payment_ref' in allowed_fields:
+ if self.payment_ref:
+ st_line_text_values.append(self.payment_ref)
+ if not allowed_fields or 'narration' in allowed_fields:
+ value = html2plaintext(self.narration or "")
+ if value:
+ st_line_text_values.append(value)
+ if not allowed_fields or 'ref' in allowed_fields:
+ if self.ref:
+ st_line_text_values.append(self.ref)
+ return st_line_text_values
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_chart_template.py b/dev_odex30_accounting/odex30_account_accountant/models/account_chart_template.py
new file mode 100644
index 0000000..166ba8f
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/models/account_chart_template.py
@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+from odoo.addons.account.models.chart_template import template
+from odoo import models
+
+class AccountChartTemplate(models.AbstractModel):
+ _inherit = 'account.chart.template'
+
+ def _get_account_accountant_res_company(self, chart_template):
+ company = self.env.company
+ data = self._get_chart_template_data(chart_template)
+ company_data = data['res.company'].get(company.id, {})
+
+ # Pre-reload to ensure the necessary xmlids for the load exist in case they were deleted or not created yet.
+ required_data = {k: v for k, v in data.items() if k in ['account.journal', 'account.account']}
+ self._pre_reload_data(company, data['template_data'], required_data)
+
+ return {
+ company.id: {
+ 'deferred_expense_journal_id': company.deferred_expense_journal_id.id or company_data.get('deferred_expense_journal_id'),
+ 'deferred_revenue_journal_id': company.deferred_revenue_journal_id.id or company_data.get('deferred_revenue_journal_id'),
+ 'deferred_expense_account_id': company.deferred_expense_account_id.id or company_data.get('deferred_expense_account_id'),
+ 'deferred_revenue_account_id': company.deferred_revenue_account_id.id or company_data.get('deferred_revenue_account_id'),
+ }
+ }
+
+ def _get_chart_template_data(self, chart_template):
+
+ data = super()._get_chart_template_data(chart_template)
+
+ for _company_id, company_data in data['res.company'].items():
+ company_data['deferred_expense_journal_id'] = (
+ company_data.get('deferred_expense_journal_id')
+ or next((xid for xid, d in data['account.journal'].items() if d['type'] == 'general'), None)
+ )
+
+ company_data['deferred_revenue_journal_id'] = (
+ company_data.get('deferred_revenue_journal_id')
+ or next((xid for xid, d in data['account.journal'].items() if d['type'] == 'general'), None)
+ )
+
+ company_data['deferred_expense_account_id'] = (
+ company_data.get('deferred_expense_account_id')
+ or next((xid for xid, d in data['account.account'].items() if d['account_type'] == 'asset_current'), None)
+ )
+
+ company_data['deferred_revenue_account_id'] = (
+ company_data.get('deferred_revenue_account_id')
+ or next((xid for xid, d in data['account.account'].items() if d['account_type'] == 'liability_current'), None)
+ )
+
+ return data
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_fiscal_year.py b/dev_odex30_accounting/odex30_account_accountant/models/account_fiscal_year.py
new file mode 100644
index 0000000..01555d4
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/models/account_fiscal_year.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+
+from odoo.exceptions import ValidationError
+from odoo import api, fields, models, _
+
+
+from datetime import datetime
+
+
+class AccountFiscalYear(models.Model):
+ _name = 'account.fiscal.year'
+ _description = 'Fiscal Year'
+
+ name = fields.Char(string='Name', required=True)
+ date_from = fields.Date(string='Start Date', required=True,
+ help='Start Date, included in the fiscal year.')
+ date_to = fields.Date(string='End Date', required=True,
+ help='Ending Date, included in the fiscal year.')
+ company_id = fields.Many2one('res.company', string='Company', required=True,
+ default=lambda self: self.env.company)
+
+ @api.constrains('date_from', 'date_to', 'company_id')
+ def _check_dates(self):
+
+ for fy in self:
+ # Starting date must be prior to the ending date
+ date_from = fy.date_from
+ date_to = fy.date_to
+ if date_to < date_from:
+ raise ValidationError(_('The ending date must not be prior to the starting date.'))
+ if fy.company_id.parent_id:
+ raise ValidationError(_('You cannot have a fiscal year on a child company.'))
+
+ domain = [
+ ('id', '!=', fy.id),
+ ('company_id', '=', fy.company_id.id),
+ '|', '|',
+ '&', ('date_from', '<=', fy.date_from), ('date_to', '>=', fy.date_from),
+ '&', ('date_from', '<=', fy.date_to), ('date_to', '>=', fy.date_to),
+ '&', ('date_from', '<=', fy.date_from), ('date_to', '>=', fy.date_to),
+ ]
+
+ if self.search_count(domain) > 0:
+ raise ValidationError(_('You can not have an overlap between two fiscal years, please correct the start and/or end dates of your fiscal years.'))
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_journal_dashboard.py b/dev_odex30_accounting/odex30_account_accountant/models/account_journal_dashboard.py
new file mode 100644
index 0000000..85f14c9
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/models/account_journal_dashboard.py
@@ -0,0 +1,62 @@
+from odoo import models
+
+
+class account_journal(models.Model):
+ _inherit = "account.journal"
+
+ def action_open_reconcile(self):
+ self.ensure_one()
+
+ if self.type in ('bank', 'cash', 'credit'):
+ return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
+ default_context={
+ 'default_journal_id': self.id,
+ 'search_default_journal_id': self.id,
+ 'search_default_not_matched': True,
+ },
+ )
+ else:
+ # Open reconciliation view for customers/suppliers
+ return self.env['ir.actions.act_window']._for_xml_id('odex30_account_accountant.action_move_line_posted_unreconciled')
+
+ def action_open_to_check(self):
+ self.ensure_one()
+ return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
+ default_context={
+ 'search_default_to_check': True,
+ 'search_default_journal_id': self.id,
+ 'default_journal_id': self.id,
+ },
+ )
+
+ def action_open_bank_transactions(self):
+ self.ensure_one()
+ return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
+ default_context={
+ 'search_default_journal_id': self.id,
+ 'default_journal_id': self.id
+ },
+ kanban_first=False,
+ )
+
+ def action_open_reconcile_statement(self):
+ return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
+ default_context={
+ 'search_default_statement_id': self.env.context.get('statement_id'),
+ },
+ )
+
+ def open_action(self):
+ # EXTENDS account
+ # set default action for liquidity journals in dashboard
+
+ if self.type in ('bank', 'cash', 'credit') and not self._context.get('action_name'):
+ self.ensure_one()
+ return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
+ extra_domain=[('line_ids.account_id', '=', self.default_account_id.id)],
+ default_context={
+ 'default_journal_id': self.id,
+ 'search_default_journal_id': self.id,
+ },
+ )
+ return super().open_action()
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_move.py b/dev_odex30_accounting/odex30_account_accountant/models/account_move.py
new file mode 100644
index 0000000..7f549b8
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/models/account_move.py
@@ -0,0 +1,836 @@
+import calendar
+from contextlib import contextmanager
+from itertools import chain
+from dateutil.relativedelta import relativedelta
+import logging
+import re
+
+from odoo import fields, models, api, _, Command
+from odoo.exceptions import UserError
+from odoo.osv import expression
+from odoo.tools import SQL, float_compare
+
+
+_logger = logging.getLogger(__name__)
+
+
+DEFERRED_DATE_MIN = '1900-01-01'
+DEFERRED_DATE_MAX = '9999-12-31'
+
+
+class AccountMove(models.Model):
+ _inherit = "account.move"
+
+ # Technical field to keep the value of payment_state when switching from invoicing to accounting
+ # (using invoicing_switch_threshold setting field). It allows keeping the former payment state, so that
+ # we can restore it if the user misconfigured the switch date and wants to change it.
+ payment_state_before_switch = fields.Char(string="Payment State Before Switch", copy=False)
+
+ # Deferred management fields
+ deferred_move_ids = fields.Many2many(
+ string="Deferred Entries",
+ comodel_name='account.move',
+ relation='account_move_deferred_rel',
+ column1='original_move_id',
+ column2='deferred_move_id',
+ help="The deferred entries created by this invoice",
+ copy=False,
+ )
+ deferred_original_move_ids = fields.Many2many(
+ string="Original Invoices",
+ comodel_name='account.move',
+ relation='account_move_deferred_rel',
+ column1='deferred_move_id',
+ column2='original_move_id',
+ help="The original invoices that created the deferred entries",
+ copy=False,
+ )
+ deferred_entry_type = fields.Selection(
+ string="Deferred Entry Type",
+ selection=[
+ ('expense', 'Deferred Expense'),
+ ('revenue', 'Deferred Revenue'),
+ ],
+ compute='_compute_deferred_entry_type',
+ copy=False,
+ )
+
+ signing_user = fields.Many2one(
+ string='Signer',
+ comodel_name='res.users',
+ compute='_compute_signing_user', store=True,
+ copy=False,
+ )
+ show_signature_area = fields.Boolean(compute='_compute_signature')
+ signature = fields.Binary(compute='_compute_signature') # can't be `related`: the sign module might not be there
+
+ @api.depends('state', 'move_type', 'invoice_user_id')
+ def _compute_signing_user(self):
+ other_moves = self.filtered(lambda move: not move.is_sale_document())
+ other_moves.signing_user = False
+
+ is_odoobot_user = self.env.user == self.env.ref('base.user_root')
+ is_backend_user = self.env.user.has_group('base.group_user')
+
+ for invoice in (self - other_moves).filtered(lambda inv: inv.state == 'posted'):
+ # signer priority:
+ # - res.user set in res.settings
+ # - real backend user posting the invoice
+ # - if odoobot: the person that initiated the invoice ie: The salesman
+ # - if invoice initiated by a portal user -> No signature
+ representative = invoice.company_id.signing_user
+ # checking `has_group('base.group_user')` ensure we never keep a portal user to sign
+ if is_odoobot_user:
+ user_can_sign = invoice.invoice_user_id and invoice.invoice_user_id.has_group('base.group_user')
+ invoice.signing_user = representative or invoice.invoice_user_id if user_can_sign else False
+ else:
+ invoice.signing_user = representative or self.env.user if is_backend_user else False
+
+ @api.depends('state')
+ def _compute_signature(self):
+ is_portal_user = self.env.user.has_group('base.group_portal')
+ # Checking `company_id.sign_invoice` removes the needs to check if the sign module is installed
+ # Setting it to True through `res.settings` auto install the sign module
+ moves_not_to_sign = self.filtered(
+ lambda inv: not inv.company_id.sign_invoice
+ or inv.state in {'draft', 'cancel'}
+ or not inv.is_sale_document()
+ # Allow signature for portal user only if the invoice already went through the send&print workflow
+ or (is_portal_user and not inv.invoice_pdf_report_id)
+ )
+ moves_not_to_sign.show_signature_area = False
+ moves_not_to_sign.signature = None
+
+ invoice_with_signature = self - moves_not_to_sign
+ invoice_with_signature.show_signature_area = True
+ for invoice in invoice_with_signature:
+ invoice.signature = invoice.signing_user.sudo().sign_signature
+
+ def _post(self, soft=True):
+ # Deferred management
+ posted = super()._post(soft)
+ for move in self:
+ if move._get_deferred_entries_method() == 'on_validation' and any(move.line_ids.mapped('deferred_start_date')):
+ move._generate_deferred_entries()
+ return posted
+
+ def action_post(self):
+ # EXTENDS 'account' to trigger the CRON auto-reconciling the statement lines.
+ res = super().action_post()
+ if self.statement_line_id and not self._context.get('skip_statement_line_cron_trigger'):
+ self.env.ref('odex30_odex30_account_accountant.auto_reconcile_bank_statement_line')._trigger()
+ return res
+
+ def button_draft(self):
+ if any(len(deferral_move.deferred_original_move_ids) > 1 for deferral_move in self.deferred_move_ids):
+ raise UserError(_("You cannot reset to draft an invoice that is grouped in deferral entry. You can create a credit note instead."))
+ reversed_moves = self.deferred_move_ids._unlink_or_reverse()
+ if reversed_moves:
+ for move in reversed_moves:
+ move.with_context(skip_readonly_check=True).write({
+ 'date': move._get_accounting_date(move.date, move._affect_tax_report()),
+ })
+ self.deferred_move_ids |= reversed_moves
+ return super().button_draft()
+
+ def unlink(self):
+ # Prevent deferred moves under audit trail restriction from being unlinked
+ deferral_moves = self.filtered(lambda move: move._is_protected_by_audit_trail() and move.deferred_original_move_ids)
+ deferral_moves.deferred_original_move_ids.deferred_move_ids = False
+ deferral_moves._reverse_moves()
+ return super(AccountMove, self - deferral_moves).unlink()
+
+ # ============================= START - Deferred Management ====================================
+
+ def _get_deferred_entries_method(self):
+ self.ensure_one()
+ if self.is_purchase_document():
+ return self.company_id.generate_deferred_expense_entries_method
+ return self.company_id.generate_deferred_revenue_entries_method
+
+ @api.depends('deferred_original_move_ids')
+ def _compute_deferred_entry_type(self):
+ for move in self:
+ if move.deferred_original_move_ids:
+ move.deferred_entry_type = 'expense' if move.deferred_original_move_ids[0].is_purchase_document() else 'revenue'
+ else:
+ move.deferred_entry_type = False
+
+ @api.model
+ def _get_deferred_diff_dates(self, start, end):
+ """
+ Returns the number of months between two dates [start, end[
+ The computation is done by using months of 30 days so that the deferred amount for february
+ (28-29 days), march (31 days) and april (30 days) are all the same (in case of monthly computation).
+ See test_deferred_management_get_diff_dates for examples.
+ """
+ if start > end:
+ start, end = end, start
+ nb_months = end.month - start.month + 12 * (end.year - start.year)
+ start_day, end_day = start.day, end.day
+ if start_day == calendar.monthrange(start.year, start.month)[1]:
+ start_day = 30
+ if end_day == calendar.monthrange(end.year, end.month)[1]:
+ end_day = 30
+ nb_days = end_day - start_day
+ return (nb_months * 30 + nb_days) / 30
+
+ @api.model
+ def _get_deferred_period_amount(self, method, period_start, period_end, line_start, line_end, balance):
+ """
+ Returns the amount to defer for the given period taking into account the deferred method (day/month/full_months).
+ """
+ if period_end <= line_start or period_end <= period_start:
+ return 0 # invalid period
+ if method == 'day':
+ amount_per_day = balance / (line_end - line_start).days
+ return (period_end - period_start).days * amount_per_day
+ elif method in ('month', 'full_months'):
+ if method == 'full_months':
+ reset_day_1 = relativedelta(day=1)
+ line_start, line_end = line_start + reset_day_1, line_end + reset_day_1
+ period_start, period_end = period_start + reset_day_1, period_end + reset_day_1
+ line_diff = self._get_deferred_diff_dates(line_end, line_start)
+ period_diff = self._get_deferred_diff_dates(period_end, period_start)
+ return period_diff / line_diff * balance if line_diff else balance
+
+ @api.model
+ def _get_deferred_amounts_by_line(self, lines, periods, deferred_type):
+ """
+ :return: a list of dictionaries containing the deferred amounts for each line and each period
+ E.g. (where period1 = (date1, date2, label1), period2 = (date2, date3, label2), ...)
+ [
+ {'account_id': 1, period_1: 100, period_2: 200},
+ {'account_id': 1, period_1: 100, period_2: 200},
+ {'account_id': 2, period_1: 300, period_2: 400},
+ ]
+ """
+ values = []
+ for line in lines:
+ line_start = fields.Date.to_date(line['deferred_start_date'])
+ line_end = fields.Date.to_date(line['deferred_end_date'])
+ if line_end < line_start:
+ # This normally shouldn't happen, but if it does, would cause calculation errors later on.
+ # To not make the reports crash, we just set both dates to the same day.
+ # The user should fix the dates manually.
+ line_end = line_start
+
+ columns = {}
+ for period in periods:
+ if period[2] == 'not_started' and line_start <= period[0]:
+ # The 'Not Started' column only considers lines starting the deferral after the report end date
+ columns[period] = 0.0
+ continue
+ # periods = [Total, Not Started, Before, ..., Current, ..., Later]
+ # The dates to calculate the amount for the current period
+ period_start = max(period[0], line_start)
+ period_end = min(period[1], line_end) + relativedelta(days=1) # +1 to include end date of report
+
+ columns[period] = self._get_deferred_period_amount(
+ self.env.company.deferred_expense_amount_computation_method if deferred_type == "expense" else self.env.company.deferred_revenue_amount_computation_method,
+ period_start, period_end,
+ line_start, line_end + relativedelta(days=1), # +1 to include the end date of the line
+ line['balance']
+ )
+
+ values.append({
+ **self.env['account.move.line']._get_deferred_amounts_by_line_values(line),
+ **columns,
+ })
+ return values
+
+ @api.model
+ def _get_deferred_lines(self, line, deferred_account, deferred_type, period, ref, force_balance=None, grouping_field='account_id'):
+ """
+ :return: a list of Command objects to create the deferred lines of a single given period
+ """
+ deferred_amounts = self._get_deferred_amounts_by_line(line, [period], deferred_type)[0]
+ balance = deferred_amounts[period] if force_balance is None else force_balance
+ return [
+ Command.create({
+ **self.env['account.move.line']._get_deferred_lines_values(account.id, coeff * balance, ref, line.analytic_distribution, line),
+ 'partner_id': line.partner_id.id,
+ 'product_id': line.product_id.id,
+ })
+ for (account, coeff) in [(deferred_amounts[grouping_field], 1), (deferred_account, -1)]
+ ]
+
+ def _generate_deferred_entries(self):
+ """
+ Generates the deferred entries for the invoice.
+ """
+ self.ensure_one()
+ if self.state != 'posted':
+ return
+ if self.is_entry():
+ raise UserError(_("You cannot generate deferred entries for a miscellaneous journal entry."))
+ deferred_type = "expense" if self.is_purchase_document(include_receipts=True) else "revenue"
+ deferred_account = self.company_id.deferred_expense_account_id if deferred_type == "expense" else self.company_id.deferred_revenue_account_id
+ deferred_journal = self.company_id.deferred_expense_journal_id if deferred_type == "expense" else self.company_id.deferred_revenue_journal_id
+ if not deferred_journal:
+ raise UserError(_("Please set the deferred journal in the accounting settings."))
+ if not deferred_account:
+ raise UserError(_("Please set the deferred accounts in the accounting settings."))
+
+ moves_vals_to_create = []
+ lines_vals_to_create = []
+ lines_periods = []
+ for line in self.line_ids.filtered(lambda l: l.deferred_start_date and l.deferred_end_date):
+ periods = line._get_deferred_periods()
+ if not periods:
+ continue
+
+ start_date = line.deferred_start_date
+ end_date = line.deferred_end_date
+ accounting_date = line.date
+
+ # When using the 'full_months' computation method, every consumed month counts as a full month.
+ # We therefore need to subtract one month from the end date for the following check on dates.
+ if line.company_id.deferred_expense_amount_computation_method == 'full_months':
+ # We need to add one day to the end date since it's excluded by _get_deferred_diff_dates().
+ if self._get_deferred_diff_dates(start_date.replace(day=1), end_date + relativedelta(days=1)) < 2:
+ end_date += relativedelta(months=-1)
+
+ # When all move line dates (start, end, accounting) are within the same month, we skip the line.
+ # It would otherwise lead to the creation of both a reversal and a deferral move that would cancel each other out.
+ if start_date.replace(day=1) == end_date.replace(day=1) == accounting_date.replace(day=1):
+ continue
+
+ ref = _("Deferral of %s", line.move_id.name or '')
+
+ moves_vals_to_create.append({
+ 'move_type': 'entry',
+ 'deferred_original_move_ids': [Command.set(line.move_id.ids)],
+ 'journal_id': deferred_journal.id,
+ 'company_id': self.company_id.id,
+ 'partner_id': line.partner_id.id,
+ 'auto_post': 'at_date',
+ 'ref': ref,
+ 'name': False,
+ 'date': line.move_id.date,
+ })
+ lines_vals_to_create.append([
+ self.env['account.move.line']._get_deferred_lines_values(account.id, coeff * line.balance, ref, line.analytic_distribution, line)
+ for (account, coeff) in [(line.account_id, -1), (deferred_account, 1)]
+ ])
+ lines_periods.append((line, periods))
+ # create the deferred moves
+ moves_fully_deferred = self.create(moves_vals_to_create)
+ # We write the lines after creation, to make sure the `deferred_original_move_ids` is set.
+ # This way we can avoid adding taxes for deferred moves.
+ for move_fully_deferred, lines_vals in zip(moves_fully_deferred, lines_vals_to_create):
+ for line_vals in lines_vals:
+ # This will link the moves to the lines. Instead of move.write('line_ids': lines_ids)
+ line_vals['move_id'] = move_fully_deferred.id
+ self.env['account.move.line'].create(list(chain(*lines_vals_to_create)))
+
+ deferral_moves_vals = []
+ deferral_moves_line_vals = []
+ # Create the deferred entries for the periods [deferred_start_date, deferred_end_date]
+ for (line, periods), move_vals in zip(lines_periods, moves_vals_to_create):
+ remaining_balance = line.balance
+ for period_index, period in enumerate(periods):
+ # For the last deferral move the balance is forced to remaining balance to avoid rounding errors
+ force_balance = remaining_balance if period_index == len(periods) - 1 else None
+ deferred_amounts = self._get_deferred_amounts_by_line(line, [period], deferred_type)[0]
+ balance = deferred_amounts[period] if force_balance is None else force_balance
+ remaining_balance -= line.currency_id.round(balance)
+ deferral_moves_vals.append({**move_vals, 'date': period[1]})
+ deferral_moves_line_vals.append([
+ {
+ **self.env['account.move.line']._get_deferred_lines_values(account.id, coeff * balance, move_vals['ref'], line.analytic_distribution, line),
+ 'partner_id': line.partner_id.id,
+ 'product_id': line.product_id.id,
+ }
+ for (account, coeff) in [(deferred_amounts['account_id'], 1), (deferred_account, -1)]
+ ])
+
+ deferral_moves = self.create(deferral_moves_vals)
+ for deferral_move, lines_vals in zip(deferral_moves, deferral_moves_line_vals):
+ for line_vals in lines_vals:
+ # This will link the moves to the lines. Instead of move.write('line_ids': lines_ids)
+ line_vals['move_id'] = deferral_move.id
+ self.env['account.move.line'].create(list(chain(*deferral_moves_line_vals)))
+
+ # Avoid having deferral moves with a total amount of 0.
+ to_unlink = deferral_moves.filtered(lambda move: move.currency_id.is_zero(move.amount_total))
+ to_unlink.unlink()
+
+ (moves_fully_deferred + deferral_moves - to_unlink)._post(soft=True)
+
+ def open_deferred_entries(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _("Deferred Entries"),
+ 'res_model': 'account.move.line',
+ 'domain': [('id', 'in', self.deferred_move_ids.line_ids.ids)],
+ 'views': [(self.env.ref('odex30_account_accountant.view_deferred_entries_tree').id, 'list')],
+ 'context': {
+ 'search_default_group_by_move': True,
+ 'expand': True,
+ }
+ }
+
+ def open_deferred_original_entry(self):
+ self.ensure_one()
+ action = {
+ 'type': 'ir.actions.act_window',
+ 'name': _("Original Deferred Entries"),
+ 'res_model': 'account.move.line',
+ 'domain': [('id', 'in', self.deferred_original_move_ids.line_ids.ids)],
+ 'views': [(False, 'list'), (False, 'form')],
+ 'context': {
+ 'search_default_group_by_move': True,
+ 'expand': True,
+ }
+ }
+ if len(self.deferred_original_move_ids) == 1:
+ action.update({
+ 'res_model': 'account.move',
+ 'res_id': self.deferred_original_move_ids[0].id,
+ 'views': [(False, 'form')],
+ })
+ return action
+
+ # ============================= END - Deferred management ======================================
+
+ def action_open_bank_reconciliation_widget(self):
+ return self.statement_line_id._action_open_bank_reconciliation_widget(
+ default_context={
+ 'search_default_journal_id': self.statement_line_id.journal_id.id,
+ 'search_default_statement_line_id': self.statement_line_id.id,
+ 'default_st_line_id': self.statement_line_id.id,
+ }
+ )
+
+ def action_open_bank_reconciliation_widget_statement(self):
+ return self.statement_line_id._action_open_bank_reconciliation_widget(
+ extra_domain=[('statement_id', 'in', self.statement_id.ids)],
+ )
+
+ def action_open_business_doc(self):
+ if self.statement_line_id:
+ return self.action_open_bank_reconciliation_widget()
+ else:
+ action = super().action_open_business_doc()
+ # prevent propagation of the following keys
+ action['context'] = action.get('context', {}) | {
+ 'preferred_aml_value': None,
+ 'preferred_aml_currency_id': None,
+ }
+ return action
+
+ def _get_mail_thread_data_attachments(self):
+ res = super()._get_mail_thread_data_attachments()
+ res += self.statement_line_id.statement_id.attachment_ids
+ return res
+
+ @contextmanager
+ def _get_edi_creation(self):
+ with super()._get_edi_creation() as move:
+ previous_lines = move.invoice_line_ids
+ yield move.with_context(disable_onchange_name_predictive=True)
+ for line in move.invoice_line_ids - previous_lines:
+ line._onchange_name_predictive()
+
+
+class AccountMoveLine(models.Model):
+ _name = "account.move.line"
+ _inherit = "account.move.line"
+
+ move_attachment_ids = fields.One2many('ir.attachment', compute='_compute_attachment')
+
+ # Deferred management fields
+ deferred_start_date = fields.Date(
+ string="Start Date",
+ compute='_compute_deferred_start_date', store=True, readonly=False,
+ index='btree_not_null',
+ copy=False,
+ help="Date at which the deferred expense/revenue starts"
+ )
+ deferred_end_date = fields.Date(
+ string="End Date",
+ index='btree_not_null',
+ copy=False,
+ help="Date at which the deferred expense/revenue ends"
+ )
+ has_deferred_moves = fields.Boolean(compute='_compute_has_deferred_moves')
+ has_abnormal_deferred_dates = fields.Boolean(compute='_compute_has_abnormal_deferred_dates')
+
+ def _order_to_sql(self, order, query, alias=None, reverse=False):
+ sql_order = super()._order_to_sql(order, query, alias, reverse)
+ preferred_aml_residual_value = self._context.get('preferred_aml_value')
+ preferred_aml_currency_id = self._context.get('preferred_aml_currency_id')
+ if preferred_aml_residual_value and preferred_aml_currency_id and order == self._order:
+ currency = self.env['res.currency'].browse(preferred_aml_currency_id)
+ # using round since currency.round(55.55) = 55.550000000000004
+ preferred_aml_residual_value = round(preferred_aml_residual_value, currency.decimal_places)
+ sql_residual_currency = self._field_to_sql(alias or self._table, 'amount_residual_currency', query)
+ sql_currency = self._field_to_sql(alias or self._table, 'currency_id', query)
+ return SQL(
+ "ROUND(%(residual_currency)s, %(decimal_places)s) = %(value)s "
+ "AND %(currency)s = %(currency_id)s DESC, %(order)s",
+ residual_currency=sql_residual_currency,
+ decimal_places=currency.decimal_places,
+ value=preferred_aml_residual_value,
+ currency=sql_currency,
+ currency_id=currency.id,
+ order=sql_order,
+ )
+ return sql_order
+
+ def copy_data(self, default=None):
+ data_list = super().copy_data(default=default)
+ for line, values in zip(self, data_list):
+ if 'move_reverse_cancel' in self._context:
+ values['deferred_start_date'] = line.deferred_start_date
+ values['deferred_end_date'] = line.deferred_end_date
+ return data_list
+
+ def write(self, vals):
+ """ Prevent changing the account of a move line when there are already deferral entries.
+ """
+ if 'account_id' in vals:
+ for line in self:
+ if (
+ line.has_deferred_moves
+ and line.deferred_start_date
+ and line.deferred_end_date
+ and vals['account_id'] != line.account_id.id
+ ):
+ raise UserError(_(
+ "You cannot change the account for a deferred line in %(move_name)s if it has already been deferred.",
+ move_name=line.move_id.display_name
+ ))
+ return super().write(vals)
+
+ # ============================= START - Deferred management ====================================
+ def _compute_has_deferred_moves(self):
+ for line in self:
+ line.has_deferred_moves = line.move_id.deferred_move_ids
+
+ @api.depends('deferred_start_date', 'deferred_end_date')
+ def _compute_has_abnormal_deferred_dates(self):
+
+ for line in self:
+ line.has_abnormal_deferred_dates = (
+ line.deferred_start_date
+ and line.deferred_end_date
+ and float_compare(
+ self.env['account.move']._get_deferred_diff_dates(line.deferred_start_date, line.deferred_end_date + relativedelta(days=1)) % 1, # end date is included
+ 1 / 30,
+ precision_digits=2
+ ) == 0
+ )
+
+ def _has_deferred_compatible_account(self):
+ self.ensure_one()
+ return (
+ self.move_id.is_purchase_document(include_receipts=True)
+ and
+ self.account_id.account_type in ('expense', 'expense_depreciation', 'expense_direct_cost')
+ ) or (
+ self.move_id.is_sale_document(include_receipts=True)
+ and
+ self.account_id.account_type in ('income', 'income_other')
+ )
+
+ @api.onchange('deferred_start_date')
+ def _onchange_deferred_start_date(self):
+ if not self._has_deferred_compatible_account():
+ self.deferred_start_date = False
+
+ @api.onchange('deferred_end_date')
+ def _onchange_deferred_end_date(self):
+ if not self._has_deferred_compatible_account():
+ self.deferred_end_date = False
+
+ @api.depends('deferred_end_date', 'move_id.invoice_date', 'move_id.state')
+ def _compute_deferred_start_date(self):
+ for line in self:
+ if not line.deferred_start_date and line.move_id.invoice_date and line.deferred_end_date:
+ line.deferred_start_date = line.move_id.invoice_date
+
+ @api.constrains('deferred_start_date', 'deferred_end_date', 'account_id')
+ def _check_deferred_dates(self):
+ for line in self:
+ if line.deferred_start_date and not line.deferred_end_date:
+ raise UserError(_("You cannot create a deferred entry with a start date but no end date."))
+ elif line.deferred_start_date and line.deferred_end_date and line.deferred_start_date > line.deferred_end_date:
+ raise UserError(_("You cannot create a deferred entry with a start date later than the end date."))
+
+ @api.model
+ def _get_deferred_ends_of_month(self, start_date, end_date):
+
+ dates = []
+ while start_date <= end_date:
+ start_date = start_date + relativedelta(day=31) # Go to end of month
+ dates.append(start_date)
+ start_date = start_date + relativedelta(days=1) # Go to first day of next month
+ return dates
+
+ def _get_deferred_periods(self):
+
+ self.ensure_one()
+ periods = [
+ (max(self.deferred_start_date, date.replace(day=1)), min(date, self.deferred_end_date), 'current')
+ for date in self._get_deferred_ends_of_month(self.deferred_start_date, self.deferred_end_date)
+ ]
+ if not periods or len(periods) == 1 and periods[0][0].replace(day=1) == self.date.replace(day=1):
+ return []
+ else:
+ return periods
+
+ @api.model
+ def _get_deferred_amounts_by_line_values(self, line):
+ return {
+ 'account_id': line['account_id'],
+ # line either be a dict with ids (coming from SQL query), or a real account.move.line object
+ 'product_id': line['product_id'] if isinstance(line, dict) else line['product_id'].id,
+ 'product_category_id': line['product_category_id'] if isinstance(line, dict) else line['product_category_id'].id,
+ 'balance': line['balance'],
+ 'move_id': line['move_id'],
+ }
+
+ @api.model
+ def _get_deferred_lines_values(self, account_id, balance, ref, analytic_distribution, line=None):
+ return {
+ 'account_id': account_id,
+ # line either be a dict with ids (coming from SQL query), or a real account.move.line object
+ 'product_id': line['product_id'] if isinstance(line, dict) else line['product_id'].id,
+ 'product_category_id': line['product_category_id'] if isinstance(line, dict) else line['product_category_id'].id,
+ 'balance': balance,
+ 'name': ref,
+ 'analytic_distribution': analytic_distribution,
+ }
+
+
+ def _get_computed_taxes(self):
+ if self.move_id.deferred_original_move_ids:
+
+ return self.tax_ids
+ return super()._get_computed_taxes()
+
+ def _compute_attachment(self):
+ for record in self:
+ record.move_attachment_ids = self.env['ir.attachment'].search(expression.OR(record._get_attachment_domains()))
+
+ def action_reconcile(self):
+
+ self = self.filtered(lambda x: x.balance or x.amount_currency) # noqa: PLW0642
+ if not self:
+ return
+
+ wizard = self.env['account.reconcile.wizard'].with_context(
+ active_model='account.move.line',
+ active_ids=self.ids,
+ ).new({})
+ return wizard._action_open_wizard() if (wizard.is_write_off_required or wizard.force_partials) else wizard.reconcile()
+
+ def _get_predict_postgres_dictionary(self):
+ lang = self._context.get('lang') and self._context.get('lang')[:2]
+ return {'fr': 'french'}.get(lang, 'english')
+
+ @api.model
+ def _build_predictive_query(self, move_id, additional_domain=None):
+ move_query = self.env['account.move']._where_calc([
+ ('move_type', '=', move_id.move_type),
+ ('state', '=', 'posted'),
+ ('partner_id', '=', move_id.partner_id.id),
+ ('company_id', '=', move_id.journal_id.company_id.id or self.env.company.id),
+ ])
+ move_query.order = 'account_move.invoice_date'
+ move_query.limit = int(self.env["ir.config_parameter"].sudo().get_param(
+ "account.bill.predict.history.limit",
+ '100',
+ ))
+ return self.env['account.move.line']._where_calc([
+ ('move_id', 'in', move_query),
+ ('display_type', '=', 'product'),
+ ] + (additional_domain or []))
+
+ def _predicted_field(self, name, partner_id, field, query=None, additional_queries=None):
+
+ if not name or not partner_id:
+ return False
+
+ psql_lang = self._get_predict_postgres_dictionary()
+ description = name + ' account_move_line' # give more priority to main query than additional queries
+ move_id = self.env.context.get('predicted_field_move', self.move_id)
+ parsed_description = re.sub(r"[*&()|!':<>=%/~@,.;$\[\]]+", " ", description)
+ parsed_description = ' | '.join(parsed_description.split())
+
+ try:
+ main_source = (query if query is not None else self._build_predictive_query(move_id)).select(
+ SQL("%s AS prediction", field),
+ SQL(
+ "setweight(to_tsvector(%s, account_move_line.name), 'B') || setweight(to_tsvector('simple', 'account_move_line'), 'A') AS document",
+ psql_lang
+ ),
+ )
+ if "(" in field.code: # aggregate function
+ main_source = SQL("%s %s", main_source, SQL("GROUP BY account_move_line.id, account_move_line.name, account_move_line.partner_id"))
+
+ self.env.cr.execute(SQL("""
+ WITH account_move_line AS MATERIALIZED (%(account_move_line)s),
+
+ source AS (%(source)s),
+
+ ranking AS (
+ SELECT prediction, ts_rank(source.document, query_plain) AS rank
+ FROM source, to_tsquery(%(lang)s, %(description)s) query_plain
+ WHERE source.document @@ query_plain
+ )
+
+ SELECT prediction, MAX(rank) AS ranking, COUNT(*)
+ FROM ranking
+ GROUP BY prediction
+ ORDER BY ranking DESC, count DESC
+ LIMIT 2
+ """,
+ account_move_line=self._build_predictive_query(move_id).select(SQL('*')),
+ source=SQL('(%s)', SQL(') UNION ALL (').join([main_source] + (additional_queries or []))),
+ lang=psql_lang,
+ description=parsed_description,
+ ))
+ result = self.env.cr.dictfetchall()
+ if result:
+ # Only confirm the prediction if it's at least 10% better than the second one
+ if len(result) > 1 and result[0]['ranking'] < 1.1 * result[1]['ranking']:
+ return False
+ return result[0]['prediction']
+ except Exception:
+
+ _logger.exception('Error while predicting invoice line fields')
+ return False
+
+ def _predict_taxes(self):
+ field = SQL('array_agg(account_move_line__tax_rel__tax_ids.id ORDER BY account_move_line__tax_rel__tax_ids.id)')
+ query = self._build_predictive_query(self.move_id)
+ query.left_join('account_move_line', 'id', 'account_move_line_account_tax_rel', 'account_move_line_id', 'tax_rel')
+ query.left_join('account_move_line__tax_rel', 'account_tax_id', 'account_tax', 'id', 'tax_ids')
+ query.add_where('account_move_line__tax_rel__tax_ids.active IS NOT FALSE')
+ predicted_tax_ids = self._predicted_field(self.name, self.partner_id, field, query)
+ if predicted_tax_ids == [None]:
+ return False
+ if predicted_tax_ids is not False and set(predicted_tax_ids) != set(self.tax_ids.ids):
+ return predicted_tax_ids
+ return False
+
+ @api.model
+ def _predict_specific_tax(self, move, name, partner, amount_type, amount, type_tax_use):
+ field = SQL('array_agg(account_move_line__tax_rel__tax_ids.id ORDER BY account_move_line__tax_rel__tax_ids.id)')
+ query = self._build_predictive_query(move)
+ query.left_join('account_move_line', 'id', 'account_move_line_account_tax_rel', 'account_move_line_id', 'tax_rel')
+ query.left_join('account_move_line__tax_rel', 'account_tax_id', 'account_tax', 'id', 'tax_ids')
+ query.add_where("""
+ account_move_line__tax_rel__tax_ids.active IS NOT FALSE
+ AND account_move_line__tax_rel__tax_ids.amount_type = %s
+ AND account_move_line__tax_rel__tax_ids.type_tax_use = %s
+ AND account_move_line__tax_rel__tax_ids.amount = %s
+ """, (amount_type, type_tax_use, amount))
+ return self.with_context(predicted_field_move=move)._predicted_field(name, partner, field, query)
+
+ def _predict_product(self):
+ predict_product = int(self.env['ir.config_parameter'].sudo().get_param('account_predictive_bills.predict_product', '1'))
+ if predict_product and self.company_id.predict_bill_product:
+ query = self._build_predictive_query(self.move_id, ['|', ('product_id', '=', False), ('product_id.active', '=', True)])
+ predicted_product_id = self._predicted_field(self.name, self.partner_id, SQL('account_move_line.product_id'), query)
+ if predicted_product_id and predicted_product_id != self.product_id.id:
+ return predicted_product_id
+ return False
+
+ def _predict_account(self):
+ field = SQL('account_move_line.account_id')
+ if self.move_id.is_purchase_document(True):
+ excluded_group = 'income'
+ else:
+ excluded_group = 'expense'
+ account_query = self.env['account.account']._where_calc([
+ *self.env['account.account']._check_company_domain(self.move_id.company_id or self.env.company),
+ ('deprecated', '=', False),
+ ('internal_group', 'not in', (excluded_group, 'off')),
+ ('account_type', 'not in', ('liability_payable', 'asset_receivable')),
+ ])
+ account_name = self.env['account.account']._field_to_sql('account_account', 'name')
+ psql_lang = self._get_predict_postgres_dictionary()
+ additional_queries = [SQL(account_query.select(
+ SQL("account_account.id AS account_id"),
+ SQL("setweight(to_tsvector(%(psql_lang)s, %(account_name)s), 'B') AS document", psql_lang=psql_lang, account_name=account_name),
+ ))]
+ query = self._build_predictive_query(self.move_id, [('account_id', 'in', account_query)])
+
+ predicted_account_id = self._predicted_field(self.name, self.partner_id, field, query, additional_queries)
+ if predicted_account_id and predicted_account_id != self.account_id.id:
+ return predicted_account_id
+ return False
+
+ @api.onchange('name')
+ def _onchange_name_predictive(self):
+ if ((self.move_id.quick_edit_mode or self.move_id.move_type == 'in_invoice') and self.name and self.display_type == 'product'
+ and not self.env.context.get('disable_onchange_name_predictive', False)):
+
+ if not self.product_id:
+ predicted_product_id = self._predict_product()
+ if predicted_product_id:
+ # We only update the price_unit, tax_ids and name in case they evaluate to False
+ protected_fields = ['price_unit', 'tax_ids', 'name']
+ to_protect = [self._fields[fname] for fname in protected_fields if self[fname]]
+ with self.env.protecting(to_protect, self):
+ self.product_id = predicted_product_id
+
+ # In case no product has been set, the account and taxes
+ # will not depend on any product and can thus be predicted
+ if not self.product_id:
+ # Predict account.
+ predicted_account_id = self._predict_account()
+ if predicted_account_id:
+ self.account_id = predicted_account_id
+
+ # Predict taxes
+ predicted_tax_ids = self._predict_taxes()
+ if predicted_tax_ids:
+ self.tax_ids = [Command.set(predicted_tax_ids)]
+
+ def _read_group_select(self, aggregate_spec, query):
+ # Enable to use HAVING clause that sum rounded values depending on the
+ # currency precision settings. Limitation: we only handle a having
+ # clause of one element with that specific method :sum_rounded.
+ fname, __, func = models.parse_read_group_spec(aggregate_spec)
+ if func != 'sum_rounded':
+ return super()._read_group_select(aggregate_spec, query)
+ currency_alias = query.make_alias(self._table, 'currency_id')
+ query.add_join('LEFT JOIN', currency_alias, 'res_currency', SQL(
+ "%s = %s",
+ self._field_to_sql(self._table, 'currency_id', query),
+ SQL.identifier(currency_alias, 'id'),
+ ))
+
+ return SQL(
+ 'SUM(ROUND(%s, %s))',
+ self._field_to_sql(self._table, fname, query),
+ self.env['res.currency']._field_to_sql(currency_alias, 'decimal_places', query),
+ )
+
+ def _read_group_groupby(self, groupby_spec, query):
+ # enable grouping by :abs_rounded on fields, which is useful when trying
+ # to match positive and negative amounts
+ if ':' in groupby_spec:
+ fname, method = groupby_spec.split(':')
+ if method == 'abs_rounded':
+ # rounds with the used currency settings
+ currency_alias = query.make_alias(self._table, 'currency_id')
+ query.add_join('LEFT JOIN', currency_alias, 'res_currency', SQL(
+ "%s = %s",
+ self._field_to_sql(self._table, 'currency_id', query),
+ SQL.identifier(currency_alias, 'id'),
+ ))
+
+ return SQL(
+ 'ROUND(ABS(%s), %s)',
+ self._field_to_sql(self._table, fname, query),
+ self.env['res.currency']._field_to_sql(currency_alias, 'decimal_places', query),
+ )
+
+ return super()._read_group_groupby(groupby_spec, query)
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_payment.py b/dev_odex30_accounting/odex30_account_accountant/models/account_payment.py
new file mode 100644
index 0000000..d441aca
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/models/account_payment.py
@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+import ast
+from odoo import models, _
+
+
+class AccountPayment(models.Model):
+ _inherit = "account.payment"
+
+ def action_open_manual_reconciliation_widget(self):
+
+ self.ensure_one()
+ action_values = self.env['ir.actions.act_window']._for_xml_id('odex30_account_accountant.action_move_line_posted_unreconciled')
+ if self.partner_id:
+ context = ast.literal_eval(action_values['context'])
+ context.update({'search_default_partner_id': self.partner_id.id})
+ if self.partner_type == 'customer':
+ context.update({'search_default_trade_receivable': 1})
+ elif self.partner_type == 'supplier':
+ context.update({'search_default_trade_payable': 1})
+ action_values['context'] = context
+ return action_values
+
+ def button_open_statement_lines(self):
+
+ self.ensure_one()
+
+ return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
+ extra_domain=[('id', 'in', self.reconciled_statement_line_ids.ids)],
+ default_context={
+ 'create': False,
+ 'default_st_line_id': self.reconciled_statement_line_ids.ids[-1],
+ },
+ name=_("Matched Transactions")
+ )
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_reconcile_model.py b/dev_odex30_accounting/odex30_account_accountant/models/account_reconcile_model.py
new file mode 100644
index 0000000..7b0652d
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/models/account_reconcile_model.py
@@ -0,0 +1,485 @@
+from odoo import fields, models, Command, tools
+from odoo.tools import SQL
+
+import re
+from collections import defaultdict
+from dateutil.relativedelta import relativedelta
+
+
+class AccountReconcileModel(models.Model):
+ _inherit = 'account.reconcile.model'
+
+ ####################################################
+ # RECONCILIATION PROCESS
+ ####################################################
+
+ def _apply_lines_for_bank_widget(self, residual_amount_currency, partner, st_line):
+
+ self.ensure_one()
+ currency = st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id
+ vals_list = []
+ for line in self.line_ids:
+ vals = line._apply_in_bank_widget(residual_amount_currency, partner, st_line)
+ amount_currency = vals['amount_currency']
+
+ if currency.is_zero(amount_currency):
+ continue
+
+ vals_list.append(vals)
+ residual_amount_currency -= amount_currency
+
+ return vals_list
+
+
+ def _apply_rules(self, st_line, partner):
+
+ available_models = self.filtered(lambda m: m.rule_type != 'writeoff_button').sorted()
+
+ for rec_model in available_models:
+
+ if not rec_model._is_applicable_for(st_line, partner):
+ continue
+
+ if rec_model.rule_type == 'invoice_matching':
+ rules_map = rec_model._get_invoice_matching_rules_map()
+ for rule_index in sorted(rules_map.keys()):
+ for rule_method in rules_map[rule_index]:
+ candidate_vals = rule_method(st_line, partner)
+ if not candidate_vals:
+ continue
+
+ if candidate_vals.get('amls'):
+ res = rec_model._get_invoice_matching_amls_result(st_line, partner, candidate_vals)
+ if res:
+ return {
+ **res,
+ 'model': rec_model,
+ }
+ else:
+ return {
+ **candidate_vals,
+ 'model': rec_model,
+ }
+
+ elif rec_model.rule_type == 'writeoff_suggestion':
+ return {
+ 'model': rec_model,
+ 'status': 'write_off',
+ 'auto_reconcile': rec_model.auto_reconcile,
+ }
+ return {}
+
+ def _is_applicable_for(self, st_line, partner):
+
+ self.ensure_one()
+
+ # Filter on journals, amount nature, amount and partners
+ # All the conditions defined in this block are non-match conditions.
+ if ((self.match_journal_ids and st_line.move_id.journal_id not in self.match_journal_ids)
+ or (self.match_nature == 'amount_received' and st_line.amount < 0)
+ or (self.match_nature == 'amount_paid' and st_line.amount > 0)
+ or (self.match_amount == 'lower' and abs(st_line.amount) >= self.match_amount_max)
+ or (self.match_amount == 'greater' and abs(st_line.amount) <= self.match_amount_min)
+ or (self.match_amount == 'between' and (abs(st_line.amount) > self.match_amount_max or abs(st_line.amount) < self.match_amount_min))
+ or (self.match_partner and not partner)
+ or (self.match_partner and self.match_partner_ids and partner not in self.match_partner_ids)
+ or (self.match_partner and self.match_partner_category_ids and not (partner.category_id & self.match_partner_category_ids))
+ ):
+ return False
+
+ # Filter on label, note and transaction_type
+ for record, rule_field, record_field in [(st_line, 'label', 'payment_ref'), (st_line.move_id, 'note', 'narration'), (st_line, 'transaction_type', 'transaction_type')]:
+ rule_term = (self['match_' + rule_field + '_param'] or '').lower()
+ record_term = (record[record_field] or '').lower()
+
+ # This defines non-match conditions
+ if ((self['match_' + rule_field] == 'contains' and rule_term not in record_term)
+ or (self['match_' + rule_field] == 'not_contains' and rule_term in record_term)
+ or (self['match_' + rule_field] == 'match_regex' and not re.match(rule_term, record_term))
+ ):
+ return False
+
+ return True
+
+ def _get_invoice_matching_amls_domain(self, st_line, partner):
+ aml_domain = st_line._get_default_amls_matching_domain()
+
+ if st_line.amount > 0.0:
+ aml_domain.append(('balance', '>', 0.0))
+ else:
+ aml_domain.append(('balance', '<', 0.0))
+
+ currency = st_line.foreign_currency_id or st_line.currency_id
+ if self.match_same_currency:
+ aml_domain.append(('currency_id', '=', currency.id))
+
+ if partner:
+ aml_domain.append(('partner_id', '=', partner.id))
+
+ if self.past_months_limit:
+ date_limit = fields.Date.context_today(self) - relativedelta(months=self.past_months_limit)
+ aml_domain.append(('date', '>=', fields.Date.to_string(date_limit)))
+
+ return aml_domain
+
+ def _get_st_line_text_values_for_matching(self, st_line):
+
+ self.ensure_one()
+ allowed_fields = []
+ if self.match_text_location_label:
+ allowed_fields.append('payment_ref')
+ if self.match_text_location_note:
+ allowed_fields.append('narration')
+ if self.match_text_location_reference:
+ allowed_fields.append('ref')
+ return st_line._get_st_line_strings_for_matching(allowed_fields=allowed_fields)
+
+ def _get_invoice_matching_st_line_tokens(self, st_line):
+
+ st_line_text_values = self._get_st_line_text_values_for_matching(st_line)
+ significant_token_size = 4
+ numerical_tokens = []
+ exact_tokens = set() # preventing duplicates
+ text_tokens = []
+ for text_value in st_line_text_values:
+ split_text = (text_value or '').split()
+ # Exact tokens
+ exact_tokens.add(text_value)
+ exact_tokens.update(
+ token for token in split_text
+ if len(token) >= significant_token_size
+ )
+ # Text tokens
+ tokens = [
+ ''.join(x for x in token if re.match(r'[0-9a-zA-Z\s]', x))
+ for token in split_text
+ ]
+
+ # Numerical tokens
+ for token in tokens:
+ # The token is too short to be significant.
+ if len(token) < significant_token_size:
+ continue
+
+ text_tokens.append(token)
+
+ formatted_token = ''.join(x for x in token if x.isdecimal())
+
+ # The token is too short after formatting to be significant.
+ if len(formatted_token) < significant_token_size:
+ continue
+
+ numerical_tokens.append(formatted_token)
+
+ return numerical_tokens, list(exact_tokens), text_tokens
+
+ def _get_invoice_matching_amls_candidates(self, st_line, partner):
+
+ def get_order_by_clause(prefix=SQL()):
+ direction = SQL(' DESC') if self.matching_order == 'new_first' else SQL(' ASC')
+ return SQL(", ").join(
+ SQL("%s%s%s", prefix, SQL(field), direction)
+ for field in ('date_maturity', 'date', 'id')
+ )
+
+ assert self.rule_type == 'invoice_matching'
+ self.env['account.move'].flush_model()
+ self.env['account.move.line'].flush_model()
+
+ aml_domain = self._get_invoice_matching_amls_domain(st_line, partner)
+ query = self.env['account.move.line']._where_calc(aml_domain)
+ tables = query.from_clause
+ where_clause = query.where_clause or SQL("TRUE")
+
+ aml_cte = SQL()
+ sub_queries: list[SQL] = []
+ numerical_tokens, exact_tokens, _text_tokens = self._get_invoice_matching_st_line_tokens(st_line)
+ if numerical_tokens or exact_tokens:
+ aml_cte = SQL('''
+ WITH aml_cte AS (
+ SELECT
+ account_move_line.id as account_move_line_id,
+ account_move_line.date as account_move_line_date,
+ account_move_line.date_maturity as account_move_line_date_maturity,
+ account_move_line.name as account_move_line_name,
+ account_move_line__move_id.name as account_move_line__move_id_name,
+ account_move_line__move_id.ref as account_move_line__move_id_ref
+ FROM %s
+ JOIN account_move account_move_line__move_id ON account_move_line__move_id.id = account_move_line.move_id
+ WHERE %s
+ )
+ ''', tables, where_clause)
+ if numerical_tokens:
+ for table_alias, field in (
+ ('account_move_line', 'name'),
+ ('account_move_line__move_id', 'name'),
+ ('account_move_line__move_id', 'ref'),
+ ):
+ sub_queries.append(SQL(r'''
+ SELECT
+ account_move_line_id as id,
+ account_move_line_date as date,
+ account_move_line_date_maturity as date_maturity,
+ UNNEST(
+ REGEXP_SPLIT_TO_ARRAY(
+ SUBSTRING(
+ REGEXP_REPLACE(%(field)s, '[^0-9\s]', '', 'g'),
+ '\S(?:.*\S)*'
+ ),
+ '\s+'
+ )
+ ) AS token
+ FROM aml_cte
+ WHERE %(field)s IS NOT NULL
+ ''', field=SQL("%s_%s", SQL(table_alias), SQL(field))))
+ if exact_tokens:
+ for table_alias, field in (
+ ('account_move_line', 'name'),
+ ('account_move_line__move_id', 'name'),
+ ('account_move_line__move_id', 'ref'),
+ ):
+ sub_queries.append(SQL('''
+ SELECT
+ account_move_line_id as id,
+ account_move_line_date as date,
+ account_move_line_date_maturity as date_maturity,
+ %(field)s AS token
+ FROM aml_cte
+ WHERE %(field)s != ''
+ ''', field=SQL("%s_%s", SQL(table_alias), SQL(field))))
+ if sub_queries:
+ order_by = get_order_by_clause(prefix=SQL('sub.'))
+ candidate_ids = [r[0] for r in self.env.execute_query(SQL(
+ '''
+ %s
+ SELECT
+ sub.id,
+ COUNT(*) AS nb_match
+ FROM (%s) AS sub
+ WHERE sub.token IN %s
+ GROUP BY sub.date_maturity, sub.date, sub.id
+ HAVING COUNT(*) > 0
+ ORDER BY nb_match DESC, %s
+ ''',
+ aml_cte,
+ SQL(" UNION ALL ").join(sub_queries),
+ tuple(numerical_tokens + exact_tokens),
+ order_by,
+ ))]
+ if candidate_ids:
+ return {
+ 'allow_auto_reconcile': True,
+ 'amls': self.env['account.move.line'].browse(candidate_ids),
+ }
+ elif self.match_text_location_label or self.match_text_location_note or self.match_text_location_reference:
+ # In the case any of the Label, Note or Reference matching rule has been toggled, and the query didn't return
+ # any candidates, the model should not try to mount another aml instead.
+ return
+
+ if not partner:
+ st_line_currency = st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id
+ if st_line_currency == self.company_id.currency_id:
+ aml_amount_field = SQL('amount_residual')
+ else:
+ aml_amount_field = SQL('amount_residual_currency')
+
+ order_by = get_order_by_clause(prefix=SQL('account_move_line.'))
+ rows = self.env.execute_query(SQL(
+ '''
+ SELECT account_move_line.id
+ FROM %s
+ WHERE
+ %s
+ AND account_move_line.currency_id = %s
+ AND ROUND(account_move_line.%s, %s) = ROUND(%s, %s)
+ ORDER BY %s
+ ''',
+ tables,
+ where_clause,
+ st_line_currency.id,
+ aml_amount_field,
+ st_line_currency.decimal_places,
+ -st_line.amount_residual,
+ st_line_currency.decimal_places,
+ order_by,
+ ))
+ amls = self.env['account.move.line'].browse([row[0] for row in rows])
+ else:
+ amls = self.env['account.move.line'].search(aml_domain, order=get_order_by_clause().code)
+
+ if amls:
+ return {
+ 'allow_auto_reconcile': False,
+ 'amls': amls,
+ }
+
+ def _get_invoice_matching_rules_map(self):
+
+ rules_map = defaultdict(list)
+ rules_map[10].append(self._get_invoice_matching_amls_candidates)
+ return rules_map
+
+ def _get_partner_from_mapping(self, st_line):
+
+ self.ensure_one()
+
+ if self.rule_type not in ('invoice_matching', 'writeoff_suggestion'):
+ return self.env['res.partner']
+
+ for partner_mapping in self.partner_mapping_line_ids:
+ match_payment_ref = True
+ if partner_mapping.payment_ref_regex:
+ match_payment_ref = re.match(partner_mapping.payment_ref_regex, st_line.payment_ref) if st_line.payment_ref else False
+
+ match_narration = True
+ if partner_mapping.narration_regex:
+ match_narration = re.match(
+ partner_mapping.narration_regex,
+ tools.html2plaintext(st_line.narration or '').rstrip(),
+ flags=re.DOTALL, # Ignore '/n' set by online sync.
+ )
+
+ if match_payment_ref and match_narration:
+ return partner_mapping.partner_id
+ return self.env['res.partner']
+
+ def _get_invoice_matching_amls_result(self, st_line, partner, candidate_vals):
+ def _create_result_dict(amls_values_list, status):
+ if 'rejected' in status:
+ return
+
+ result = {'amls': self.env['account.move.line']}
+ for aml_values in amls_values_list:
+ result['amls'] |= aml_values['aml']
+
+ if 'allow_write_off' in status and self.line_ids:
+ result['status'] = 'write_off'
+
+ if 'allow_auto_reconcile' in status and candidate_vals['allow_auto_reconcile'] and self.auto_reconcile:
+ result['auto_reconcile'] = True
+
+ return result
+
+ st_line_currency = st_line.foreign_currency_id or st_line.currency_id
+ st_line_amount = st_line._prepare_move_line_default_vals()[1]['amount_currency']
+ sign = 1 if st_line_amount > 0.0 else -1
+
+ amls = candidate_vals['amls']
+ amls_values_list = []
+ amls_with_epd_values_list = []
+ same_currency_mode = amls.currency_id == st_line_currency
+ for aml in amls:
+ aml_values = {
+ 'aml': aml,
+ 'amount_residual': aml.amount_residual,
+ 'amount_residual_currency': aml.amount_residual_currency,
+ }
+
+ amls_values_list.append(aml_values)
+
+ # Manage the early payment discount.
+ if aml.move_id._is_eligible_for_early_payment_discount(st_line_currency, st_line.date):
+
+ rate = abs(aml.amount_currency) / abs(aml.balance) if aml.balance else 1.0
+ amls_with_epd_values_list.append({
+ **aml_values,
+ 'amount_residual': st_line.company_currency_id.round(aml.discount_amount_currency / rate),
+ 'amount_residual_currency': aml.discount_amount_currency,
+ })
+ else:
+ amls_with_epd_values_list.append(aml_values)
+
+ def match_batch_amls(amls_values_list):
+ if not same_currency_mode:
+ return None, []
+
+ kepts_amls_values_list = []
+ sum_amount_residual_currency = 0.0
+ for aml_values in amls_values_list:
+
+ if st_line_currency.compare_amounts(st_line_amount, -aml_values['amount_residual_currency']) == 0:
+ # Special case: the amounts are the same, submit the line directly.
+ return 'perfect', [aml_values]
+
+ if st_line_currency.compare_amounts(sign * (st_line_amount + sum_amount_residual_currency), 0.0) > 0:
+ # Here, we still have room for other candidates ; so we add the current one to the list we keep.
+ # Then, we continue iterating, even if there is no room anymore, just in case one of the following candidates
+ # is an exact match, which would then be preferred on the current candidates.
+ kepts_amls_values_list.append(aml_values)
+ sum_amount_residual_currency += aml_values['amount_residual_currency']
+
+ if st_line_currency.is_zero(sign * (st_line_amount + sum_amount_residual_currency)):
+ return 'perfect', kepts_amls_values_list
+ elif kepts_amls_values_list:
+ return 'partial', kepts_amls_values_list
+ else:
+ return None, []
+
+ # Try to match a batch with the early payment feature. Only a perfect match is allowed.
+ match_type, kepts_amls_values_list = match_batch_amls(amls_with_epd_values_list)
+ if match_type != 'perfect':
+ kepts_amls_values_list = []
+
+ # Try to match the amls having the same currency as the statement line.
+ if not kepts_amls_values_list:
+ _match_type, kepts_amls_values_list = match_batch_amls(amls_values_list)
+
+ # Try to match the whole candidates.
+ if not kepts_amls_values_list:
+ kepts_amls_values_list = amls_values_list
+
+ # Try to match the amls having the same currency as the statement line.
+ if kepts_amls_values_list:
+ status = self._check_rule_propositions(st_line, kepts_amls_values_list)
+ result = _create_result_dict(kepts_amls_values_list, status)
+ if result:
+ return result
+
+ def _check_rule_propositions(self, st_line, amls_values_list):
+
+ self.ensure_one()
+
+ if not self.allow_payment_tolerance:
+ return {'allow_write_off', 'allow_auto_reconcile'}
+
+ st_line_currency = st_line.foreign_currency_id or st_line.currency_id
+ st_line_amount_curr = st_line._prepare_move_line_default_vals()[1]['amount_currency']
+ amls_amount_curr = sum(
+ st_line._prepare_counterpart_amounts_using_st_line_rate(
+ aml_values['aml'].currency_id,
+ aml_values['amount_residual'],
+ aml_values['amount_residual_currency'],
+ )['amount_currency']
+ for aml_values in amls_values_list
+ )
+ sign = 1 if st_line_amount_curr > 0.0 else -1
+ amount_curr_after_rec = st_line_currency.round(
+ sign * (amls_amount_curr + st_line_amount_curr)
+ )
+
+ if st_line_currency.is_zero(amount_curr_after_rec):
+ return {'allow_auto_reconcile'}
+
+
+ if amount_curr_after_rec > 0.0:
+ return {'allow_auto_reconcile'}
+
+ if self.payment_tolerance_param == 0:
+ return {'rejected'}
+
+
+ if self.payment_tolerance_type == 'fixed_amount' and st_line_currency.compare_amounts(-amount_curr_after_rec, self.payment_tolerance_param) <= 0:
+ return {'allow_write_off', 'allow_auto_reconcile'}
+
+ reconciled_percentage_left = (abs(amount_curr_after_rec / amls_amount_curr)) * 100.0
+ if self.payment_tolerance_type == 'percentage' and st_line_currency.compare_amounts(reconciled_percentage_left, self.payment_tolerance_param) <= 0:
+ return {'allow_write_off', 'allow_auto_reconcile'}
+
+ return {'rejected'}
+
+ def run_auto_reconciliation(self):
+
+
+ cron_limit_time = tools.config['limit_time_real_cron'] or -1
+ limit_time = cron_limit_time if 0 < cron_limit_time < 180 else 180
+ self.env['account.bank.statement.line']._cron_try_auto_reconcile_statement_lines(limit_time=limit_time)
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_reconcile_model_line.py b/dev_odex30_accounting/odex30_account_accountant/models/account_reconcile_model_line.py
new file mode 100644
index 0000000..4be6936
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/models/account_reconcile_model_line.py
@@ -0,0 +1,117 @@
+from odoo import models, Command, _
+from odoo.exceptions import UserError
+
+import re
+
+from math import copysign
+
+
+class AccountReconcileModelLine(models.Model):
+ _inherit = 'account.reconcile.model.line'
+
+ def _prepare_aml_vals(self, partner):
+ """ Prepare a dictionary that will be used later to create a new journal item (account.move.line) for the
+ given reconcile model line.
+
+ :param partner: The partner to be linked to the journal item.
+ :return: A python dictionary.
+ """
+ self.ensure_one()
+
+ taxes = self.tax_ids
+ if taxes and partner:
+ fiscal_position = self.env['account.fiscal.position']._get_fiscal_position(partner)
+ if fiscal_position:
+ taxes = fiscal_position.map_tax(taxes)
+
+ values = {
+ 'name': self.label,
+ 'partner_id': partner.id,
+ 'analytic_distribution': self.analytic_distribution,
+ 'tax_ids': [Command.set(taxes.ids)],
+ 'reconcile_model_id': self.model_id.id,
+ }
+ if self.account_id:
+ values['account_id'] = self.account_id.id
+ return values
+
+ def _apply_in_manual_widget(self, residual_amount_currency, partner, currency):
+ """ Prepare a dictionary that will be used later to create a new journal item (account.move.line) for the
+ given reconcile model line used by the manual reconciliation widget.
+
+ Note: 'journal_id' is added to the returned dictionary even if it is a related readonly field.
+ It's a hack for the manual reconciliation widget. Indeed, a single journal entry will be created for each
+ journal.
+
+ :param residual_amount_currency: The current balance expressed in the account's currency.
+ :param partner: The partner to be linked to the journal item.
+ :param currency: The currency set on the account in the manual reconciliation widget.
+ :return: A python dictionary.
+ """
+ self.ensure_one()
+
+ if self.amount_type == 'percentage':
+ amount_currency = currency.round(residual_amount_currency * (self.amount / 100.0))
+ elif self.amount_type == 'fixed':
+ sign = 1 if residual_amount_currency > 0.0 else -1
+ amount_currency = currency.round(self.amount * sign)
+ else:
+ raise UserError(_("This reconciliation model can't be used in the manual reconciliation widget because its "
+ "configuration is not adapted"))
+
+ return {
+ **self._prepare_aml_vals(partner),
+ 'currency_id': currency.id,
+ 'amount_currency': amount_currency,
+ 'journal_id': self.journal_id.id,
+ }
+
+ def _apply_in_bank_widget(self, residual_amount_currency, partner, st_line):
+ """ Prepare a dictionary that will be used later to create a new journal item (account.move.line) for the
+ given reconcile model line used by the bank reconciliation widget.
+
+ :param residual_amount_currency: The current balance expressed in the statement line's currency.
+ :param partner: The partner to be linked to the journal item.
+ :param st_line: The statement line mounted inside the bank reconciliation widget.
+ :return: A python dictionary.
+ """
+ self.ensure_one()
+ currency = st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id
+
+ aml_values = {'currency_id': currency.id}
+
+ if self.amount_type == 'percentage_st_line':
+ transaction_amount, transaction_currency, journal_amount, journal_currency, _company_amount, _company_currency \
+ = st_line._get_accounting_amounts_and_currencies()
+ if self.model_id.rule_type == 'writeoff_button' and self.model_id.counterpart_type in ('sale', 'purchase'):
+ # The invoice should be created using the transaction currency.
+ aml_values['amount_currency'] = currency.round(-transaction_amount * self.amount / 100.0)
+ aml_values['percentage_st_line'] = self.amount / 100.0
+ aml_values['currency_id'] = transaction_currency.id
+ else:
+ # The additional journal items follow the journal currency.
+ aml_values['amount_currency'] = currency.round(-journal_amount * self.amount / 100.0)
+ aml_values['currency_id'] = journal_currency.id
+ elif self.amount_type == 'regex':
+ match = re.search(self.amount_string, st_line.payment_ref)
+ if match:
+ sign = 1 if residual_amount_currency > 0.0 else -1
+ decimal_separator = self.model_id.decimal_separator
+ try:
+ extracted_match_group = re.sub(r'[^\d' + decimal_separator + ']', '', match.group(1))
+ extracted_balance = float(extracted_match_group.replace(decimal_separator, '.'))
+ aml_values['amount_currency'] = copysign(extracted_balance * sign, residual_amount_currency)
+ except ValueError:
+ aml_values['amount_currency'] = 0.0
+ else:
+ aml_values['amount_currency'] = 0.0
+
+ if 'amount_currency' not in aml_values:
+ aml_values.update(self._apply_in_manual_widget(residual_amount_currency, partner, currency))
+ else:
+ aml_values.update(self._prepare_aml_vals(partner))
+
+ if not aml_values.get('name', False):
+ aml_values['name'] = st_line.payment_ref
+
+ return aml_values
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_tax.py b/dev_odex30_accounting/odex30_account_accountant/models/account_tax.py
new file mode 100644
index 0000000..5e3dca9
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/models/account_tax.py
@@ -0,0 +1,52 @@
+from odoo import models
+
+
+class AccountTax(models.Model):
+ _inherit = "account.tax"
+
+ def _prepare_base_line_for_taxes_computation(self, record, **kwargs):
+ # EXTENDS 'account'
+ results = super()._prepare_base_line_for_taxes_computation(record, **kwargs)
+ results['deferred_start_date'] = self._get_base_line_field_value_from_record(record, 'deferred_start_date', kwargs, False)
+ results['deferred_end_date'] = self._get_base_line_field_value_from_record(record, 'deferred_end_date', kwargs, False)
+ return results
+
+ def _prepare_tax_line_for_taxes_computation(self, record, **kwargs):
+ # EXTENDS 'account'
+ results = super()._prepare_tax_line_for_taxes_computation(record, **kwargs)
+ results['deferred_start_date'] = self._get_base_line_field_value_from_record(record, 'deferred_start_date', kwargs, False)
+ results['deferred_end_date'] = self._get_base_line_field_value_from_record(record, 'deferred_end_date', kwargs, False)
+ return results
+
+ def _prepare_base_line_grouping_key(self, base_line):
+ # EXTENDS 'account'
+ results = super()._prepare_base_line_grouping_key(base_line)
+ results['deferred_start_date'] = base_line['deferred_start_date']
+ results['deferred_end_date'] = base_line['deferred_end_date']
+ return results
+
+ def _prepare_base_line_tax_repartition_grouping_key(self, base_line, base_line_grouping_key, tax_data, tax_rep_data):
+ # EXTENDS 'account'
+ results = super()._prepare_base_line_tax_repartition_grouping_key(base_line, base_line_grouping_key, tax_data, tax_rep_data)
+ record = base_line['record']
+ if (
+ isinstance(record, models.Model)
+ and record._name == 'account.move.line'
+ and record._has_deferred_compatible_account()
+ and base_line['deferred_start_date']
+ and base_line['deferred_end_date']
+ and not tax_rep_data['tax_rep'].use_in_tax_closing
+ ):
+ results['deferred_start_date'] = base_line['deferred_start_date']
+ results['deferred_end_date'] = base_line['deferred_end_date']
+ else:
+ results['deferred_start_date'] = False
+ results['deferred_end_date'] = False
+ return results
+
+ def _prepare_tax_line_repartition_grouping_key(self, tax_line):
+ # EXTENDS 'account'
+ results = super()._prepare_tax_line_repartition_grouping_key(tax_line)
+ results['deferred_start_date'] = tax_line['deferred_start_date']
+ results['deferred_end_date'] = tax_line['deferred_end_date']
+ return results
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/bank_rec_widget.py b/dev_odex30_accounting/odex30_account_accountant/models/bank_rec_widget.py
new file mode 100644
index 0000000..3e4fa8d
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/models/bank_rec_widget.py
@@ -0,0 +1,1785 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from collections import defaultdict
+from contextlib import contextmanager
+import json
+import markupsafe
+
+from odoo import _, api, fields, models, Command
+from odoo.addons.web.controllers.utils import clean_action
+from odoo.exceptions import UserError, RedirectWarning
+from odoo.tools.misc import formatLang
+
+
+class BankRecWidget(models.Model):
+ _name = "bank.rec.widget"
+ _description = "Bank reconciliation widget for a single statement line"
+
+ # This model is never saved inside the database.
+ # _auto=False' & _table_query = "0" prevent the ORM to create the corresponding postgresql table.
+ _auto = False
+ _table_query = "0"
+
+ # ==== Business fields ====
+ st_line_id = fields.Many2one(comodel_name='account.bank.statement.line')
+ move_id = fields.Many2one(
+ related='st_line_id.move_id',
+ depends=['st_line_id'],
+ )
+ st_line_checked = fields.Boolean(
+ related='st_line_id.move_id.checked',
+ depends=['st_line_id'],
+ )
+ st_line_is_reconciled = fields.Boolean(
+ related='st_line_id.is_reconciled',
+ depends=['st_line_id'],
+ )
+ st_line_journal_id = fields.Many2one(
+ related='st_line_id.journal_id',
+ depends=['st_line_id'],
+ )
+ st_line_transaction_details = fields.Html(
+ compute='_compute_st_line_transaction_details',
+ )
+ transaction_currency_id = fields.Many2one(
+ comodel_name='res.currency',
+ compute='_compute_transaction_currency_id',
+ )
+ journal_currency_id = fields.Many2one(
+ comodel_name='res.currency',
+ compute='_compute_journal_currency_id',
+ )
+ partner_id = fields.Many2one(
+ comodel_name='res.partner',
+ string="Partner",
+ compute='_compute_partner_id',
+ store=True,
+ readonly=False,
+ )
+ line_ids = fields.One2many(
+ comodel_name='bank.rec.widget.line',
+ inverse_name='wizard_id',
+ compute='_compute_line_ids',
+ compute_sudo=False,
+ store=True,
+ readonly=False,
+ )
+ available_reco_model_ids = fields.Many2many(
+ comodel_name='account.reconcile.model',
+ compute='_compute_available_reco_model_ids',
+ store=True,
+ readonly=False,
+ )
+ selected_reco_model_id = fields.Many2one(
+ comodel_name='account.reconcile.model',
+ compute='_compute_selected_reco_model_id',
+ )
+ partner_name = fields.Char(
+ related='st_line_id.partner_name',
+ )
+
+ company_id = fields.Many2one(
+ comodel_name='res.company',
+ related='st_line_id.company_id',
+ depends=['st_line_id'],
+ )
+
+ country_code = fields.Char(related='company_id.country_id.code', depends=['company_id'])
+
+ company_currency_id = fields.Many2one(
+ string="Wizard Company Currency",
+ related='company_id.currency_id',
+ depends=['st_line_id'],
+ )
+ matching_rules_allow_auto_reconcile = fields.Boolean()
+
+ # ==== Display fields ====
+ state = fields.Selection(
+ selection=[
+ ('invalid', "Invalid"),
+ ('valid', "Valid"),
+ ('reconciled', "Reconciled"),
+ ],
+ compute='_compute_state',
+ store=True,
+ help="Invalid: The bank transaction can't be validate since the suspense account is still involved\n"
+ "Valid: The bank transaction can be validated.\n"
+ "Reconciled: The bank transaction has already been processed. Nothing left to do."
+ )
+ is_multi_currency = fields.Boolean(
+ compute='_compute_is_multi_currency',
+ )
+
+ # ==== JS fields ====
+ selected_aml_ids = fields.Many2many(
+ comodel_name='account.move.line',
+ compute='_compute_selected_aml_ids',
+ )
+ todo_command = fields.Json(
+ store=False,
+ )
+ return_todo_command = fields.Json(
+ store=False,
+ )
+ form_index = fields.Char()
+
+
+ @api.depends('st_line_id')
+ def _compute_line_ids(self):
+ for wizard in self:
+ if wizard.st_line_id:
+
+ # Liquidity line.
+ line_ids_commands = [
+ Command.clear(),
+ Command.create(wizard._lines_prepare_liquidity_line()),
+ ]
+
+ _liquidity_lines, _suspense_lines, other_lines = wizard.st_line_id._seek_for_lines()
+ for aml in other_lines:
+ exchange_diff_amls = (aml.matched_debit_ids + aml.matched_credit_ids) \
+ .exchange_move_id.line_ids.filtered(lambda l: l.account_id != aml.account_id)
+ if wizard.state == 'reconciled' and exchange_diff_amls:
+ line_ids_commands.append(
+ Command.create(wizard._lines_prepare_aml_line(
+ aml, # Create the aml line with un-squashed amounts (aml - exchange diff)
+ balance=aml.balance - sum(exchange_diff_amls.mapped('balance')),
+ amount_currency=aml.amount_currency - sum(exchange_diff_amls.mapped('amount_currency')),
+ ))
+ )
+ for exchange_diff_aml in exchange_diff_amls:
+ line_ids_commands.append(
+ Command.create(wizard._lines_prepare_aml_line(exchange_diff_aml))
+ )
+ else:
+ line_ids_commands.append(Command.create(wizard._lines_prepare_aml_line(aml)))
+
+ wizard.line_ids = line_ids_commands
+
+ wizard._lines_add_auto_balance_line()
+
+ else:
+
+ wizard.line_ids = [Command.clear()]
+
+ @api.depends('st_line_id')
+ def _compute_available_reco_model_ids(self):
+ for wizard in self:
+ if wizard.st_line_id:
+ available_reco_models = self.env['account.reconcile.model'].search([
+ ('rule_type', '=', 'writeoff_button'),
+ ('company_id', '=', wizard.st_line_id.company_id.id),
+ '|',
+ ('match_journal_ids', '=', False),
+ ('match_journal_ids', '=', wizard.st_line_id.journal_id.id),
+ ])
+ available_reco_models = available_reco_models.filtered(
+ lambda x: x.counterpart_type == 'general'
+ or len(x.line_ids.journal_id) <= 1
+ )
+ wizard.available_reco_model_ids = [Command.set(available_reco_models.ids)]
+ else:
+ wizard.available_reco_model_ids = [Command.clear()]
+
+ @api.depends('line_ids.reconcile_model_id')
+ def _compute_selected_reco_model_id(self):
+ for wizard in self:
+ selected_reconcile_models = wizard.line_ids.reconcile_model_id.filtered(lambda x: x.rule_type == 'writeoff_button')
+ if len(selected_reconcile_models) == 1:
+ wizard.selected_reco_model_id = selected_reconcile_models.id
+ else:
+ wizard.selected_reco_model_id = None
+
+ @api.depends('st_line_id', 'line_ids.account_id')
+ def _compute_state(self):
+ for wizard in self:
+ if not wizard.st_line_id:
+ wizard.state = 'invalid'
+ elif wizard.st_line_id.is_reconciled:
+ wizard.state = 'reconciled'
+ else:
+ suspense_account = wizard.st_line_id.journal_id.suspense_account_id
+ if suspense_account in wizard.line_ids.account_id:
+ wizard.state = 'invalid'
+ else:
+ wizard.state = 'valid'
+
+ @api.depends('st_line_id')
+ def _compute_journal_currency_id(self):
+ for wizard in self:
+ wizard.journal_currency_id = wizard.st_line_id.journal_id.currency_id \
+ or wizard.st_line_id.journal_id.company_id.currency_id
+
+ def _format_transaction_details(self):
+ """ Format the 'transaction_details' field of the statement line to be more readable for the end user.
+
+ Example:
+ {
+ "debtor": {
+ "name": None,
+ "private_id": None,
+ },
+ "debtor_account": {
+ "iban": "BE84103080286059",
+ "bank_transaction_code": None,
+ "credit_debit_indicator": "DBIT",
+ "status": "BOOK",
+ "value_date": "2022-12-29",
+ "transaction_date": None,
+ "balance_after_transaction": None,
+ },
+ }
+
+ Becomes:
+ debtor_account:
+ iban: BE84103080286059
+ credit_debit_indicator: DBIT
+ status: BOOK
+ value_date: 2022-12-29
+
+ :return: An html representation of the transaction details.
+ """
+ self.ensure_one()
+ details = self.st_line_id.transaction_details
+ if not details:
+ return
+
+ if isinstance(details, str):
+ details = json.loads(details, strict=False)
+
+ def node_to_html(header, node):
+ if not node:
+ return ""
+
+ if isinstance(node, dict):
+ li_elements = markupsafe.Markup("").join(node_to_html(f"{k}: ", v) for k, v in node.items())
+ value = li_elements and markupsafe.Markup('%s') % li_elements
+ elif isinstance(node, (tuple, list)):
+ li_elements = markupsafe.Markup("").join(node_to_html(f"{i}: ", v) for i, v in enumerate(node, start=1))
+ value = li_elements and markupsafe.Markup('%s') % li_elements
+ else:
+ value = node
+
+ if not value:
+ return ""
+
+ return markupsafe.Markup('
%(header)s%(value)s
') % {
+ 'header': header,
+ 'value': value,
+ }
+
+ main_html = node_to_html('', details)
+ return markupsafe.Markup("%s") % main_html
+
+ @api.depends('st_line_id')
+ def _compute_st_line_transaction_details(self):
+ for wizard in self:
+ wizard.st_line_transaction_details = wizard._format_transaction_details()
+
+ @api.depends('st_line_id')
+ def _compute_transaction_currency_id(self):
+ for wizard in self:
+ wizard.transaction_currency_id = wizard.st_line_id.foreign_currency_id or wizard.journal_currency_id
+
+ @api.depends('st_line_id')
+ def _compute_partner_id(self):
+ for wizard in self:
+ if wizard.st_line_id:
+ wizard.partner_id = wizard.st_line_id._retrieve_partner()
+ else:
+ wizard.partner_id = None
+
+ @api.depends('company_id')
+ def _compute_is_multi_currency(self):
+ self.is_multi_currency = self.env.user.has_groups('base.group_multi_currency')
+
+ @api.depends('company_id', 'line_ids.source_aml_id')
+ def _compute_selected_aml_ids(self):
+ for wizard in self:
+ wizard.selected_aml_ids = [Command.set(wizard.line_ids.source_aml_id.ids)]
+
+ # -------------------------------------------------------------------------
+ # ONCHANGE METHODS
+ # -------------------------------------------------------------------------
+
+ @api.onchange('todo_command')
+ def _onchange_todo_command(self):
+ self.ensure_one()
+ todo_command = self.todo_command
+ self.todo_command = None
+ self.return_todo_command = None
+
+ # Ensure the lines are well loaded.
+ # Suppose the initial values of 'line_ids' are 2 lines,
+ # "self.line_ids = [Command.create(...)]" will produce a single new line in 'line_ids' but three lines in case
+ # the field is accessed before.
+ self._ensure_loaded_lines()
+
+ method_name = todo_command['method_name']
+ getattr(self, f'_js_action_{method_name}')(*todo_command.get('args', []), **todo_command.get('kwargs', {}))
+
+ # -------------------------------------------------------------------------
+ # LOW-LEVEL METHODS
+ # -------------------------------------------------------------------------
+
+ @api.model
+ def new(self, values=None, origin=None, ref=None):
+ widget = super().new(values=values, origin=origin, ref=ref)
+
+ # Ensure the lines are well loaded.
+ # Suppose the initial values of 'line_ids' are 2 lines,
+ # "self.line_ids = [Command.create(...)]" will produce a single new line in 'line_ids' but three lines in case
+ # the field is accessed before.
+ widget.line_ids
+
+ return widget
+
+ # -------------------------------------------------------------------------
+ # INIT
+ # -------------------------------------------------------------------------
+
+ @api.model
+ def fetch_initial_data(self):
+ # Fields.
+ fields = self.fields_get()
+ field_attributes = self.env['ir.ui.view']._get_view_field_attributes()
+ for field_name, field in self._fields.items():
+ if field.type == 'one2many':
+ fields[field_name]['relatedFields'] = self[field_name]\
+ .fields_get(attributes=field_attributes)
+ del fields[field_name]['relatedFields'][field.inverse_name]
+ for one2many_fieldname, one2many_field in self[field_name]._fields.items():
+ if one2many_field.type == "many2many":
+ comodel = self.env[one2many_field.comodel_name]
+ fields[field_name]['relatedFields'][one2many_fieldname]['relatedFields'] = comodel \
+ .fields_get(allfields=['id', 'display_name'], attributes=field_attributes)
+ elif field.name == 'available_reco_model_ids':
+ fields[field_name]['relatedFields'] = self[field_name]\
+ .fields_get(allfields=['id', 'display_name'], attributes=field_attributes)
+
+ fields['todo_command']['onChange'] = True
+
+ # Initial values.
+ initial_values = {}
+ for field_name, field in self._fields.items():
+ if field.type == 'one2many':
+ initial_values[field_name] = []
+ else:
+ initial_values[field_name] = field.convert_to_read(self[field_name], self, {})
+
+ return {
+ 'initial_values': initial_values,
+ 'fields': fields,
+ }
+
+ # -------------------------------------------------------------------------
+ # LINES METHODS
+ # -------------------------------------------------------------------------
+
+ def _ensure_loaded_lines(self):
+ # Ensure the lines are well loaded.
+ # Suppose the initial values of 'line_ids' are 2 lines,
+ # "self.line_ids = [Command.create(...)]" will produce a single new line in 'line_ids' but three lines in case
+ # the field is accessed before.
+ self.line_ids
+
+ def _lines_turn_auto_balance_into_manual_line(self, line):
+ # When editing an auto_balance line, it becomes a custom manual line.
+ if line.flag == 'auto_balance':
+ line.flag = 'manual'
+
+ def _lines_get_line_in_edit_form(self):
+ self.ensure_one()
+
+ if not self.form_index:
+ return
+
+ return self.line_ids.filtered(lambda x: x.index == self.form_index)
+
+ def _lines_prepare_aml_line(self, aml, **kwargs):
+ self.ensure_one()
+ return {
+ 'flag': 'aml',
+ 'source_aml_id': aml.id,
+ **kwargs,
+ }
+
+ def _lines_prepare_liquidity_line(self):
+ """ Create a line corresponding to the journal item having the liquidity account on the statement line."""
+ self.ensure_one()
+ st_line = self.st_line_id
+
+ # In case of a different currencies on the journal and on the transaction, we need to retrieve the transaction
+ # amount on the suspense line because a journal item can only have one foreign currency. Indeed, in such
+ # configuration, the foreign currency amount expressed in journal's currency is set on the liquidity line but
+ # the transaction amount is on the suspense account line.
+ liquidity_line, _suspense_lines, _other_lines = st_line._seek_for_lines()
+
+ return self._lines_prepare_aml_line(liquidity_line, flag='liquidity')
+
+ def _lines_prepare_auto_balance_line(self):
+ """ Create the auto_balance line if necessary in order to have fully balanced lines."""
+ self.ensure_one()
+ st_line = self.st_line_id
+
+ # Compute the current open balance.
+ transaction_amount, transaction_currency, journal_amount, _journal_currency, company_amount, _company_currency \
+ = self.st_line_id._get_accounting_amounts_and_currencies()
+ open_amount_currency = -transaction_amount
+ open_balance = -company_amount
+ for line in self.line_ids:
+ if line.flag in ('liquidity', 'auto_balance'):
+ continue
+
+ open_balance -= line.balance
+ journal_transaction_rate = abs(transaction_amount / journal_amount) if journal_amount else 0.0
+ company_transaction_rate = abs(transaction_amount / company_amount) if company_amount else 0.0
+ if line.currency_id == self.transaction_currency_id:
+ open_amount_currency -= line.amount_currency
+ elif line.currency_id == self.journal_currency_id:
+ open_amount_currency -= transaction_currency.round(line.amount_currency * journal_transaction_rate)
+ else:
+ open_amount_currency -= transaction_currency.round(line.balance * company_transaction_rate)
+
+ # Create a new auto-balance line.
+ account = None
+ partner = self.partner_id
+ if partner:
+ name = _("Open balance of %(amount)s", amount=formatLang(self.env, transaction_amount, currency_obj=transaction_currency))
+ partner_is_customer = partner.customer_rank and not partner.supplier_rank
+ partner_is_supplier = partner.supplier_rank and not partner.customer_rank
+ if partner_is_customer:
+ account = partner.with_company(st_line.company_id).property_account_receivable_id
+ elif partner_is_supplier:
+ account = partner.with_company(st_line.company_id).property_account_payable_id
+ elif st_line.amount > 0:
+ account = partner.with_company(st_line.company_id).property_account_receivable_id
+ else:
+ account = partner.with_company(st_line.company_id).property_account_payable_id
+
+ if not account:
+ name = st_line.payment_ref
+ account = st_line.journal_id.suspense_account_id
+
+ return {
+ 'flag': 'auto_balance',
+
+ 'account_id': account.id,
+ 'name': name,
+ 'amount_currency': open_amount_currency,
+ 'balance': open_balance,
+ }
+
+ def _lines_add_auto_balance_line(self):
+ ''' Add the line auto balancing the debit/credit. '''
+
+ # Drop the existing line then re-create it to ensure this line is always the last one.
+ line_ids_commands = []
+ for auto_balance_line in self.line_ids.filtered(lambda x: x.flag == 'auto_balance'):
+ line_ids_commands.append(Command.unlink(auto_balance_line.id))
+
+ # Re-create a new auto-balance line if needed.
+ auto_balance_line_vals = self._lines_prepare_auto_balance_line()
+ if not self.company_currency_id.is_zero(auto_balance_line_vals['balance']):
+ line_ids_commands.append(Command.create(auto_balance_line_vals))
+ self.line_ids = line_ids_commands
+
+ def _lines_prepare_new_aml_line(self, aml, **kwargs):
+ return self._lines_prepare_aml_line(
+ aml,
+ flag='new_aml',
+ currency_id=aml.currency_id.id,
+ amount_currency=-aml.amount_residual_currency,
+ balance=-aml.amount_residual,
+ source_amount_currency=-aml.amount_residual_currency,
+ source_balance=-aml.amount_residual,
+ source_rate=(aml.amount_currency / aml.balance) if aml.balance else 0.0,
+ **kwargs,
+ )
+
+ def _lines_check_partial_amount(self, line):
+ if line.flag != 'new_aml':
+ return None
+
+ exchange_diff_line = self.line_ids\
+ .filtered(lambda x: x.flag == 'exchange_diff' and x.source_aml_id == line.source_aml_id)
+ auto_balance_line_vals = self._lines_prepare_auto_balance_line()
+
+ auto_balance = auto_balance_line_vals['balance']
+ current_balance = line.balance + exchange_diff_line.balance
+ has_enough_comp_debit = self.company_currency_id.compare_amounts(auto_balance, 0) < 0 \
+ and self.company_currency_id.compare_amounts(current_balance, 0) > 0 \
+ and self.company_currency_id.compare_amounts(current_balance, -auto_balance) > 0
+ has_enough_comp_credit = self.company_currency_id.compare_amounts(auto_balance, 0) > 0 \
+ and self.company_currency_id.compare_amounts(current_balance, 0) < 0 \
+ and self.company_currency_id.compare_amounts(-current_balance, auto_balance) > 0
+
+ auto_amount_currency = auto_balance_line_vals['amount_currency']
+ current_amount_currency = line.amount_currency
+ has_enough_curr_debit = line.currency_id.compare_amounts(auto_amount_currency, 0) < 0 \
+ and line.currency_id.compare_amounts(current_amount_currency, 0) > 0 \
+ and line.currency_id.compare_amounts(current_amount_currency, -auto_amount_currency) > 0
+ has_enough_curr_credit = line.currency_id.compare_amounts(auto_amount_currency, 0) > 0 \
+ and line.currency_id.compare_amounts(current_amount_currency, 0) < 0 \
+ and line.currency_id.compare_amounts(-current_amount_currency, auto_amount_currency) > 0
+
+ if line.currency_id == self.transaction_currency_id:
+ if has_enough_curr_debit or has_enough_curr_credit:
+ amount_currency_after_partial = current_amount_currency + auto_amount_currency
+
+ # Get the bank transaction rate.
+ transaction_amount, _transaction_currency, _journal_amount, _journal_currency, company_amount, _company_currency \
+ = self.st_line_id._get_accounting_amounts_and_currencies()
+ rate = abs(company_amount / transaction_amount) if transaction_amount else 0.0
+
+ # Compute the amounts to make a partial.
+ balance_after_partial = line.company_currency_id.round(amount_currency_after_partial * rate)
+ new_line_balance = line.company_currency_id.round(balance_after_partial * abs(line.balance) / abs(current_balance))
+ exchange_diff_line_balance = balance_after_partial - new_line_balance
+ return {
+ 'exchange_diff_line': exchange_diff_line,
+ 'amount_currency': amount_currency_after_partial,
+ 'balance': new_line_balance,
+ 'exchange_balance': exchange_diff_line_balance,
+ }
+ elif has_enough_comp_debit or has_enough_comp_credit:
+ # Compute the new value for balance.
+ balance_after_partial = current_balance + auto_balance
+
+ # Get the rate of the original journal item.
+ rate = line.source_rate
+
+ # Compute the amounts to make a partial.
+ new_line_balance = line.company_currency_id.round(balance_after_partial * abs(line.balance) / abs(current_balance))
+ exchange_diff_line_balance = balance_after_partial - new_line_balance
+ amount_currency_after_partial = line.currency_id.round(new_line_balance * rate)
+ return {
+ 'exchange_diff_line': exchange_diff_line,
+ 'amount_currency': amount_currency_after_partial,
+ 'balance': new_line_balance,
+ 'exchange_balance': exchange_diff_line_balance,
+ }
+ return None
+
+ def _do_amounts_apply_for_early_payment(self, open_amount_currency, total_early_payment_discount):
+ return self.transaction_currency_id.compare_amounts(open_amount_currency, total_early_payment_discount) == 0
+
+ def _lines_check_apply_early_payment_discount(self):
+ """ Try to apply the early payment discount on the currently mounted journal items.
+ :return: True if applied, False otherwise.
+ """
+ all_aml_lines = self.line_ids.filtered(lambda x: x.flag == 'new_aml')
+
+ # Get the balance without the 'new_aml' lines.
+ auto_balance_line_vals = self._lines_prepare_auto_balance_line()
+ open_balance_wo_amls = auto_balance_line_vals['balance'] + sum(all_aml_lines.mapped('balance'))
+ open_amount_currency_wo_amls = auto_balance_line_vals['amount_currency'] + sum(all_aml_lines.mapped('amount_currency'))
+
+ # Get the balance after adding the 'new_aml' lines but without considering the partial amounts.
+ open_balance = open_balance_wo_amls - sum(all_aml_lines.mapped('source_balance'))
+ open_amount_currency = open_amount_currency_wo_amls - sum(all_aml_lines.mapped('source_amount_currency'))
+
+ is_same_currency = all_aml_lines.currency_id == self.transaction_currency_id
+ at_least_one_aml_for_early_payment = False
+
+ early_pay_aml_values_list = []
+ total_early_payment_discount = 0.0
+
+ for aml_line in all_aml_lines:
+ aml = aml_line.source_aml_id
+
+ if aml.move_id._is_eligible_for_early_payment_discount(self.transaction_currency_id, self.st_line_id.date):
+ at_least_one_aml_for_early_payment = True
+ total_early_payment_discount += aml.amount_currency - aml.discount_amount_currency
+
+ early_pay_aml_values_list.append({
+ 'aml': aml,
+ 'amount_currency': aml_line.amount_currency,
+ 'balance': aml_line.balance,
+ })
+
+ line_ids_create_command_list = []
+ is_early_payment_applied = False
+
+ # Cleanup the existing early payment discount lines.
+ for line in self.line_ids.filtered(lambda x: x.flag == 'early_payment'):
+ line_ids_create_command_list.append(Command.unlink(line.id))
+
+ if is_same_currency \
+ and at_least_one_aml_for_early_payment \
+ and self._do_amounts_apply_for_early_payment(open_amount_currency, total_early_payment_discount):
+ # == Compute the early payment discount lines ==
+ # Remove the partials on existing lines.
+ for aml_line in all_aml_lines:
+ aml_line.amount_currency = aml_line.source_amount_currency
+ aml_line.balance = aml_line.source_balance
+
+ # Add the early payment lines.
+ early_payment_values = self.env['account.move']._get_invoice_counterpart_amls_for_early_payment_discount(
+ early_pay_aml_values_list,
+ open_balance,
+ )
+
+ for vals_list in early_payment_values.values():
+ for vals in vals_list:
+ line_ids_create_command_list.append(Command.create({
+ 'flag': 'early_payment',
+ 'account_id': vals['account_id'],
+ 'date': self.st_line_id.date,
+ 'name': vals['name'],
+ 'partner_id': vals['partner_id'],
+ 'currency_id': vals['currency_id'],
+ 'amount_currency': vals['amount_currency'],
+ 'balance': vals['balance'],
+ 'analytic_distribution': vals.get('analytic_distribution'),
+ 'tax_ids': vals.get('tax_ids', []),
+ 'tax_tag_ids': vals.get('tax_tag_ids', []),
+ 'tax_repartition_line_id': vals.get('tax_repartition_line_id'),
+ 'group_tax_id': vals.get('group_tax_id'),
+ }))
+ is_early_payment_applied = True
+
+ if line_ids_create_command_list:
+ self.line_ids = line_ids_create_command_list
+
+ return is_early_payment_applied
+
+ def _lines_check_apply_partial_matching(self):
+ """ Try to apply a partial matching on the currently mounted journal items.
+ :return: True if applied, False otherwise.
+ """
+ all_aml_lines = self.line_ids.filtered(lambda x: x.flag == 'new_aml')
+ if all_aml_lines:
+ last_line = all_aml_lines[-1]
+
+ # Cleanup the existing partials if not on the last line.
+ line_ids_commands = []
+ lines_impacted = self.env['bank.rec.widget.line']
+ for aml_line in all_aml_lines:
+ is_partial = aml_line.display_stroked_amount_currency or aml_line.display_stroked_balance
+ if is_partial and not aml_line.manually_modified:
+ line_ids_commands.append(Command.update(aml_line.id, {
+ 'amount_currency': aml_line.source_amount_currency,
+ 'balance': aml_line.source_balance,
+ }))
+ lines_impacted |= aml_line
+ if line_ids_commands:
+ self.line_ids = line_ids_commands
+ self._lines_recompute_exchange_diff(lines_impacted)
+
+ # Check for a partial reconciliation.
+ partial_amounts = self._lines_check_partial_amount(last_line)
+
+ if partial_amounts:
+ # Make a partial: an auto-balance line is no longer necessary.
+ last_line.amount_currency = partial_amounts['amount_currency']
+ last_line.balance = partial_amounts['balance']
+ exchange_line = partial_amounts['exchange_diff_line']
+ if exchange_line:
+ exchange_line.balance = partial_amounts['exchange_balance']
+ if exchange_line.currency_id == self.company_currency_id:
+ exchange_line.amount_currency = exchange_line.balance
+ return True
+
+ return False
+
+ def _lines_load_new_amls(self, amls, reco_model=None):
+ """ Create counterpart lines for the journal items passed as parameter."""
+ # Create a new line for each aml.
+ line_ids_commands = []
+ kwargs = {'reconcile_model_id': reco_model.id} if reco_model else {}
+ for aml in amls:
+ aml_line_vals = self._lines_prepare_new_aml_line(aml, **kwargs)
+ line_ids_commands.append(Command.create(aml_line_vals))
+
+ if not line_ids_commands:
+ return
+
+ self.line_ids = line_ids_commands
+
+ def _prepare_base_line_for_taxes_computation(self, line):
+ """ Convert the current dictionary in order to use the generic taxes computation method defined on account.tax.
+ :return: A python dictionary.
+ """
+ self.ensure_one()
+ tax_type = line.tax_ids[0].type_tax_use if line.tax_ids else None
+ is_refund = (tax_type == 'sale' and line.balance > 0.0) or (tax_type == 'purchase' and line.balance < 0.0)
+
+ if line.force_price_included_taxes and line.tax_ids:
+ special_mode = 'total_included'
+ base_amount = line.tax_base_amount_currency
+ else:
+ special_mode = 'total_excluded'
+ base_amount = line.amount_currency
+
+ return self.env['account.tax']._prepare_base_line_for_taxes_computation(
+ line,
+ price_unit=base_amount,
+ quantity=1.0,
+ is_refund=is_refund,
+ special_mode=special_mode,
+ )
+
+ def _prepare_tax_line_for_taxes_computation(self, line):
+ """ Convert the current dictionary in order to use the generic taxes computation method defined on account.tax.
+ :return: A python dictionary.
+ """
+ self.ensure_one()
+ return self.env['account.tax']._prepare_tax_line_for_taxes_computation(line)
+
+ def _lines_prepare_tax_line(self, tax_line_vals):
+ self.ensure_one()
+
+ tax_rep = self.env['account.tax.repartition.line'].browse(tax_line_vals['tax_repartition_line_id'])
+ name = tax_rep.tax_id.name
+ if self.st_line_id.payment_ref:
+ name = f'{name} - {self.st_line_id.payment_ref}'
+ currency = self.env['res.currency'].browse(tax_line_vals['currency_id'])
+ amount_currency = tax_line_vals['amount_currency']
+ balance = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate(currency, None, amount_currency)['balance']
+
+ return {
+ 'flag': 'tax_line',
+
+ 'account_id': tax_line_vals['account_id'],
+ 'date': self.st_line_id.date,
+ 'name': name,
+ 'partner_id': tax_line_vals['partner_id'],
+ 'currency_id': currency.id,
+ 'amount_currency': amount_currency,
+ 'balance': balance,
+
+ 'analytic_distribution': tax_line_vals['analytic_distribution'],
+ 'tax_repartition_line_id': tax_rep.id,
+ 'tax_ids': tax_line_vals['tax_ids'],
+ 'tax_tag_ids': tax_line_vals['tax_tag_ids'],
+ 'group_tax_id': tax_line_vals['group_tax_id'],
+ }
+
+ def _lines_recompute_taxes(self):
+ self.ensure_one()
+ AccountTax = self.env['account.tax']
+ base_amls = self.line_ids.filtered(lambda x: x.flag == 'manual' and not x.tax_repartition_line_id)
+ base_lines = [self._prepare_base_line_for_taxes_computation(x) for x in base_amls]
+ tax_amls = self.line_ids.filtered(lambda x: x.flag == 'tax_line')
+ tax_lines = [self._prepare_tax_line_for_taxes_computation(x) for x in tax_amls]
+ AccountTax._add_tax_details_in_base_lines(base_lines, self.company_id)
+ AccountTax._round_base_lines_tax_details(base_lines, self.company_id)
+ AccountTax._add_accounting_data_in_base_lines_tax_details(base_lines, self.company_id, include_caba_tags=True)
+ tax_results = AccountTax._prepare_tax_lines(base_lines, self.company_id, tax_lines=tax_lines)
+
+ line_ids_commands = []
+
+ # Update the base lines.
+ for base_line, to_update in tax_results['base_lines_to_update']:
+ line = base_line['record']
+ amount_currency = to_update['amount_currency']
+ balance = self.st_line_id\
+ ._prepare_counterpart_amounts_using_st_line_rate(line.currency_id, None, amount_currency)['balance']
+
+ line_ids_commands.append(Command.update(line.id, {
+ 'balance': balance,
+ 'amount_currency': amount_currency,
+ 'tax_tag_ids': to_update['tax_tag_ids'],
+ }))
+
+ # Tax lines that are no longer needed.
+ for tax_line_vals in tax_results['tax_lines_to_delete']:
+ line_ids_commands.append(Command.unlink(tax_line_vals['record'].id))
+
+ # Newly created tax lines.
+ for tax_line_vals in tax_results['tax_lines_to_add']:
+ line_ids_commands.append(Command.create(self._lines_prepare_tax_line(tax_line_vals)))
+
+ # Update of existing tax lines.
+ for tax_line_vals, grouping_key, to_update in tax_results['tax_lines_to_update']:
+ new_line_vals = self._lines_prepare_tax_line({**grouping_key, **to_update})
+ line_ids_commands.append(Command.update(tax_line_vals['record'].id, {
+ 'amount_currency': new_line_vals['amount_currency'],
+ 'balance': new_line_vals['balance'],
+ }))
+
+ self.line_ids = line_ids_commands
+
+ def _get_key_mapping_aml_and_exchange_diff(self, line):
+ if line.source_aml_id:
+ return 'source_aml_id', line.source_aml_id.id
+ return None, None
+
+ def _reorder_exchange_and_aml_lines(self):
+ # Reorder to put each exchange line right after the corresponding new_aml.
+ new_lines_ids = []
+ exchange_lines = self.line_ids.filtered(lambda x: x.flag == 'exchange_diff')
+ source_2_exchange_mapping = defaultdict(lambda: self.env['bank.rec.widget.line'])
+ for line in exchange_lines:
+ source_2_exchange_mapping[self._get_key_mapping_aml_and_exchange_diff(line)] |= line
+ for line in self.line_ids:
+ if line in exchange_lines:
+ continue
+
+ new_lines_ids.append(line.id)
+ line_key = self._get_key_mapping_aml_and_exchange_diff(line)
+ if line_key in source_2_exchange_mapping:
+ new_lines_ids += source_2_exchange_mapping[line_key].mapped('id')
+ self.line_ids = self.env['bank.rec.widget.line'].browse(new_lines_ids)
+
+ def _remove_related_exchange_diff_lines(self, lines):
+ """ Delete the exchange_diff_lines related to the lines given in parameter.
+ If the parameter (lines) is not set, then all exchange_diff_lines will be removed
+ """
+ exch_diff_command_unlink = []
+ for line in lines:
+ if line.flag == 'exchange_diff':
+ continue
+
+ line_source_key, line_source_id = self._get_key_mapping_aml_and_exchange_diff(line)
+ if not line_source_key:
+ continue
+ exch_diff_command_unlink += [
+ Command.unlink(exch_diff.id)
+ for exch_diff in self.line_ids.filtered(lambda x: x[line_source_key] and x[line_source_key].id == line_source_id)
+ ]
+
+ if exch_diff_command_unlink:
+ self.line_ids = exch_diff_command_unlink
+
+ def _lines_get_account_balance_exchange_diff(self, currency, balance, amount_currency):
+ # Compute the balance of the line using the rate/currency coming from the bank transaction.
+ amounts_in_st_curr = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate(
+ currency,
+ balance,
+ amount_currency,
+ )
+ origin_balance = amounts_in_st_curr['balance']
+ if currency == self.company_currency_id and self.transaction_currency_id != self.company_currency_id:
+ # The reconciliation will be expressed using the rate of the statement line.
+ origin_balance = balance
+ elif currency != self.company_currency_id and self.transaction_currency_id == self.company_currency_id:
+ # The reconciliation will be expressed using the foreign currency of the aml to cover the Mexican
+ # case.
+ origin_balance = currency\
+ ._convert(amount_currency, self.transaction_currency_id, self.company_id, self.st_line_id.date)
+
+ # Compute the exchange difference balance.
+ exchange_diff_balance = origin_balance - balance
+ if self.company_currency_id.is_zero(exchange_diff_balance):
+ return self.env['account.account'], 0.0
+
+ expense_exchange_account = self.company_id.expense_currency_exchange_account_id
+ income_exchange_account = self.company_id.income_currency_exchange_account_id
+
+ if exchange_diff_balance > 0.0:
+ account = expense_exchange_account
+ else:
+ account = income_exchange_account
+ return account, exchange_diff_balance
+
+ def _lines_get_exchange_diff_values(self, line):
+ if line.flag != 'new_aml':
+ return []
+ account, exchange_diff_balance = self._lines_get_account_balance_exchange_diff(line.currency_id, line.balance, line.amount_currency)
+ if line.currency_id.is_zero(exchange_diff_balance):
+ return []
+ return [{
+ 'flag': 'exchange_diff',
+ 'source_aml_id': line.source_aml_id.id,
+ 'account_id': account.id,
+ 'date': line.date,
+ 'name': _("Exchange Difference: %s", line.name),
+ 'partner_id': line.partner_id.id,
+ 'currency_id': line.currency_id.id,
+ 'amount_currency': exchange_diff_balance if line.currency_id == self.company_currency_id else 0.0,
+ 'balance': exchange_diff_balance,
+ 'source_amount_currency': line.amount_currency,
+ 'source_balance': exchange_diff_balance,
+ }]
+
+ def _lines_recompute_exchange_diff(self, lines):
+ """ Recompute the exchange_diffs for the given lines, creating some if necessary.
+ If lines are not given, the method will be applied on all new_amls
+ """
+ self.ensure_one()
+ # If the method is called after deleting lines we should delete the related exchange diffs
+ deleted_lines = lines - self.line_ids
+ self._remove_related_exchange_diff_lines(deleted_lines)
+ lines = lines - deleted_lines
+
+ exchange_diffs_aml = self.line_ids.filtered(lambda x: x.flag == 'exchange_diff').grouped('source_aml_id')
+ line_ids_commands = []
+ reorder_needed = False
+
+ for line in lines:
+ exchange_diff_values = self._lines_get_exchange_diff_values(line)
+ if line.source_aml_id and line.source_aml_id in exchange_diffs_aml:
+ line_ids_commands += [
+ Command.update(exchange_diffs_aml[line.source_aml_id].id, exch_diff_val)
+ for exch_diff_val in exchange_diff_values
+ ]
+ else:
+ line_ids_commands += [
+ Command.create(exch_diff_val)
+ for exch_diff_val in exchange_diff_values
+ ]
+ reorder_needed = True
+
+ if line_ids_commands:
+ self.line_ids = line_ids_commands
+ if reorder_needed:
+ self._reorder_exchange_and_aml_lines()
+
+ def _lines_prepare_reco_model_write_off_vals(self, reco_model, write_off_vals):
+ self.ensure_one()
+
+ balance = self.st_line_id\
+ ._prepare_counterpart_amounts_using_st_line_rate(self.transaction_currency_id, None, write_off_vals['amount_currency'])['balance']
+
+ return {
+ 'flag': 'manual',
+
+ 'account_id': write_off_vals['account_id'],
+ 'date': self.st_line_id.date,
+ 'name': write_off_vals['name'],
+ 'partner_id': write_off_vals['partner_id'],
+ 'currency_id': write_off_vals['currency_id'],
+ 'amount_currency': write_off_vals['amount_currency'],
+ 'balance': balance,
+ 'tax_base_amount_currency': write_off_vals['amount_currency'],
+ 'force_price_included_taxes': True,
+
+ 'reconcile_model_id': reco_model.id,
+ 'analytic_distribution': write_off_vals['analytic_distribution'],
+ 'tax_ids': write_off_vals['tax_ids'],
+ }
+
+ # -------------------------------------------------------------------------
+ # LINES UPDATE METHODS
+ # -------------------------------------------------------------------------
+
+ def _line_value_changed_account_id(self, line):
+ self.ensure_one()
+ self._lines_turn_auto_balance_into_manual_line(line)
+
+ # Recompute taxes.
+ if line.flag not in ('tax_line', 'early_payment') and line.tax_ids:
+ self._lines_recompute_taxes()
+ self._lines_add_auto_balance_line()
+
+ def _line_value_changed_date(self, line):
+ self.ensure_one()
+ if line.flag == 'liquidity' and line.date:
+ self.st_line_id.date = line.date
+ self._action_reload_liquidity_line()
+ self.return_todo_command = {'reset_global_info': True, 'reset_record': True}
+
+ def _line_value_changed_ref(self, line):
+ self.ensure_one()
+ if line.flag == 'liquidity':
+ self.st_line_id.move_id.ref = line.ref
+ self._action_reload_liquidity_line()
+ self.return_todo_command = {'reset_record': True}
+
+ def _line_value_changed_narration(self, line):
+ self.ensure_one()
+ if line.flag == 'liquidity':
+ self.st_line_id.move_id.narration = line.narration
+ self._action_reload_liquidity_line()
+ self.return_todo_command = {'reset_record': True}
+
+ def _line_value_changed_name(self, line):
+ self.ensure_one()
+ if line.flag == 'liquidity':
+ self.st_line_id.payment_ref = line.name
+ self._action_reload_liquidity_line()
+ self.return_todo_command = {'reset_global_info': True, 'reset_record': True}
+ return
+
+ self._lines_turn_auto_balance_into_manual_line(line)
+
+ def _line_value_changed_amount_transaction_currency(self, line):
+ self.ensure_one()
+ if line.flag == 'liquidity':
+ if line.transaction_currency_id != self.journal_currency_id:
+ self.st_line_id.amount_currency = line.amount_transaction_currency
+ self.st_line_id.foreign_currency_id = line.transaction_currency_id
+ else:
+ self.st_line_id.amount_currency = 0.0
+ self.st_line_id.foreign_currency_id = None
+ self._action_reload_liquidity_line()
+ self.return_todo_command = {'reset_global_info': True, 'reset_record': True}
+
+ def _line_value_changed_transaction_currency_id(self, line):
+ self._line_value_changed_amount_transaction_currency(line)
+
+ def _line_value_changed_amount_currency(self, line):
+ self.ensure_one()
+ if line.flag == 'liquidity':
+ self.st_line_id.amount = line.amount_currency
+ self._action_reload_liquidity_line()
+ self.return_todo_command = {'reset_global_info': True, 'reset_record': True}
+ return
+
+ self._lines_turn_auto_balance_into_manual_line(line)
+
+ sign = -1 if line.amount_currency < 0.0 else 1
+ if line.flag == 'new_aml':
+ # The balance must keep the same sign as the original aml and must not exceed its original value.
+ line.amount_currency = sign * max(0.0, min(abs(line.amount_currency), abs(line.source_amount_currency)))
+ line.manually_modified = True
+
+ # If the user remove completely the value, reset to the original balance.
+ if not line.amount_currency:
+ line.amount_currency = line.source_amount_currency
+
+ elif not line.amount_currency:
+ line.amount_currency = 0.0
+
+ if line.currency_id == line.company_currency_id:
+ # Single currency: amount_currency must be equal to balance.
+ line.balance = line.amount_currency
+ elif line.flag == 'new_aml':
+ if line.currency_id.compare_amounts(abs(line.amount_currency), abs(line.source_amount_currency)) == 0.0:
+ # The value has been reset to its original value. Reset the balance as well to avoid rounding issues.
+ line.balance = line.source_balance
+ else:
+ # Apply the rate.
+ if line.source_rate:
+ line.balance = line.company_currency_id.round(line.amount_currency / line.source_rate)
+ else:
+ line.balance = 0.0
+ elif line.flag in ('manual', 'early_payment', 'tax_line'):
+ if line.currency_id in (self.transaction_currency_id, self.journal_currency_id):
+ line.balance = self.st_line_id\
+ ._prepare_counterpart_amounts_using_st_line_rate(line.currency_id, None, line.amount_currency)['balance']
+ else:
+ line.balance = line.currency_id\
+ ._convert(line.amount_currency, self.company_currency_id, self.company_id, self.st_line_id.date)
+
+ if line.flag not in ('tax_line', 'early_payment'):
+ if line.tax_ids:
+ # Manual edition of amounts. Disable the price_included mode.
+ line.force_price_included_taxes = False
+ self._lines_recompute_taxes()
+ self._lines_recompute_exchange_diff(line)
+
+ self._lines_add_auto_balance_line()
+
+ def _line_value_changed_balance(self, line):
+ self.ensure_one()
+ if line.flag == 'liquidity':
+ self.st_line_id.amount = line.balance
+ self._action_reload_liquidity_line()
+ self.return_todo_command = {'reset_global_info': True, 'reset_record': True}
+ return
+
+ self._lines_turn_auto_balance_into_manual_line(line)
+
+ sign = -1 if line.balance < 0.0 else 1
+ if line.flag == 'new_aml':
+ # The balance must keep the same sign as the original aml and must not exceed its original value.
+ line.balance = sign * max(0.0, min(abs(line.balance), abs(line.source_balance)))
+ line.manually_modified = True
+
+ # If the user remove completely the value, reset to the original balance.
+ if not line.balance:
+ line.balance = line.source_balance
+
+ elif not line.balance:
+ line.balance = 0.0
+
+ # Single currency: amount_currency must be equal to balance.
+ if line.currency_id == line.company_currency_id:
+ line.amount_currency = line.balance
+ self._line_value_changed_amount_currency(line)
+ elif line.flag == 'exchange_diff':
+ self._lines_add_auto_balance_line()
+ else:
+ self._lines_recompute_exchange_diff(line)
+ self._lines_add_auto_balance_line()
+
+ def _line_value_changed_currency_id(self, line):
+ self.ensure_one()
+ self._line_value_changed_amount_currency(line)
+
+ def _line_value_changed_tax_ids(self, line):
+ self.ensure_one()
+ self._lines_turn_auto_balance_into_manual_line(line)
+
+ if line.tax_ids:
+ # Adding taxes but no tax before.
+ if not line.tax_base_amount_currency:
+ line.tax_base_amount_currency = line.amount_currency
+ line.force_price_included_taxes = True
+ else:
+ if line.force_price_included_taxes:
+ # Removing taxes letting the field empty.
+ # If the user didn't touch the amount_currency/balance, restore the original amount.
+ line.amount_currency = line.tax_base_amount_currency
+ self._line_value_changed_amount_currency(line)
+ line.tax_base_amount_currency = False
+
+ self._lines_recompute_taxes()
+ self._lines_add_auto_balance_line()
+
+ def _line_value_changed_partner_id(self, line):
+ self.ensure_one()
+ if line.flag == 'liquidity':
+ self.st_line_id.partner_id = line.partner_id
+ self._action_reload_liquidity_line()
+ self.return_todo_command = {'reset_global_info': True, 'reset_record': True}
+ return
+
+ self._lines_turn_auto_balance_into_manual_line(line)
+
+ new_account = None
+ if line.partner_id:
+ partner_is_customer = line.partner_id.customer_rank and not line.partner_id.supplier_rank
+ partner_is_supplier = line.partner_id.supplier_rank and not line.partner_id.customer_rank
+ is_partner_receivable_amount_zero = line.partner_currency_id.is_zero(line.partner_receivable_amount)
+ is_partner_payable_amount_zero = line.partner_currency_id.is_zero(line.partner_payable_amount)
+ if partner_is_customer or not is_partner_receivable_amount_zero and is_partner_payable_amount_zero:
+ new_account = line.partner_receivable_account_id
+ elif partner_is_supplier or is_partner_receivable_amount_zero and not is_partner_payable_amount_zero:
+ new_account = line.partner_payable_account_id
+ elif self.st_line_id.amount < 0.0:
+ new_account = line.partner_payable_account_id or line.partner_receivable_account_id
+ else:
+ new_account = line.partner_receivable_account_id or line.partner_payable_account_id
+
+ if new_account:
+ # Set the new receivable/payable account if any.
+ line.account_id = new_account
+ self._line_value_changed_account_id(line)
+ elif line.flag not in ('tax_line', 'early_payment') and line.tax_ids:
+ # Recompute taxes.
+ self._lines_recompute_taxes()
+ self._lines_add_auto_balance_line()
+
+ def _line_value_changed_analytic_distribution(self, line):
+ self.ensure_one()
+ self._lines_turn_auto_balance_into_manual_line(line)
+
+ if line.flag == 'liquidity':
+ st_line = self.st_line_id
+ liquidity_line, _suspense_lines, _write_off_lines = self.st_line_id._seek_for_lines()
+ liquidity_line.analytic_distribution = line.analytic_distribution
+ # We need to keep track of the statement line to avoid losing the data.
+ # Will be improved in master by turning _action_reload_liquidity_line into a context manager.
+ self.with_context(default_st_line_id=st_line.id)._action_reload_liquidity_line()
+ return
+
+ # Recompute taxes.
+ if line.flag not in ('tax_line', 'early_payment') and any(x.analytic for x in line.tax_ids):
+ self._lines_recompute_taxes()
+ self._lines_add_auto_balance_line()
+
+ # -------------------------------------------------------------------------
+ # ACTIONS
+ # -------------------------------------------------------------------------
+
+ def _action_trigger_matching_rules(self):
+ self.ensure_one()
+
+ if self.st_line_id.is_reconciled:
+ return
+
+ reconcile_models = self.env['account.reconcile.model'].search([
+ ('rule_type', '!=', 'writeoff_button'),
+ ('company_id', '=', self.company_id.id),
+ '|',
+ ('match_journal_ids', '=', False),
+ ('match_journal_ids', '=', self.st_line_id.journal_id.id),
+ ])
+ matching = reconcile_models._apply_rules(self.st_line_id, self.partner_id)
+
+ if matching.get('amls'):
+ reco_model = matching['model']
+ # In case there is a write-off, keep the whole amount and let the write-off doing the auto-balancing.
+ allow_partial = matching.get('status') != 'write_off'
+ self._action_add_new_amls(matching['amls'], reco_model=reco_model, allow_partial=allow_partial)
+ if matching.get('status') == 'write_off':
+ reco_model = matching['model']
+ self._action_select_reconcile_model(reco_model)
+ if matching.get('auto_reconcile'):
+ self.matching_rules_allow_auto_reconcile = True
+ return matching
+
+ def _prepare_embedded_views_data(self):
+ self.ensure_one()
+ st_line = self.st_line_id
+
+ context = {
+ 'search_view_ref': 'odex30_account_accountant.view_account_move_line_search_bank_rec_widget',
+ 'list_view_ref': 'odex30_account_accountant.view_account_move_line_list_bank_rec_widget',
+ }
+
+ if self.partner_id:
+ context['search_default_partner_id'] = self.partner_id.id
+
+ dynamic_filters = []
+
+ # == Dynamic Customer/Vendor filter ==
+ journal = st_line.journal_id
+
+ account_ids = set()
+
+ inbound_accounts = journal._get_journal_inbound_outstanding_payment_accounts() - journal.default_account_id
+ outbound_accounts = journal._get_journal_outbound_outstanding_payment_accounts() - journal.default_account_id
+
+ # Matching on debit account.
+ for account in inbound_accounts:
+ account_ids.add(account.id)
+
+ # Matching on credit account.
+ for account in outbound_accounts:
+ account_ids.add(account.id)
+
+ rec_pay_matching_filter = {
+ 'name': 'receivable_payable_matching',
+ 'description': _("Customer/Vendor"),
+ 'domain': [
+ '|',
+ # Matching invoices.
+ '&',
+ ('account_id.account_type', 'in', ('asset_receivable', 'liability_payable')),
+ ('payment_id', '=', False),
+ # Matching Payments.
+ '&',
+ ('account_id', 'in', tuple(account_ids)),
+ ('payment_id', '!=', False),
+ ],
+ 'no_separator': True,
+ 'is_default': False,
+ }
+
+ misc_matching_filter = {
+ 'name': 'misc_matching',
+ 'description': _("Misc"),
+ 'domain': ['!'] + rec_pay_matching_filter['domain'],
+ 'is_default': False,
+ }
+
+ dynamic_filters.append(rec_pay_matching_filter)
+ dynamic_filters.append(misc_matching_filter)
+
+ # Stringify the domain.
+ for dynamic_filter in dynamic_filters:
+ dynamic_filter['domain'] = str(dynamic_filter['domain'])
+
+ return {
+ 'amls': {
+ 'domain': st_line._get_default_amls_matching_domain(),
+ 'dynamic_filters': dynamic_filters,
+ 'context': context,
+ },
+ }
+
+ def _action_mount_st_line(self, st_line):
+ self.ensure_one()
+ self.st_line_id = st_line
+ self.form_index = self.line_ids[0].index if self.state == 'reconciled' else None
+ self._action_trigger_matching_rules()
+
+ def _js_action_mount_st_line(self, st_line_id):
+ self.ensure_one()
+ st_line = self.env['account.bank.statement.line'].browse(st_line_id)
+ self._action_mount_st_line(st_line)
+ self.return_todo_command = self._prepare_embedded_views_data()
+
+ def _js_action_restore_st_line_data(self, initial_data):
+ self.ensure_one()
+ initial_values = initial_data['initial_values']
+
+ self.st_line_id = self.env['account.bank.statement.line'].browse(initial_values['st_line_id'])
+ return_todo_command = initial_values['return_todo_command']
+
+ # Skip restore and trigger matching rules if the liquidity line was modified
+ liquidity_line = self.line_ids.filtered(lambda l: l.flag == 'liquidity')
+ initial_liquidity_line_values = next((cmd[2] for cmd in initial_values['line_ids'] if cmd[2]['flag'] == 'liquidity'), {})
+ initial_liquidity_line = self.env['bank.rec.widget.line'].new(initial_liquidity_line_values)
+ for field in initial_liquidity_line_values.keys() - ['index', 'suggestion_html']:
+ if initial_liquidity_line[field] != liquidity_line[field]:
+ self._js_action_mount_st_line(self.st_line_id.id)
+ return
+
+ # If the user goes to reco model and create a new one, we want to make it appearing when coming back.
+ # That's why we pop 'available_reco_model_ids' as well.
+ for field_name in ('id', 'st_line_id', 'todo_command', 'return_todo_command', 'available_reco_model_ids'):
+ initial_values.pop(field_name, None)
+
+ st_line_domain = self.st_line_id._get_default_amls_matching_domain()
+ initial_values['line_ids'] = self._process_restore_lines_ids(initial_values['line_ids'])
+ self.update(initial_values)
+
+ if (
+ return_todo_command
+ and return_todo_command.get('res_model') == 'account.move'
+ and (created_invoice := self.env['account.move'].browse(return_todo_command['res_id']))
+ and created_invoice.state == 'posted'
+ ):
+ lines = created_invoice.line_ids.filtered_domain(st_line_domain)
+ self._action_add_new_amls(lines)
+ else:
+ self._lines_add_auto_balance_line()
+
+ self.return_todo_command = self._prepare_embedded_views_data()
+
+ def _process_restore_lines_ids(self, initial_commands):
+ st_line_domain = self.st_line_id._get_default_amls_matching_domain()
+ still_available_aml_ids = self.env['account.move.line'].browse(
+ orm_command[2]['source_aml_id']
+ for orm_command in initial_commands
+ if orm_command[0] == Command.CREATE and orm_command[2].get('source_aml_id')
+ ).filtered_domain(st_line_domain).ids
+ still_available_aml_ids += [None] # still available if there was no source
+ line_ids_commands = [Command.clear()]
+ for orm_command in initial_commands:
+ match orm_command:
+ case (Command.CREATE, _, values) if values.get('source_aml_id' in still_available_aml_ids):
+ # Discard the virtual id coming from the client
+ line_ids_commands.append(Command.create(values))
+ case _:
+ line_ids_commands.append(orm_command)
+ return line_ids_commands
+
+ def _action_reload_liquidity_line(self):
+ self.ensure_one()
+ self = self.with_context(default_st_line_id=self.st_line_id.id)
+
+ self.invalidate_model()
+
+ # Ensure the lines are well loaded.
+ # Suppose the initial values of 'line_ids' are 2 lines,
+ # "self.line_ids = [Command.create(...)]" will produce a single new line in 'line_ids' but three lines in case
+ # the field is accessed before.
+ self.line_ids
+
+ self._action_trigger_matching_rules()
+
+ # Focus back the liquidity line.
+ self._js_action_mount_line_in_edit(self.line_ids.filtered(lambda x: x.flag == 'liquidity').index)
+
+ def _validation_lines_vals(self, line_ids_create_command_list, aml_to_exchange_diff_vals, to_reconcile):
+ # Check which partner to set.
+ lines = self.line_ids.filtered(lambda x: x.flag != 'liquidity')
+ partners = lines.partner_id
+ partner_to_set = self.env['res.partner']
+ if len(partners) == 1:
+ # To avoid "Incompatible companies on records" error, make sure the user is linked to a main company.
+ allowed_companies = partners.company_id.root_id
+ if len(lines.company_id) == 1:
+ # Or the user is linked to the aml's company.
+ allowed_companies |= lines.company_id
+ # Or the user is not linked to any company.
+ if not partners.company_id or partners.company_id in allowed_companies:
+ partner_to_set = partners
+
+ source2exchange = self.line_ids.filtered(lambda l: l.flag == 'exchange_diff').grouped('source_aml_id')
+ for line in self.line_ids:
+ if line.flag == 'exchange_diff':
+ continue
+
+ amount_currency = line.amount_currency
+ balance = line.balance
+ if line.flag == 'new_aml':
+ to_reconcile.append((len(line_ids_create_command_list) + 1, line.source_aml_id))
+ exchange_diff = source2exchange.get(line.source_aml_id)
+ if exchange_diff:
+ aml_to_exchange_diff_vals[len(line_ids_create_command_list) + 1] = {
+ 'amount_residual': exchange_diff.balance,
+ 'amount_residual_currency': exchange_diff.amount_currency,
+ 'analytic_distribution': exchange_diff.analytic_distribution,
+ }
+ # Squash amounts of exchange diff into corresponding new_aml
+ amount_currency += exchange_diff.amount_currency
+ balance += exchange_diff.balance
+ line_ids_create_command_list.append(Command.create(line._get_aml_values(
+ sequence=len(line_ids_create_command_list) + 1,
+ partner_id=partner_to_set.id if line.flag in ('liquidity', 'auto_balance') else line.partner_id.id,
+ amount_currency=amount_currency,
+ balance=balance,
+ )))
+
+ def _action_validate(self):
+ self.ensure_one()
+ # Prepare the lines to be created.
+ to_reconcile = []
+ line_ids_create_command_list = []
+ aml_to_exchange_diff_vals = {}
+
+ self._validation_lines_vals(line_ids_create_command_list, aml_to_exchange_diff_vals, to_reconcile)
+
+ st_line = self.st_line_id
+ move = st_line.move_id
+
+ # Update the move.
+ move_ctx = move.with_context(
+ force_delete=True,
+ skip_readonly_check=True,
+ )
+ move_ctx.write({'line_ids': [Command.clear()] + line_ids_create_command_list})
+
+ AccountMoveLine = self.env['account.move.line']
+ sequence2lines = move_ctx.line_ids.grouped('sequence')
+ lines = [
+ (sequence2lines[index], counterpart_aml)
+ for index, counterpart_aml in to_reconcile
+ ]
+ all_line_ids = tuple({_id for line, counterpart in lines for _id in (line + counterpart).ids})
+ # Handle exchange diffs
+ exchange_diff_moves = None
+ lines_with_exch_diff = AccountMoveLine
+ if aml_to_exchange_diff_vals:
+ exchange_diff_vals_list = []
+ for line, counterpart in lines:
+ line = line.with_prefetch(all_line_ids)
+ counterpart = counterpart.with_prefetch(all_line_ids)
+ exchange_diff_amounts = aml_to_exchange_diff_vals.get(line.sequence, {})
+ exchange_analytic_distribution = exchange_diff_amounts.pop('analytic_distribution', False)
+ if exchange_diff_amounts:
+ related_exchange_diff_amls = line if exchange_diff_amounts['amount_residual'] * line.amount_residual > 0 else counterpart
+ exchange_diff_vals_list.append(related_exchange_diff_amls._prepare_exchange_difference_move_vals(
+ [exchange_diff_amounts],
+ exchange_date=max(line.date, counterpart.date),
+ exchange_analytic_distribution=exchange_analytic_distribution,
+ ))
+ lines_with_exch_diff += line
+ exchange_diff_moves = AccountMoveLine._create_exchange_difference_moves(exchange_diff_vals_list)
+
+ # Perform the reconciliation.
+ self.env['account.move.line']\
+ .with_context(no_exchange_difference_no_recursive=True)._reconcile_plan([
+ (line + counterpart).with_prefetch(all_line_ids)
+ for line, counterpart in lines
+ ])
+
+ # Assign exchange move to partials.
+ for index, line in enumerate(lines_with_exch_diff):
+ exchange_move = exchange_diff_moves[index]
+ for debit_credit in ('debit', 'credit'):
+ partials = line[f'matched_{debit_credit}_ids'] \
+ .filtered(lambda partial: partial[f'{debit_credit}_move_id'].move_id != exchange_move)
+ partials.exchange_move_id = exchange_move
+
+ # Fill missing partner.
+ st_line_ctx = st_line.with_context(skip_account_move_synchronization=True, skip_readonly_check=True)
+
+ # Create missing partner bank if necessary.
+ if st_line.account_number and st_line.partner_id:
+ st_line_ctx.partner_bank_id = st_line._find_or_create_bank_account() or st_line.partner_bank_id
+
+ # Refresh analytic lines.
+ move.line_ids.with_context(validate_analytic=True)._inverse_analytic_distribution()
+
+ @contextmanager
+ def _action_validate_method(self):
+ self.ensure_one()
+ st_line = self.st_line_id
+
+ yield
+
+ # The current record has been invalidated. Reload it completely.
+ self.st_line_id = st_line
+ self._ensure_loaded_lines()
+ self.return_todo_command = {'done': True}
+
+ def _js_action_validate(self):
+ with self._action_validate_method():
+ self._action_validate()
+
+ def _action_to_check(self):
+ self.st_line_id.move_id.checked = False
+ self.invalidate_recordset(fnames=['st_line_checked'])
+ self._action_validate()
+
+ def _js_action_to_check(self):
+ self.ensure_one()
+
+ if self.state == 'valid':
+ # The validation can be performed.
+ with self._action_validate_method():
+ self._action_to_check()
+ else:
+ # No need any validation.
+ self.st_line_id.move_id.checked = False
+ self.invalidate_recordset(fnames=['st_line_checked'])
+ self.return_todo_command = {'done': True}
+
+ def _js_action_reset(self):
+ self.ensure_one()
+ st_line = self.st_line_id
+
+ # Hashed entries shouldn't be modified; we will provide clear errors as well as redirect the user if needed.
+ if st_line.inalterable_hash:
+ if not st_line.has_reconciled_entries:
+ raise UserError(_("You can't hit the reset button on a secured bank transaction."))
+ else:
+ raise RedirectWarning(
+ message=_("This bank transaction is locked up tighter than a squirrel in a nut factory! You can't hit the reset button on it. So, do you want to \"unreconcile\" it instead?"),
+ action=st_line.move_id.open_reconcile_view(),
+ button_text=_('View Reconciled Entries'),
+ )
+
+ st_line.action_undo_reconciliation()
+
+ # The current record has been invalidated. Reload it completely.
+ self.st_line_id = st_line
+ self._ensure_loaded_lines()
+ self._action_trigger_matching_rules()
+ self.return_todo_command = {'done': True}
+
+ def _js_action_set_as_checked(self):
+ self.ensure_one()
+ self.st_line_id.move_id.checked = True
+ self.invalidate_recordset(fnames=['st_line_checked'])
+ self.return_todo_command = {'done': True}
+
+ def _action_clear_manual_operations_form(self):
+ self.form_index = None
+
+ def _action_remove_lines(self, lines):
+ self.ensure_one()
+ if not lines:
+ return
+
+ is_taxes_recomputation_needed = bool(lines.tax_ids)
+ has_new_aml = any(line.flag == 'new_aml' for line in lines)
+
+ # Update 'line_ids'.
+ self.line_ids = [
+ Command.unlink(line.id)
+ for line in lines
+ ]
+ self._remove_related_exchange_diff_lines(lines)
+
+ # Recompute taxes and auto balance the lines.
+ if is_taxes_recomputation_needed:
+ self._lines_recompute_taxes()
+ if has_new_aml and not self._lines_check_apply_early_payment_discount():
+ self._lines_check_apply_partial_matching()
+ self._lines_add_auto_balance_line()
+ self._action_clear_manual_operations_form()
+
+ def _js_action_remove_line(self, line_index):
+ self.ensure_one()
+ line = self.line_ids.filtered(lambda x: x.index == line_index)
+ self._action_remove_lines(line)
+
+ def _action_select_reconcile_model(self, reco_model):
+ self.ensure_one()
+
+ # Cleanup a previously selected model.
+ self.line_ids = [
+ Command.unlink(x.id)
+ for x in self.line_ids
+ if x.flag not in ('new_aml', 'liquidity') and x.reconcile_model_id and x.reconcile_model_id != reco_model
+ ]
+ self._lines_recompute_taxes()
+
+ if reco_model.to_check:
+ self.st_line_id.move_id.checked = False
+ self.invalidate_recordset(fnames=['st_line_checked'])
+
+ # Compute the residual balance on which apply the newly selected model.
+ auto_balance_line_vals = self._lines_prepare_auto_balance_line()
+ residual_balance = auto_balance_line_vals['amount_currency']
+
+ write_off_vals_list = reco_model._apply_lines_for_bank_widget(residual_balance, self.partner_id, self.st_line_id)
+
+ if reco_model.rule_type == 'writeoff_button' and reco_model.counterpart_type in ('sale', 'purchase'):
+ invoice = self._create_invoice_from_write_off_values(reco_model, write_off_vals_list)
+
+ action = {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.move',
+ 'context': {'create': False},
+ 'view_mode': 'form',
+ 'res_id': invoice.id,
+ }
+ self.return_todo_command = clean_action(action, self.env)
+ else:
+ # Apply the newly generated lines.
+ self.line_ids = [
+ Command.create(self._lines_prepare_reco_model_write_off_vals(reco_model, x))
+ for x in write_off_vals_list
+ ]
+
+ self._lines_recompute_taxes()
+ self._lines_add_auto_balance_line()
+
+ def _js_action_select_reconcile_model(self, reco_model_id):
+ self.ensure_one()
+ reco_model = self.env['account.reconcile.model'].browse(reco_model_id)
+ self._action_select_reconcile_model(reco_model)
+
+ def _create_invoice_from_write_off_values(self, reco_model, write_off_vals_list):
+ # Create a new invoice/bill and redirect the user to it.
+ journal = reco_model.line_ids.journal_id[:1]
+
+ invoice_line_ids = []
+ total_amount_currency = 0.0
+ percentage_st_line = 0.0
+ for write_off_values in write_off_vals_list:
+ write_off_values = dict(write_off_values)
+ total_amount_currency -= (
+ write_off_values['amount_currency']
+ if 'percentage_st_line' not in write_off_values
+ else 0
+ )
+ percentage_st_line += write_off_values.pop('percentage_st_line', 0)
+ write_off_values.pop('currency_id', None)
+ write_off_values.pop('partner_id', None)
+ write_off_values.pop('reconcile_model_id', None)
+ invoice_line_ids.append(write_off_values)
+
+ st_line_amount = self.st_line_id.amount_currency if self.st_line_id.foreign_currency_id else self.st_line_id.amount
+ total_amount_currency += self.transaction_currency_id.round(st_line_amount * percentage_st_line)
+
+ # Type of move depends on debit or credit of bank statement line and reconciliation model chosen.
+ if reco_model.counterpart_type == 'sale':
+ move_type = 'out_invoice' if total_amount_currency > 0 else 'out_refund'
+ else:
+ move_type = 'in_invoice' if total_amount_currency < 0 else 'in_refund'
+
+ price_unit_sign = 1 if total_amount_currency < 0.0 else -1
+ invoice_line_ids_commands = []
+ for line_values in invoice_line_ids:
+ price_total = price_unit_sign * line_values.pop('amount_currency')
+ taxes = self.env['account.tax'].browse(line_values['tax_ids'][0][2])
+ line_values['price_unit'] = self._get_invoice_price_unit_from_price_total(price_total, taxes)
+ invoice_line_ids_commands.append(Command.create(line_values))
+
+ invoice_values = {
+ 'invoice_date': self.st_line_id.date,
+ 'move_type': move_type,
+ 'partner_id': self.st_line_id.partner_id.id,
+ 'currency_id': self.transaction_currency_id.id,
+ 'payment_reference': self.st_line_id.payment_ref,
+ 'invoice_line_ids': invoice_line_ids_commands,
+ }
+ if journal:
+ invoice_values['journal_id'] = journal.id
+
+ invoice = self.env['account.move'].create(invoice_values)
+ if not invoice.currency_id.is_zero(invoice.amount_total - total_amount_currency):
+ invoice._check_total_amount(abs(total_amount_currency))
+ return invoice
+
+ def _get_invoice_price_unit_from_price_total(self, price_total, taxes):
+ """ Determine price unit based on the total amount and taxes applied. """
+ self.ensure_one()
+ taxes_computation = taxes._get_tax_details(
+ price_total,
+ 1.0,
+ precision_rounding=self.transaction_currency_id.rounding,
+ rounding_method=self.company_id.tax_calculation_rounding_method,
+ special_mode='total_included',
+ )
+ return taxes_computation['total_excluded'] + sum(x['tax_amount'] for x in taxes_computation['taxes_data'] if x['tax'].price_include)
+
+ def _action_add_new_amls(self, amls, reco_model=None, allow_partial=True):
+ self.ensure_one()
+ existing_amls = set(self.line_ids.filtered(lambda x: x.flag in ('new_aml', 'aml', 'liquidity')).source_aml_id)
+ amls = amls.filtered(lambda x: x not in existing_amls)
+ if not amls:
+ return
+
+ self._lines_load_new_amls(amls, reco_model=reco_model)
+ added_lines = self.line_ids.filtered(lambda x: x.flag == 'new_aml' and x.source_aml_id in amls)
+ self._lines_recompute_exchange_diff(added_lines)
+ if not self._lines_check_apply_early_payment_discount() and allow_partial:
+ self._lines_check_apply_partial_matching()
+ self._lines_add_auto_balance_line()
+ self._action_clear_manual_operations_form()
+
+ def _js_action_add_new_aml(self, aml_id):
+ self.ensure_one()
+ aml = self.env['account.move.line'].browse(aml_id)
+ self._action_add_new_amls(aml)
+
+ def _action_remove_new_amls(self, amls):
+ self.ensure_one()
+ to_remove = self.line_ids.filtered(lambda x: x.flag == 'new_aml' and x.source_aml_id in amls)
+ self._action_remove_lines(to_remove)
+
+ def _js_action_remove_new_aml(self, aml_id):
+ self.ensure_one()
+ aml = self.env['account.move.line'].browse(aml_id)
+ self._action_remove_new_amls(aml)
+
+ def _js_action_mount_line_in_edit(self, line_index):
+ self.ensure_one()
+ self.form_index = line_index
+
+ def _js_action_line_changed(self, form_index, field_name):
+ self.ensure_one()
+ line = self.line_ids.filtered(lambda x: x.index == form_index)
+
+ # Invalidate the cache of newly set value to force the recomputation of computed fields.
+ value = line[field_name]
+ line.invalidate_recordset(fnames=[field_name], flush=False)
+ line[field_name] = value
+
+ getattr(self, f'_line_value_changed_{field_name}')(line)
+
+ def _js_action_line_set_partner_receivable_account(self, form_index):
+ self.ensure_one()
+ line = self.line_ids.filtered(lambda x: x.index == form_index)
+ line.account_id = line.partner_receivable_account_id
+ self._line_value_changed_account_id(line)
+
+ def _js_action_line_set_partner_payable_account(self, form_index):
+ self.ensure_one()
+ line = self.line_ids.filtered(lambda x: x.index == form_index)
+ line.account_id = line.partner_payable_account_id
+ self._line_value_changed_account_id(line)
+
+ def _js_action_redirect_to_move(self, form_index):
+ self.ensure_one()
+ line = self.line_ids.filtered(lambda x: x.index == form_index)
+ move = line.source_aml_move_id
+
+ action = {
+ 'type': 'ir.actions.act_window',
+ 'context': {'create': False},
+ 'view_mode': 'form',
+ }
+
+ if move.origin_payment_id:
+ action.update({
+ 'res_model': 'account.payment',
+ 'res_id': move.origin_payment_id.id,
+ })
+ else:
+ action.update({
+ 'res_model': 'account.move',
+ 'res_id': move.id,
+ })
+ self.return_todo_command = clean_action(action, self.env)
+
+ def _js_action_apply_line_suggestion(self, form_index):
+ self.ensure_one()
+ line = self.line_ids.filtered(lambda x: x.index == form_index)
+
+ # Since 'balance'/'amount_currency' are both dependencies of 'suggestion_balance'/'suggestion_amount_currency',
+ # keep the value in variable before assigning anything to avoid an inconsistency after applying
+ # 'suggestion_amount_currency' but before updating 'balance'.
+ suggestion_amount_currency = line.suggestion_amount_currency
+ suggestion_balance = line.suggestion_balance
+
+ line.amount_currency = suggestion_amount_currency
+ line.balance = suggestion_balance
+
+ if line.currency_id == line.company_currency_id:
+ self._line_value_changed_balance(line)
+ else:
+ self._line_value_changed_amount_currency(line)
+
+ @api.model
+ def collect_global_info_data(self, journal_id):
+ journal = self.env['account.journal'].browse(journal_id)
+ balance = ''
+ if journal.exists() and any(company in journal.company_id._accessible_branches() for company in self.env.companies):
+ balance = formatLang(self.env,
+ journal.current_statement_balance,
+ currency_obj=journal.currency_id or journal.company_id.sudo().currency_id)
+ return {'balance_amount': balance}
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/bank_rec_widget_line.py b/dev_odex30_accounting/odex30_account_accountant/models/bank_rec_widget_line.py
new file mode 100644
index 0000000..98f2e9e
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/models/bank_rec_widget_line.py
@@ -0,0 +1,503 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo import _, api, fields, models, Command
+from odoo.osv import expression
+from odoo.tools.misc import formatLang, frozendict
+
+import markupsafe
+import uuid
+
+
+class BankRecWidgetLine(models.Model):
+ _name = "bank.rec.widget.line"
+ _inherit = "analytic.mixin"
+ _description = "Line of the bank reconciliation widget"
+
+ # This model is never saved inside the database.
+ # _auto=False' & _table_query = "0" prevent the ORM to create the corresponding postgresql table.
+ _auto = False
+ _table_query = "0"
+
+ wizard_id = fields.Many2one(comodel_name='bank.rec.widget')
+ index = fields.Char(compute='_compute_index')
+ flag = fields.Selection(
+ selection=[
+ ('liquidity', 'liquidity'),
+ ('new_aml', 'new_aml'),
+ ('aml', 'aml'),
+ ('exchange_diff', 'exchange_diff'),
+ ('tax_line', 'tax_line'),
+ ('manual', 'manual'),
+ ('early_payment', 'early_payment'),
+ ('auto_balance', 'auto_balance'),
+ ],
+ )
+
+ journal_default_account_id = fields.Many2one(
+ related='wizard_id.st_line_id.journal_id.default_account_id',
+ depends=['wizard_id'],
+ )
+ account_id = fields.Many2one(
+ comodel_name='account.account',
+ compute='_compute_account_id',
+ store=True,
+ readonly=False,
+ check_company=True,
+ domain="""[
+ ('deprecated', '=', False),
+ ('id', '!=', journal_default_account_id),
+ ('account_type', 'not in', ('asset_cash', 'off_balance')),
+ ]""",
+ )
+ date = fields.Date(
+ compute='_compute_date',
+ store=True,
+ readonly=False,
+ )
+ name = fields.Char(
+ compute='_compute_name',
+ store=True,
+ readonly=False,
+ )
+ partner_id = fields.Many2one(
+ comodel_name='res.partner',
+ compute='_compute_partner_id',
+ store=True,
+ readonly=False,
+ )
+ currency_id = fields.Many2one(
+ comodel_name='res.currency',
+ compute='_compute_currency_id',
+ store=True,
+ readonly=False,
+ )
+ company_id = fields.Many2one(related='wizard_id.company_id')
+ country_code = fields.Char(related='company_id.country_id.code', depends=['company_id'])
+ company_currency_id = fields.Many2one(related='wizard_id.company_currency_id')
+ amount_currency = fields.Monetary(
+ currency_field='currency_id',
+ compute='_compute_amount_currency',
+ store=True,
+ readonly=False,
+ )
+ balance = fields.Monetary(
+ currency_field='company_currency_id',
+ compute='_compute_balance',
+ store=True,
+ readonly=False,
+ )
+ transaction_currency_id = fields.Many2one(
+ related='wizard_id.st_line_id.foreign_currency_id',
+ depends=['wizard_id'],
+ )
+ amount_transaction_currency = fields.Monetary(
+ currency_field='transaction_currency_id',
+ related='wizard_id.st_line_id.amount_currency',
+ depends=['wizard_id'],
+ )
+ debit = fields.Monetary(
+ currency_field='company_currency_id',
+ compute='_compute_from_balance',
+ )
+ credit = fields.Monetary(
+ currency_field='company_currency_id',
+ compute='_compute_from_balance',
+ )
+ force_price_included_taxes = fields.Boolean()
+ tax_base_amount_currency = fields.Monetary(
+ currency_field='currency_id',
+ )
+
+ source_aml_id = fields.Many2one(comodel_name='account.move.line')
+ source_aml_move_id = fields.Many2one(
+ comodel_name='account.move',
+ compute='_compute_source_aml_fields',
+ store=True,
+ readonly=False,
+ )
+ source_aml_move_name = fields.Char(
+ compute='_compute_source_aml_fields',
+ store=True,
+ readonly=False,
+ )
+ tax_repartition_line_id = fields.Many2one(
+ comodel_name='account.tax.repartition.line',
+ compute='_compute_tax_repartition_line_id',
+ store=True,
+ readonly=False,
+ )
+ tax_ids = fields.Many2many(
+ comodel_name='account.tax',
+ compute='_compute_tax_ids',
+ store=True,
+ readonly=False,
+ check_company=True,
+ )
+ tax_tag_ids = fields.Many2many(
+ comodel_name='account.account.tag',
+ compute='_compute_tax_tag_ids',
+ store=True,
+ readonly=False,
+ )
+ group_tax_id = fields.Many2one(
+ comodel_name='account.tax',
+ compute='_compute_group_tax_id',
+ store=True,
+ readonly=False,
+ )
+ reconcile_model_id = fields.Many2one(comodel_name='account.reconcile.model')
+ source_amount_currency = fields.Monetary(currency_field='currency_id')
+ source_balance = fields.Monetary(currency_field='company_currency_id')
+ source_debit = fields.Monetary(
+ currency_field='company_currency_id',
+ compute='_compute_from_source_balance',
+ )
+ source_credit = fields.Monetary(
+ currency_field='company_currency_id',
+ compute='_compute_from_source_balance',
+ )
+ source_rate = fields.Float()
+
+ display_stroked_amount_currency = fields.Boolean(compute='_compute_display_stroked_amount_currency')
+ display_stroked_balance = fields.Boolean(compute='_compute_display_stroked_balance')
+
+ partner_currency_id = fields.Many2one(
+ comodel_name='res.currency',
+ compute='_compute_partner_info',
+ )
+ partner_receivable_account_id = fields.Many2one(
+ comodel_name='account.account',
+ compute='_compute_partner_info',
+ )
+ partner_payable_account_id = fields.Many2one(
+ comodel_name='account.account',
+ compute='_compute_partner_info',
+ )
+ partner_receivable_amount = fields.Monetary(
+ currency_field='partner_currency_id',
+ compute='_compute_partner_info',
+ )
+ partner_payable_amount = fields.Monetary(
+ currency_field='partner_currency_id',
+ compute='_compute_partner_info',
+ )
+
+ bank_account = fields.Char(
+ compute='_compute_bank_account',
+ )
+ suggestion_html = fields.Html(
+ compute='_compute_suggestion',
+ sanitize=False,
+ )
+ suggestion_amount_currency = fields.Monetary(
+ currency_field='currency_id',
+ compute='_compute_suggestion',
+ )
+ suggestion_balance = fields.Monetary(
+ currency_field='company_currency_id',
+ compute='_compute_suggestion',
+ )
+ ref = fields.Char(
+ compute='_compute_ref_narration',
+ store=True,
+ readonly=False,
+ )
+ narration = fields.Html(
+ compute='_compute_ref_narration',
+ store=True,
+ readonly=False,
+ )
+
+ manually_modified = fields.Boolean()
+
+ def _compute_index(self):
+ for line in self:
+ line.index = uuid.uuid4()
+
+ @api.depends('source_aml_id')
+ def _compute_account_id(self):
+ for line in self:
+ if line.flag in ('aml', 'new_aml', 'liquidity', 'exchange_diff'):
+ line.account_id = line.source_aml_id.account_id
+ else:
+ line.account_id = line.account_id
+
+ @api.depends('source_aml_id')
+ def _compute_date(self):
+ for line in self:
+ if line.flag in ('aml', 'new_aml', 'exchange_diff'):
+ line.date = line.source_aml_id.date
+ elif line.flag in ('liquidity', 'auto_balance', 'manual', 'early_payment', 'tax_line'):
+ line.date = line.wizard_id.st_line_id.date
+ else:
+ line.date = line.date
+
+ @api.depends('source_aml_id')
+ def _compute_name(self):
+ for line in self:
+ if line.flag in ('aml', 'new_aml', 'liquidity'):
+ # In the case the source_aml_id is from a credit note, the aml might not have a name set
+ line.name = line.source_aml_id.name or line.source_aml_move_name
+ else:
+ line.name = line.name
+
+ @api.depends('source_aml_id')
+ def _compute_partner_id(self):
+ for line in self:
+ if line.flag in ('aml', 'new_aml'):
+ line.partner_id = line.source_aml_id.partner_id
+ elif line.flag in ('liquidity', 'auto_balance', 'manual', 'early_payment', 'tax_line'):
+ line.partner_id = line.wizard_id.partner_id
+ else:
+ line.partner_id = line.partner_id
+
+ @api.depends('source_aml_id')
+ def _compute_currency_id(self):
+ for line in self:
+ if line.flag in ('aml', 'new_aml', 'liquidity', 'exchange_diff'):
+ line.currency_id = line.source_aml_id.currency_id
+ elif line.flag in ('auto_balance', 'manual', 'early_payment'):
+ line.currency_id = line.wizard_id.transaction_currency_id
+ else:
+ line.currency_id = line.currency_id
+
+ @api.depends('source_aml_id')
+ def _compute_balance(self):
+ for line in self:
+ if line.flag in ('aml', 'liquidity'):
+ line.balance = line.source_aml_id.balance
+ else:
+ line.balance = line.balance
+
+ @api.depends('source_aml_id')
+ def _compute_amount_currency(self):
+ for line in self:
+ if line.flag in ('aml', 'liquidity'):
+ line.amount_currency = line.source_aml_id.amount_currency
+ else:
+ line.amount_currency = line.amount_currency
+
+ @api.depends('balance')
+ def _compute_from_balance(self):
+ for line in self:
+ line.debit = line.balance if line.balance > 0.0 else 0.0
+ line.credit = -line.balance if line.balance < 0.0 else 0.0
+
+ @api.depends('source_balance')
+ def _compute_from_source_balance(self):
+ for line in self:
+ line.source_debit = line.source_balance if line.source_balance > 0.0 else 0.0
+ line.source_credit = -line.source_balance if line.source_balance < 0.0 else 0.0
+
+ @api.depends('source_aml_id', 'account_id', 'partner_id')
+ def _compute_analytic_distribution(self):
+ cache = {}
+ for line in self:
+ if line.flag in ('liquidity', 'aml'):
+ line.analytic_distribution = line.source_aml_id.analytic_distribution
+ elif line.flag in ('tax_line', 'early_payment'):
+ line.analytic_distribution = line.analytic_distribution
+ else:
+ arguments = frozendict({
+ "partner_id": line.partner_id.id,
+ "partner_category_id": line.partner_id.category_id.ids,
+ "account_prefix": line.account_id.code,
+ "company_id": line.company_id.id,
+ })
+ if arguments not in cache:
+ cache[arguments] = self.env['account.analytic.distribution.model']._get_distribution(arguments)
+ line.analytic_distribution = cache[arguments] or line.analytic_distribution
+
+ @api.depends('source_aml_id')
+ def _compute_tax_repartition_line_id(self):
+ for line in self:
+ if line.flag == 'aml':
+ line.tax_repartition_line_id = line.source_aml_id.tax_repartition_line_id
+ else:
+ line.tax_repartition_line_id = line.tax_repartition_line_id
+
+ @api.depends('source_aml_id')
+ def _compute_tax_ids(self):
+ for line in self:
+ if line.flag == 'aml':
+ line.tax_ids = [Command.set(line.source_aml_id.tax_ids.ids)]
+ else:
+ line.tax_ids = line.tax_ids
+
+ @api.depends('source_aml_id')
+ def _compute_tax_tag_ids(self):
+ for line in self:
+ if line.flag == 'aml':
+ line.tax_tag_ids = [Command.set(line.source_aml_id.tax_tag_ids.ids)]
+ else:
+ line.tax_tag_ids = line.tax_tag_ids
+
+ @api.depends('source_aml_id')
+ def _compute_group_tax_id(self):
+ for line in self:
+ if line.flag == 'aml':
+ line.group_tax_id = line.source_aml_id.group_tax_id
+ else:
+ line.group_tax_id = line.group_tax_id
+
+ @api.depends('currency_id', 'amount_currency', 'source_amount_currency')
+ def _compute_display_stroked_amount_currency(self):
+ for line in self:
+ line.display_stroked_amount_currency = \
+ line.flag == 'new_aml' \
+ and line.currency_id.compare_amounts(line.amount_currency, line.source_amount_currency) != 0
+
+ @api.depends('currency_id', 'balance', 'source_balance')
+ def _compute_display_stroked_balance(self):
+ for line in self:
+ line.display_stroked_balance = \
+ line.flag in ('new_aml', 'exchange_diff') \
+ and line.currency_id.compare_amounts(line.balance, line.source_balance) != 0
+
+ @api.depends('flag')
+ def _compute_source_aml_fields(self):
+ for line in self:
+ line.source_aml_move_id = None
+ line.source_aml_move_name = None
+ if line.flag in ('new_aml', 'liquidity'):
+ line.source_aml_move_id = line.source_aml_id.move_id
+ line.source_aml_move_name = line.source_aml_id.move_id.name
+ elif line.flag == 'aml':
+ partials = line.source_aml_id.matched_debit_ids + line.source_aml_id.matched_credit_ids
+ all_counterpart_lines = partials.debit_move_id + partials.credit_move_id
+ counterpart_lines = all_counterpart_lines - line.source_aml_id - partials.exchange_move_id.line_ids
+ if len(counterpart_lines) == 1:
+ line.source_aml_move_id = counterpart_lines.move_id
+ line.source_aml_move_name = counterpart_lines.move_id.name
+
+ @api.depends('wizard_id.form_index', 'partner_id')
+ def _compute_partner_info(self):
+ for line in self:
+ line.partner_receivable_amount = 0.0
+ line.partner_payable_amount = 0.0
+ line.partner_currency_id = None
+ line.partner_receivable_account_id = None
+ line.partner_payable_account_id = None
+
+ if not line.partner_id or line.index != line.wizard_id.form_index:
+ continue
+
+ line.partner_currency_id = line.company_currency_id
+ partner = line.partner_id.with_company(line.wizard_id.company_id)
+ common_domain = [('parent_state', '=', 'posted'), ('partner_id', '=', partner.id)]
+ line.partner_receivable_account_id = partner.property_account_receivable_id
+ if line.partner_receivable_account_id:
+ results = self.env['account.move.line']._read_group(
+ domain=expression.AND([common_domain, [('account_id', '=', line.partner_receivable_account_id.id)]]),
+ aggregates=['amount_residual:sum'],
+ )
+ line.partner_receivable_amount = results[0][0]
+ line.partner_payable_account_id = partner.property_account_payable_id
+ if line.partner_payable_account_id:
+ results = self.env['account.move.line']._read_group(
+ domain=expression.AND([common_domain, [('account_id', '=', line.partner_payable_account_id.id)]]),
+ aggregates=['amount_residual:sum'],
+ )
+ line.partner_payable_amount = results[0][0]
+
+ @api.depends('flag')
+ def _compute_bank_account(self):
+ for line in self:
+ bank_account = line.wizard_id.st_line_id.partner_bank_id.display_name or line.wizard_id.st_line_id.account_number
+ if line.flag == 'liquidity' and bank_account:
+ line.bank_account = bank_account
+ else:
+ line.bank_account = None
+
+ @api.depends('wizard_id.form_index', 'amount_currency', 'balance')
+ def _compute_suggestion(self):
+ for line in self:
+ line.suggestion_html = None
+ line.suggestion_amount_currency = None
+ line.suggestion_balance = None
+
+ if line.flag != 'new_aml' or line.index != line.wizard_id.form_index:
+ continue
+
+ aml = line.source_aml_id
+ wizard = line.wizard_id
+ residual_amount_before_reco = abs(aml.amount_residual_currency)
+ residual_amount_after_reco = abs(aml.amount_residual_currency + line.amount_currency)
+ reconciled_amount = residual_amount_before_reco - residual_amount_after_reco
+ is_fully_reconciled = aml.currency_id.is_zero(residual_amount_after_reco)
+ is_invoice = aml.move_id.is_invoice(include_receipts=True)
+
+ if is_fully_reconciled:
+ lines = [
+ _("The invoice %(display_name_html)s with an open amount of %(open_amount)s will be entirely paid by the transaction.")
+ if is_invoice else
+ _("%(display_name_html)s with an open amount of %(open_amount)s will be fully reconciled by the transaction.")
+ ]
+ partial_amounts = wizard._lines_check_partial_amount(line)
+ if partial_amounts:
+ lines.append(
+ _("You might want to record a %(btn_start)spartial payment%(btn_end)s.")
+ if is_invoice else
+ _("You might want to make a %(btn_start)spartial reconciliation%(btn_end)s instead.")
+ )
+ line.suggestion_amount_currency = partial_amounts['amount_currency']
+ line.suggestion_balance = partial_amounts['balance']
+ else:
+ if is_invoice:
+ lines = [
+ _("The invoice %(display_name_html)s with an open amount of %(open_amount)s will be reduced by %(amount)s."),
+ _("You might want to set the invoice as %(btn_start)sfully paid%(btn_end)s."),
+ ]
+ else:
+ lines = [
+ _("%(display_name_html)s with an open amount of %(open_amount)s will be reduced by %(amount)s."),
+ _("You might want to %(btn_start)sfully reconcile%(btn_end)s the document."),
+ ]
+ line.suggestion_amount_currency = line.source_amount_currency
+ line.suggestion_balance = line.source_balance
+
+ display_name_html = markupsafe.Markup("""
+ %(display_name)s
+ """) % {
+ 'display_name': aml.move_id.display_name,
+ }
+
+ extra_text = markupsafe.Markup(' ').join(lines) % {
+ 'amount': formatLang(self.env, reconciled_amount, currency_obj=aml.currency_id),
+ 'open_amount': formatLang(self.env, residual_amount_before_reco, currency_obj=aml.currency_id),
+ 'display_name_html': display_name_html,
+ 'btn_start': markupsafe.Markup(
+ ''),
+ 'btn_end': markupsafe.Markup(''),
+ }
+ line.suggestion_html = markupsafe.Markup("""
%s
""") % extra_text
+
+ @api.depends('flag')
+ def _compute_ref_narration(self):
+ for line in self:
+ if line.flag == 'liquidity':
+ line.ref = line.wizard_id.st_line_id.ref
+ line.narration = line.wizard_id.st_line_id.narration
+ else:
+ line.ref = line.narration = None
+
+ def _get_aml_values(self, **kwargs):
+ self.ensure_one()
+ create_dict = {
+ 'name': self.name,
+ 'account_id': self.account_id.id,
+ 'currency_id': self.currency_id.id,
+ 'amount_currency': self.amount_currency,
+ 'balance': self.debit - self.credit,
+ 'reconcile_model_id': self.reconcile_model_id.id,
+ 'analytic_distribution': self.analytic_distribution,
+ 'tax_repartition_line_id': self.tax_repartition_line_id.id,
+ 'tax_ids': [Command.set(self.tax_ids.ids)],
+ 'tax_tag_ids': [Command.set(self.tax_tag_ids.ids)],
+ 'group_tax_id': self.group_tax_id.id,
+ **kwargs,
+ }
+ if self.flag == 'early_payment':
+ create_dict['display_type'] = 'epd'
+ return create_dict
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/digest.py b/dev_odex30_accounting/odex30_account_accountant/models/digest.py
new file mode 100644
index 0000000..9be0abc
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/models/digest.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+
+from odoo import fields, models, _
+from odoo.exceptions import AccessError
+
+
+class Digest(models.Model):
+ _inherit = 'digest.digest'
+
+ kpi_account_bank_cash = fields.Boolean('Bank & Cash Moves')
+ kpi_account_bank_cash_value = fields.Monetary(compute='_compute_kpi_account_total_bank_cash_value')
+
+ def _compute_kpi_account_total_bank_cash_value(self):
+ if not self.env.user.has_group('account.group_account_user'):
+ raise AccessError(_("Do not have access, skip this data for user's digest email"))
+
+ start, end, companies = self._get_kpi_compute_parameters()
+ data = self.env['account.move']._read_group([
+ ('date', '>=', start),
+ ('date', '<', end),
+ ('journal_id.type', 'in', ('cash', 'bank')),
+ ('company_id', 'in', companies.ids),
+ ], ['company_id'], ['amount_total:sum'])
+ data = dict(data)
+
+ for record in self:
+ company = record.company_id or self.env.company
+ record.kpi_account_bank_cash_value = data.get(company)
+
+ def _compute_kpis_actions(self, company, user):
+ res = super(Digest, self)._compute_kpis_actions(company, user)
+ res.update({'kpi_account_bank_cash': 'account.open_account_journal_dashboard_kanban?menu_id=%s' % (self.env.ref('account.menu_finance').id)})
+ return res
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/ir_model.py b/dev_odex30_accounting/odex30_account_accountant/models/ir_model.py
new file mode 100644
index 0000000..1ac8373
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/models/ir_model.py
@@ -0,0 +1,14 @@
+from odoo import api, models
+
+
+class IrModel(models.Model):
+ _inherit = 'ir.model'
+
+ @api.model
+ def _is_valid_for_model_selector(self, model):
+ return model not in {
+ # bank.rec.widget* does not have a psql table with _auto=False & _table_query="0",
+ # which makes the models unusable in the model selector.
+ 'bank.rec.widget',
+ 'bank.rec.widget.line',
+ } and super()._is_valid_for_model_selector(model)
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/ir_ui_menu.py b/dev_odex30_accounting/odex30_account_accountant/models/ir_ui_menu.py
new file mode 100644
index 0000000..ce62e0d
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/models/ir_ui_menu.py
@@ -0,0 +1,21 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+
+class IrUiMenu(models.Model):
+ _inherit = 'ir.ui.menu'
+
+ def _visible_menu_ids(self, debug=False):
+ visible_ids = super()._visible_menu_ids(debug)
+ # These menus should only be visible to accountants (users with group_account_readonly) and the group specified on the menu
+ # We want to avoid moving these menus to the new `accountant` module
+ if not self.env.user.has_group('account.group_account_readonly'):
+ accounting_menus = [
+ 'odex30_account_accountant.account_tag_menu',
+ 'odex30_account_accountant.menu_account_group',
+ 'odex30_account_reports.menu_action_account_report_multicurrency_revaluation',
+ ]
+ hidden_menu_ids = {self.env.ref(r).sudo().id for r in accounting_menus if self.env.ref(r, raise_if_not_found=False)}
+ return visible_ids - hidden_menu_ids
+ return visible_ids
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/res_company.py b/dev_odex30_accounting/odex30_account_accountant/models/res_company.py
new file mode 100644
index 0000000..2c72f7c
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/models/res_company.py
@@ -0,0 +1,208 @@
+from odoo import models, fields, _
+from odoo.tools.misc import DEFAULT_SERVER_DATE_FORMAT
+
+from datetime import timedelta
+from odoo.tools import date_utils
+
+
+class ResCompany(models.Model):
+ _inherit = 'res.company'
+
+ invoicing_switch_threshold = fields.Date(string="Invoicing Switch Threshold", help="Every payment and invoice before this date will receive the 'From Invoicing' status, hiding all the accounting entries related to it. Use this option after installing Accounting if you were using only Invoicing before, before importing all your actual accounting data in to Odoo.")
+ predict_bill_product = fields.Boolean(string="Predict Bill Product")
+
+ sign_invoice = fields.Boolean(string='Display signing field on invoices')
+ signing_user = fields.Many2one(comodel_name='res.users')
+
+ # Deferred expense management
+ deferred_expense_journal_id = fields.Many2one(
+ comodel_name='account.journal',
+ string="Deferred Expense Journal",
+ )
+ deferred_expense_account_id = fields.Many2one(
+ comodel_name='account.account',
+ string="Deferred Expense Account",
+ )
+ generate_deferred_expense_entries_method = fields.Selection(
+ string="Generate Deferred Expense Entries",
+ selection=[
+ ('on_validation', 'On bill validation'),
+ ('manual', 'Manually & Grouped'),
+ ],
+ default='on_validation',
+ required=True,
+ )
+ deferred_expense_amount_computation_method = fields.Selection(
+ string="Deferred Expense Based on",
+ selection=[
+ ('day', 'Days'),
+ ('month', 'Months'),
+ ('full_months', 'Full Months'),
+ ],
+ default='month',
+ required=True,
+ )
+
+ # Deferred revenue management
+ deferred_revenue_journal_id = fields.Many2one(
+ comodel_name='account.journal',
+ string="Deferred Revenue Journal",
+ )
+ deferred_revenue_account_id = fields.Many2one(
+ comodel_name='account.account',
+ string="Deferred Revenue Account",
+ )
+ generate_deferred_revenue_entries_method = fields.Selection(
+ string="Generate Deferred Revenue Entries",
+ selection=[
+ ('on_validation', 'On bill validation'),
+ ('manual', 'Manually & Grouped'),
+ ],
+ default='on_validation',
+ required=True,
+ )
+ deferred_revenue_amount_computation_method = fields.Selection(
+ string="Deferred Revenue Based on",
+ selection=[
+ ('day', 'Days'),
+ ('month', 'Months'),
+ ('full_months', 'Full Months'),
+ ],
+ default='month',
+ required=True,
+ )
+
+ def write(self, vals):
+ old_threshold_vals = {}
+ for record in self:
+ old_threshold_vals[record] = record.invoicing_switch_threshold
+
+ rslt = super(ResCompany, self).write(vals)
+
+ for record in self:
+ if 'invoicing_switch_threshold' in vals and old_threshold_vals[record] != vals['invoicing_switch_threshold']:
+ self.env['account.move.line'].flush_model(['move_id', 'parent_state'])
+ self.env['account.move'].flush_model(['company_id', 'date', 'state', 'payment_state', 'payment_state_before_switch'])
+ if record.invoicing_switch_threshold:
+ # If a new date was set as threshold, we switch all the
+ # posted moves and payments before it to 'invoicing_legacy'.
+ # We also reset to posted all the moves and payments that
+ # were 'invoicing_legacy' and were posterior to the threshold
+ self.env.cr.execute("""
+ update account_move_line aml
+ set parent_state = 'posted'
+ from account_move move
+ where aml.move_id = move.id
+ and move.payment_state = 'invoicing_legacy'
+ and move.date >= %(switch_threshold)s
+ and move.company_id = %(company_id)s;
+
+ update account_move
+ set state = 'posted',
+ payment_state = payment_state_before_switch,
+ payment_state_before_switch = null
+ where payment_state = 'invoicing_legacy'
+ and date >= %(switch_threshold)s
+ and company_id = %(company_id)s;
+
+ update account_move_line aml
+ set parent_state = 'cancel'
+ from account_move move
+ where aml.move_id = move.id
+ and move.state = 'posted'
+ and move.date < %(switch_threshold)s
+ and move.company_id = %(company_id)s;
+
+ update account_move
+ set state = 'cancel',
+ payment_state_before_switch = payment_state,
+ payment_state = 'invoicing_legacy'
+ where state = 'posted'
+ and date < %(switch_threshold)s
+ and company_id = %(company_id)s;
+ """, {'company_id': record.id, 'switch_threshold': record.invoicing_switch_threshold})
+ else:
+ # If the threshold date has been emptied, we re-post all the
+ # invoicing_legacy entries.
+ self.env.cr.execute("""
+ update account_move_line aml
+ set parent_state = 'posted'
+ from account_move move
+ where aml.move_id = move.id
+ and move.payment_state = 'invoicing_legacy'
+ and move.company_id = %(company_id)s;
+
+ update account_move
+ set state = 'posted',
+ payment_state = payment_state_before_switch,
+ payment_state_before_switch = null
+ where payment_state = 'invoicing_legacy'
+ and company_id = %(company_id)s;
+ """, {'company_id': record.id})
+
+ self.env['account.move.line'].invalidate_model(['parent_state'])
+ self.env['account.move'].invalidate_model(['state', 'payment_state', 'payment_state_before_switch'])
+
+ return rslt
+
+ def compute_fiscalyear_dates(self, current_date):
+ """Compute the start and end dates of the fiscal year where the given 'date' belongs to.
+
+ :param current_date: A datetime.date/datetime.datetime object.
+ :return: A dictionary containing:
+ * date_from
+ * date_to
+ * [Optionally] record: The fiscal year record.
+ """
+ self.ensure_one()
+ date_str = current_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
+
+ # Search a fiscal year record containing the date.
+ # If a record is found, then no need further computation, we get the dates range directly.
+ fiscalyear = self.env['account.fiscal.year'].search([
+ ('company_id', '=', self.id),
+ ('date_from', '<=', date_str),
+ ('date_to', '>=', date_str),
+ ], limit=1)
+ if fiscalyear:
+ return {
+ 'date_from': fiscalyear.date_from,
+ 'date_to': fiscalyear.date_to,
+ 'record': fiscalyear,
+ }
+
+ date_from, date_to = date_utils.get_fiscal_year(
+ current_date, day=self.fiscalyear_last_day, month=int(self.fiscalyear_last_month))
+
+ date_from_str = date_from.strftime(DEFAULT_SERVER_DATE_FORMAT)
+ date_to_str = date_to.strftime(DEFAULT_SERVER_DATE_FORMAT)
+
+ # Search for fiscal year records reducing the delta between the date_from/date_to.
+ # This case could happen if there is a gap between two fiscal year records.
+ # E.g. two fiscal year records: 2017-01-01 -> 2017-02-01 and 2017-03-01 -> 2017-12-31.
+ # => The period 2017-02-02 - 2017-02-30 is not covered by a fiscal year record.
+
+ fiscalyear_from = self.env['account.fiscal.year'].search([
+ ('company_id', '=', self.id),
+ ('date_from', '<=', date_from_str),
+ ('date_to', '>=', date_from_str),
+ ], limit=1)
+ if fiscalyear_from:
+ date_from = fiscalyear_from.date_to + timedelta(days=1)
+
+ fiscalyear_to = self.env['account.fiscal.year'].search([
+ ('company_id', '=', self.id),
+ ('date_from', '<=', date_to_str),
+ ('date_to', '>=', date_to_str),
+ ], limit=1)
+ if fiscalyear_to:
+ date_to = fiscalyear_to.date_from - timedelta(days=1)
+
+ return {'date_from': date_from, 'date_to': date_to}
+
+ def _get_unreconciled_statement_lines_redirect_action(self, unreconciled_statement_lines):
+ # OVERRIDE account
+ return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
+ extra_domain=[('id', 'in', unreconciled_statement_lines.ids)],
+ name=_('Unreconciled statements lines'),
+ )
diff --git a/dev_odex30_accounting/odex30_account_accountant/models/res_config_settings.py b/dev_odex30_accounting/odex30_account_accountant/models/res_config_settings.py
new file mode 100644
index 0000000..dce9496
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/models/res_config_settings.py
@@ -0,0 +1,111 @@
+from datetime import date
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ fiscalyear_last_day = fields.Integer(related='company_id.fiscalyear_last_day', required=True, readonly=False)
+ fiscalyear_last_month = fields.Selection(related='company_id.fiscalyear_last_month', required=True, readonly=False)
+ use_anglo_saxon = fields.Boolean(string='Anglo-Saxon Accounting', related='company_id.anglo_saxon_accounting', readonly=False)
+ invoicing_switch_threshold = fields.Date(string="Invoicing Switch Threshold", related='company_id.invoicing_switch_threshold', readonly=False)
+ group_fiscal_year = fields.Boolean(string='Fiscal Years', implied_group='odex30_account_accountant.group_fiscal_year')
+ predict_bill_product = fields.Boolean(string="Predict Bill Product", related='company_id.predict_bill_product', readonly=False)
+
+ sign_invoice = fields.Boolean(string='Authorized Signatory on invoice', related='company_id.sign_invoice', readonly=False)
+ signing_user = fields.Many2one(
+ comodel_name='res.users',
+ string="Signature used to sign all the invoice",
+ readonly=False,
+ related='company_id.signing_user',
+ help="Select a user here to override every signature on invoice by this user's signature"
+ )
+ module_sign = fields.Boolean(string='Sign', compute='_compute_module_sign_status')
+
+ # Deferred expense management
+ deferred_expense_journal_id = fields.Many2one(
+ comodel_name='account.journal',
+ help='Journal used for deferred entries',
+ readonly=False,
+ related='company_id.deferred_expense_journal_id',
+ )
+ deferred_expense_account_id = fields.Many2one(
+ comodel_name='account.account',
+ help='Account used for deferred expenses',
+ readonly=False,
+ related='company_id.deferred_expense_account_id',
+ )
+ generate_deferred_expense_entries_method = fields.Selection(
+ related='company_id.generate_deferred_expense_entries_method',
+ readonly=False, required=True,
+ help='Method used to generate deferred entries',
+ )
+ deferred_expense_amount_computation_method = fields.Selection(
+ related='company_id.deferred_expense_amount_computation_method',
+ readonly=False, required=True,
+ help='Method used to compute the amount of deferred entries',
+ )
+
+ # Deferred revenue management
+ deferred_revenue_journal_id = fields.Many2one(
+ comodel_name='account.journal',
+ help='Journal used for deferred entries',
+ readonly=False,
+ related='company_id.deferred_revenue_journal_id',
+ )
+ deferred_revenue_account_id = fields.Many2one(
+ comodel_name='account.account',
+ help='Account used for deferred revenues',
+ readonly=False,
+ related='company_id.deferred_revenue_account_id',
+ )
+ generate_deferred_revenue_entries_method = fields.Selection(
+ related='company_id.generate_deferred_revenue_entries_method',
+ readonly=False, required=True,
+ help='Method used to generate deferred entries',
+ )
+ deferred_revenue_amount_computation_method = fields.Selection(
+ related='company_id.deferred_revenue_amount_computation_method',
+ readonly=False, required=True,
+ help='Method used to compute the amount of deferred entries',
+ )
+
+ @api.depends('sign_invoice')
+ def _compute_module_sign_status(self):
+ sign_installed = 'sign' in self.env['ir.module.module']._installed()
+ for settings in self:
+ settings.module_sign = sign_installed or settings.company_id.sign_invoice
+
+ @api.constrains('fiscalyear_last_day', 'fiscalyear_last_month')
+ def _check_fiscalyear(self):
+ # We try if the date exists in 2020, which is a leap year.
+ # We do not define the constrain on res.company, since the recomputation of the related
+ # fields is done one field at a time.
+ for wiz in self:
+ try:
+ date(2020, int(wiz.fiscalyear_last_month), wiz.fiscalyear_last_day)
+ except ValueError:
+ raise ValidationError(
+ _('Incorrect fiscal year date: day is out of range for month. Month: %(month)s; Day: %(day)s',
+ month=wiz.fiscalyear_last_month, day=wiz.fiscalyear_last_day),
+ )
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ # Amazing workaround: non-stored related fields on company are a BAD idea since the 2 fields
+ # must follow the constraint '_check_fiscalyear_last_day'. The thing is, in case of related
+ # fields, the inverse write is done one value at a time, and thus the constraint is verified
+ # one value at a time... so it is likely to fail.
+ for vals in vals_list:
+ fiscalyear_last_day = vals.pop('fiscalyear_last_day', False) or self.env.company.fiscalyear_last_day
+ fiscalyear_last_month = vals.pop('fiscalyear_last_month', False) or self.env.company.fiscalyear_last_month
+ vals = {}
+ if fiscalyear_last_day != self.env.company.fiscalyear_last_day:
+ vals['fiscalyear_last_day'] = fiscalyear_last_day
+ if fiscalyear_last_month != self.env.company.fiscalyear_last_month:
+ vals['fiscalyear_last_month'] = fiscalyear_last_month
+ if vals:
+ self.env.company.write(vals)
+ return super().create(vals_list)
diff --git a/dev_odex30_accounting/odex30_account_accountant/security/ir.model.access.csv b/dev_odex30_accounting/odex30_account_accountant/security/ir.model.access.csv
new file mode 100644
index 0000000..34dba06
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/security/ir.model.access.csv
@@ -0,0 +1,12 @@
+"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
+"access_account_change_lock_date","access.account.change.lock.date","model_account_change_lock_date","account.group_account_manager",1,1,1,0
+"access_account_secure_entries_wizard","access.account.secure.entries.wizard","account.model_account_secure_entries_wizard","account.group_account_user",1,1,1,0
+"access_account_auto_reconcile_wizard","access.account.auto.reconcile.wizard","model_account_auto_reconcile_wizard","account.group_account_user",1,1,1,0
+"access_account_reconcile_wizard","access.account.reconcile.wizard","model_account_reconcile_wizard","account.group_account_user",1,1,1,0
+
+access_account_fiscal_year_readonly,account.fiscal.year.user,model_account_fiscal_year,account.group_account_readonly,1,0,0,0
+access_account_fiscal_year_basic,account.fiscal.year.basic,model_account_fiscal_year,account.group_account_basic,1,0,0,0
+access_account_fiscal_year_manager,account.fiscal.year.manager,model_account_fiscal_year,account.group_account_manager,1,1,1,1
+
+access_bank_rec_widget,access.bank.rec.widget,model_bank_rec_widget,account.group_account_user,1,1,1,1
+access_bank_rec_widget_line,access.bank.rec.widget.line,model_bank_rec_widget_line,account.group_account_user,1,1,1,1
diff --git a/dev_odex30_accounting/odex30_account_accountant/security/odex30_account_accountant_security.xml b/dev_odex30_accounting/odex30_account_accountant/security/odex30_account_accountant_security.xml
new file mode 100644
index 0000000..d81eca8
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/security/odex30_account_accountant_security.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+ Invoicing & Banks
+
+
+
+
+
+
+
+
+ Allow to define fiscal years of more or less than a year
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/amls_list_view.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/amls_list_view.js
new file mode 100644
index 0000000..b4aabbc
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/amls_list_view.js
@@ -0,0 +1,49 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+import { EmbeddedListView } from "./embedded_list_view";
+import { ListRenderer } from "@web/views/list/list_renderer";
+import { useState, onWillUnmount } from "@odoo/owl";
+
+export class BankRecAmlsRenderer extends ListRenderer {
+ setup() {
+ super.setup();
+ this.globalState = useState(this.env.methods.getState());
+
+ onWillUnmount(this.saveSearchState);
+ }
+
+ /** @override **/
+ getRowClass(record) {
+ const classes = super.getRowClass(record);
+ const amlId = this.globalState.bankRecRecordData.selected_aml_ids.currentIds.find((x) => x === record.resId);
+ if (amlId){
+ return `${classes} o_rec_widget_list_selected_item table-info`;
+ }
+ return classes;
+ }
+
+ /** @override **/
+ async onCellClicked(record, column, ev) {
+ const amlId = this.globalState.bankRecRecordData.selected_aml_ids.currentIds.find((x) => x === record.resId);
+ if (amlId) {
+ this.env.config.actionRemoveNewAml(record.resId);
+ } else {
+ this.env.config.actionAddNewAml(record.resId);
+ }
+ }
+
+ /** Backup the search facets in order to restore them when the user comes back on this view. **/
+ saveSearchState() {
+ const initParams = this.globalState.bankRecEmbeddedViewsData.amls;
+ const searchModel = this.env.searchModel;
+ initParams.exportState = {searchModel: JSON.stringify(searchModel.exportState())};
+ }
+}
+
+export const BankRecAmls = {
+ ...EmbeddedListView,
+ Renderer: BankRecAmlsRenderer,
+};
+
+registry.category("views").add("bank_rec_amls_list_view", BankRecAmls);
diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml
new file mode 100644
index 0000000..9537584
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml
@@ -0,0 +1,752 @@
+
+
+
+
+
+
+
+
+ Validate
+
+
+ Validate
+
+
+ Reset
+
+
+ To Check
+
+
+ To Check
+
+
+ Set as Checked
+
+
+
+
+
+
+
+
+
+ Confirm
+
+
+
+ code
+
+if records:
+ action = records.filtered(lambda asset: asset.state == 'draft').validate()
+
+
+
+
+ Compute Depreciation
+
+
+
+ list
+ code
+
+if records:
+ action = records.filtered(lambda asset: asset.state == 'draft').compute_depreciation_board()
+
+
+
+
+
+
+
+
+
+
+ account.move.line.list.asset
+ account.move.line
+ primary
+
+
+ hide
+ hide
+ hide
+ hide
+ hide
+ show
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_asset/views/account_move_views.xml b/dev_odex30_accounting/odex30_account_asset/views/account_move_views.xml
new file mode 100644
index 0000000..5be8e1f
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_asset/views/account_move_views.xml
@@ -0,0 +1,68 @@
+
+
+
+ account.move.form
+ account.move
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ account.move.line.form
+ account.move.line
+
+
+
+
+
+
+
+
+
+
+
+ Create Asset
+
+
+
+ code
+
+if records:
+ action = records.turn_as_asset()
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_asset/wizard/__init__.py b/dev_odex30_accounting/odex30_account_asset/wizard/__init__.py
new file mode 100644
index 0000000..71973ea
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_asset/wizard/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import asset_modify
diff --git a/dev_odex30_accounting/odex30_account_asset/wizard/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_asset/wizard/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..0b7c888
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_asset/wizard/__pycache__/__init__.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_asset/wizard/__pycache__/asset_modify.cpython-311.pyc b/dev_odex30_accounting/odex30_account_asset/wizard/__pycache__/asset_modify.cpython-311.pyc
new file mode 100644
index 0000000..35e5444
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_asset/wizard/__pycache__/asset_modify.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_asset/wizard/asset_modify.py b/dev_odex30_accounting/odex30_account_asset/wizard/asset_modify.py
new file mode 100644
index 0000000..310e1ef
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_asset/wizard/asset_modify.py
@@ -0,0 +1,409 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, _, Command
+from odoo.exceptions import UserError
+from odoo.tools.misc import format_date
+from odoo.tools import float_is_zero
+
+from dateutil.relativedelta import relativedelta
+
+
+class AssetModify(models.TransientModel):
+ _name = 'asset.modify'
+ _description = 'Modify Asset'
+
+ name = fields.Text(string='Note')
+ asset_id = fields.Many2one(string="Asset", comodel_name='account.asset', required=True, help="The asset to be modified by this wizard", ondelete="cascade")
+ method_number = fields.Integer(string='Duration', required=True)
+ method_period = fields.Selection([('1', 'Months'), ('12', 'Years')], string='Number of Months in a Period', help="The amount of time between two depreciations")
+ value_residual = fields.Monetary(string="Depreciable Amount", help="New residual amount for the asset", compute="_compute_value_residual", store=True, readonly=False)
+ salvage_value = fields.Monetary(string="Not Depreciable Amount", help="New salvage amount for the asset")
+ currency_id = fields.Many2one(related='asset_id.currency_id')
+ date = fields.Date(default=lambda self: fields.Date.today(), string='Date')
+ select_invoice_line_id = fields.Boolean(compute="_compute_select_invoice_line_id")
+ # if we should display the fields for the creation of gross increase asset
+ gain_value = fields.Boolean(compute="_compute_gain_value")
+
+ account_asset_id = fields.Many2one(
+ 'account.account',
+ string="Gross Increase Account",
+ check_company=True,
+ domain="[('deprecated', '=', False)]",
+ )
+ account_asset_counterpart_id = fields.Many2one(
+ 'account.account',
+ check_company=True,
+ domain="[('deprecated', '=', False)]",
+ string="Asset Counterpart Account",
+ )
+ account_depreciation_id = fields.Many2one(
+ 'account.account',
+ check_company=True,
+ domain="[('deprecated', '=', False)]",
+ string="Depreciation Account",
+ )
+ account_depreciation_expense_id = fields.Many2one(
+ 'account.account',
+ check_company=True,
+ domain="[('deprecated', '=', False)]",
+ string="Expense Account",
+ )
+ modify_action = fields.Selection(selection="_get_selection_modify_options", string="Action")
+ company_id = fields.Many2one('res.company', related='asset_id.company_id')
+
+ invoice_ids = fields.Many2many(
+ comodel_name='account.move',
+ string="Customer Invoice",
+ check_company=True,
+ domain="[('move_type', '=', 'out_invoice'), ('state', '=', 'posted')]",
+ help="The disposal invoice is needed in order to generate the closing journal entry.",
+ )
+ invoice_line_ids = fields.Many2many(
+ comodel_name='account.move.line',
+ check_company=True,
+ domain="[('move_id', '=', invoice_id), ('display_type', '=', 'product')]",
+ help="There are multiple lines that could be the related to this asset",
+ )
+ gain_account_id = fields.Many2one(
+ comodel_name='account.account',
+ check_company=True,
+ domain="[('deprecated', '=', False)]",
+ compute="_compute_accounts", inverse="_inverse_gain_account", readonly=False, compute_sudo=True,
+ help="Account used to write the journal item in case of gain",
+ )
+ loss_account_id = fields.Many2one(
+ comodel_name='account.account',
+ check_company=True,
+ domain="[('deprecated', '=', False)]",
+ compute="_compute_accounts", inverse="_inverse_loss_account", readonly=False, compute_sudo=True,
+ help="Account used to write the journal item in case of loss",
+ )
+
+ informational_text = fields.Html(compute='_compute_informational_text')
+
+ # Technical field to know if there was a profit or a loss in the selling of the asset
+ gain_or_loss = fields.Selection([('gain', 'Gain'), ('loss', 'Loss'), ('no', 'No')], compute='_compute_gain_or_loss')
+
+ def _compute_modify_action(self):
+ if self.env.context.get('resume_after_pause'):
+ return 'resume'
+ else:
+ return 'dispose'
+
+ @api.depends('asset_id')
+ def _get_selection_modify_options(self):
+ if self.env.context.get('resume_after_pause'):
+ return [('resume', _('Resume'))]
+ return [
+ ('dispose', _("Dispose")),
+ ('sell', _("Sell")),
+ ('modify', _("Re-evaluate")),
+ ('pause', _("Pause")),
+ ]
+
+ @api.depends('company_id')
+ def _compute_accounts(self):
+ for record in self:
+ record.gain_account_id = record.company_id.gain_account_id
+ record.loss_account_id = record.company_id.loss_account_id
+
+ @api.depends('date')
+ def _compute_value_residual(self):
+ for record in self:
+ record.value_residual = record.asset_id._get_residual_value_at_date(record.date)
+
+ def _inverse_gain_account(self):
+ for record in self:
+ record.company_id.sudo().gain_account_id = record.gain_account_id
+
+ def _inverse_loss_account(self):
+ for record in self:
+ record.company_id.sudo().loss_account_id = record.loss_account_id
+
+ @api.onchange('modify_action')
+ def _onchange_action(self):
+ if self.modify_action == 'sell' and self.asset_id.children_ids.filtered(lambda a: a.state in ('draft', 'open') or a.value_residual > 0):
+ raise UserError(_("You cannot automate the journal entry for an asset that has a running gross increase. Please use 'Dispose' on the increase(s)."))
+ if self.modify_action not in ('modify', 'resume'):
+ self.write({'value_residual': self.asset_id._get_residual_value_at_date(self.date), 'salvage_value': self.asset_id.salvage_value})
+
+ @api.onchange('invoice_ids')
+ def _onchange_invoice_ids(self):
+ self.invoice_line_ids = self.invoice_ids.invoice_line_ids.filtered(lambda line: line._origin.id in self.invoice_line_ids.ids) # because the domain filter doesn't apply and the invoice_line_ids remains selected
+ for invoice in self.invoice_ids.filtered(lambda inv: len(inv.invoice_line_ids) == 1):
+ self.invoice_line_ids += invoice.invoice_line_ids
+
+ @api.depends('asset_id', 'invoice_ids', 'invoice_line_ids', 'modify_action', 'date')
+ def _compute_gain_or_loss(self):
+ for record in self:
+ balances = abs(sum([invoice.balance for invoice in record.invoice_line_ids]))
+ comparison = record.company_id.currency_id.compare_amounts(record.asset_id._get_own_book_value(record.date), balances)
+ if record.modify_action in ('sell', 'dispose') and comparison < 0:
+ record.gain_or_loss = 'gain'
+ elif record.modify_action in ('sell', 'dispose') and comparison > 0:
+ record.gain_or_loss = 'loss'
+ else:
+ record.gain_or_loss = 'no'
+
+ @api.depends('asset_id', 'value_residual', 'salvage_value')
+ def _compute_gain_value(self):
+ for record in self:
+ record.gain_value = record.currency_id.compare_amounts(
+ record._get_own_book_value(),
+ record.asset_id._get_own_book_value(record.date)
+ ) > 0
+
+ @api.depends('loss_account_id', 'gain_account_id', 'gain_or_loss', 'modify_action', 'date', 'value_residual', 'salvage_value')
+ def _compute_informational_text(self):
+ for wizard in self:
+ if wizard.modify_action == 'dispose':
+ if wizard.gain_or_loss == 'gain':
+ account = wizard.gain_account_id.display_name or ''
+ gain_or_loss = _('gain')
+ elif wizard.gain_or_loss == 'loss':
+ account = wizard.loss_account_id.display_name or ''
+ gain_or_loss = _('loss')
+ else:
+ account = ''
+ gain_or_loss = _('gain/loss')
+ wizard.informational_text = _(
+ "A depreciation entry will be posted on and including the date %(date)s."
+ " A disposal entry will be posted on the %(account_type)s account %(account)s.",
+ date=format_date(self.env, wizard.date), account_type=gain_or_loss, account=account,
+ )
+ elif wizard.modify_action == 'sell':
+ if wizard.gain_or_loss == 'gain':
+ account = wizard.gain_account_id.display_name or ''
+ elif wizard.gain_or_loss == 'loss':
+ account = wizard.loss_account_id.display_name or ''
+ else:
+ account = ''
+ wizard.informational_text = _(
+ "A depreciation entry will be posted on and including the date %(date)s."
+ " A second entry will neutralize the original income and post the "
+ "outcome of this sale on account %(account)s.",
+ date=format_date(self.env, wizard.date), account=account,
+ )
+ elif wizard.modify_action == 'pause':
+ wizard.informational_text = _(
+ "A depreciation entry will be posted on and including the date %s.",
+ format_date(self.env, wizard.date)
+ )
+ elif wizard.modify_action == 'modify':
+ if wizard.gain_value:
+ text = _("An asset will be created for the value increase of the asset. ")
+ else:
+ text = ""
+ wizard.informational_text = _(
+ "A depreciation entry will be posted on and including the date %(date)s. %(extra_text)s "
+ "Future entries will be recomputed to depreciate the asset following the changes.",
+ date=format_date(self.env, wizard.date), extra_text=text,
+ )
+
+ else:
+ if wizard.gain_value:
+ text = _("An asset will be created for the value increase of the asset. ")
+ else:
+ text = ""
+ wizard.informational_text = _("%s Future entries will be recomputed to depreciate the asset following the changes.", text)
+
+ @api.depends('invoice_ids', 'modify_action')
+ def _compute_select_invoice_line_id(self):
+ for record in self:
+ record.select_invoice_line_id = record.modify_action == 'sell' and len(record.invoice_ids.invoice_line_ids) > 1
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if 'asset_id' in vals:
+ asset = self.env['account.asset'].browse(vals['asset_id'])
+ if asset.depreciation_move_ids.filtered(lambda m: m.state == 'posted' and not m.reversal_move_ids and m.date > fields.Date.today()):
+ raise UserError(_('Reverse the depreciation entries posted in the future in order to modify the depreciation'))
+ if 'method_number' not in vals:
+ vals.update({'method_number': asset.method_number})
+ if 'method_period' not in vals:
+ vals.update({'method_period': asset.method_period})
+ if 'salvage_value' not in vals:
+ vals.update({'salvage_value': asset.salvage_value})
+ if 'account_asset_id' not in vals:
+ vals.update({'account_asset_id': asset.account_asset_id.id})
+ if 'account_depreciation_id' not in vals:
+ vals.update({'account_depreciation_id': asset.account_depreciation_id.id})
+ if 'account_depreciation_expense_id' not in vals:
+ vals.update({'account_depreciation_expense_id': asset.account_depreciation_expense_id.id})
+ return super().create(vals_list)
+
+ def modify(self):
+ """ Modifies the duration of asset for calculating depreciation
+ and maintains the history of old values, in the chatter.
+ """
+ if self.date <= self.asset_id.company_id._get_user_fiscal_lock_date(self.asset_id.journal_id):
+ raise UserError(_("You can't re-evaluate the asset before the lock date."))
+
+ old_values = {
+ 'method_number': self.asset_id.method_number,
+ 'method_period': self.asset_id.method_period,
+ 'value_residual': self.asset_id.value_residual,
+ 'salvage_value': self.asset_id.salvage_value,
+ }
+
+ asset_vals = {
+ 'method_number': self.method_number,
+ 'method_period': self.method_period,
+ 'salvage_value': self.salvage_value,
+ 'account_asset_id': self.account_asset_id,
+ 'account_depreciation_id': self.account_depreciation_id,
+ 'account_depreciation_expense_id': self.account_depreciation_expense_id,
+ }
+ if self.env.context.get('resume_after_pause'):
+ date_before_pause = max(self.asset_id.depreciation_move_ids, key=lambda x: x.date).date if self.asset_id.depreciation_move_ids else self.asset_id.acquisition_date
+ # We are removing one day to number days because we don't count the current day
+ # i.e. If we pause and resume the same day, there isn't any gap whereas for depreciation
+ # purpose it would count as one full day
+ number_days = self.asset_id._get_delta_days(date_before_pause, self.date) - 1
+ if self.currency_id.compare_amounts(number_days, 0) < 0:
+ raise UserError(_("You cannot resume at a date equal to or before the pause date"))
+
+ asset_vals.update({'asset_paused_days': self.asset_id.asset_paused_days + number_days})
+ asset_vals.update({'state': 'open'})
+ self.asset_id.message_post(body=_("Asset unpaused. %s", self.name))
+
+ current_asset_book = self.asset_id._get_own_book_value(self.date)
+ after_asset_book = self._get_own_book_value()
+ increase = after_asset_book - current_asset_book
+
+ new_residual, new_salvage = self._get_new_asset_values(current_asset_book)
+ residual_increase = max(0, self.value_residual - new_residual)
+ salvage_increase = max(0, self.salvage_value - new_salvage)
+
+ if not self.env.context.get('resume_after_pause'):
+ if self.env['account.move'].search_count([('asset_id', '=', self.asset_id.id), ('state', '=', 'draft'), ('date', '<=', self.date)], limit=1):
+ raise UserError(_('There are unposted depreciations prior to the selected operation date, please deal with them first.'))
+ self.asset_id._create_move_before_date(self.date)
+
+ asset_vals.update({
+ 'salvage_value': new_salvage,
+ })
+ computation_children_changed = (
+ asset_vals['method_number'] != self.asset_id.method_number
+ or asset_vals['method_period'] != self.asset_id.method_period
+ or asset_vals.get('asset_paused_days') and not float_is_zero(asset_vals['asset_paused_days'] - self.asset_id.asset_paused_days, 8)
+ )
+ self.asset_id.write(asset_vals)
+
+ # Check for residual/salvage increase while rounding with the company currency precision to prevent float precision issues.
+ if self.currency_id.compare_amounts(residual_increase + salvage_increase, 0) > 0:
+ move = self.env['account.move'].create({
+ 'journal_id': self.asset_id.journal_id.id,
+ 'date': self.date + relativedelta(days=1),
+ 'move_type': 'entry',
+ 'asset_move_type': 'positive_revaluation',
+ 'line_ids': [
+ Command.create({
+ 'account_id': self.account_asset_id.id,
+ 'debit': residual_increase + salvage_increase,
+ 'credit': 0,
+ 'name': _('Value increase for: %(asset)s', asset=self.asset_id.name),
+ }),
+ Command.create({
+ 'account_id': self.account_asset_counterpart_id.id,
+ 'debit': 0,
+ 'credit': residual_increase + salvage_increase,
+ 'name': _('Value increase for: %(asset)s', asset=self.asset_id.name),
+ }),
+ ],
+ })
+ move._post()
+ asset_increase = self.env['account.asset'].create({
+ 'name': self.asset_id.name + ': ' + self.name if self.name else "",
+ 'currency_id': self.asset_id.currency_id.id,
+ 'company_id': self.asset_id.company_id.id,
+ 'method': self.asset_id.method,
+ 'method_number': self.method_number,
+ 'method_period': self.method_period,
+ 'method_progress_factor': self.asset_id.method_progress_factor,
+ 'acquisition_date': self.date + relativedelta(days=1),
+ 'value_residual': residual_increase,
+ 'salvage_value': salvage_increase,
+ 'prorata_date': self.date + relativedelta(days=1),
+ 'prorata_computation_type': 'daily_computation' if self.asset_id.prorata_computation_type == 'daily_computation' else 'constant_periods',
+ 'original_value': self._get_increase_original_value(residual_increase, salvage_increase),
+ 'account_asset_id': self.account_asset_id.id,
+ 'account_depreciation_id': self.account_depreciation_id.id,
+ 'account_depreciation_expense_id': self.account_depreciation_expense_id.id,
+ 'journal_id': self.asset_id.journal_id.id,
+ 'parent_id': self.asset_id.id,
+ 'original_move_line_ids': [(6, 0, move.line_ids.filtered(lambda r: r.account_id == self.account_asset_id).ids)],
+ })
+ asset_increase.validate()
+
+ subject = _('A gross increase has been created: %(link)s', link=asset_increase._get_html_link())
+ self.asset_id.message_post(body=subject)
+
+ if self.currency_id.compare_amounts(increase, 0) < 0:
+ move = self.env['account.move'].create(self.env['account.move']._prepare_move_for_asset_depreciation({
+ 'amount': -increase,
+ 'asset_id': self.asset_id,
+ 'move_ref': _('Value decrease for: %(asset)s', asset=self.asset_id.name),
+ 'depreciation_beginning_date': self.date,
+ 'depreciation_end_date': self.date,
+ 'date': self.date,
+ 'asset_number_days': 0,
+ 'asset_value_change': True,
+ 'asset_move_type': 'negative_revaluation',
+ }))._post()
+
+ restart_date = self.date if self.env.context.get('resume_after_pause') else self.date + relativedelta(days=1)
+ if self.asset_id.depreciation_move_ids:
+ self.asset_id.compute_depreciation_board(restart_date)
+ else:
+ # We have no moves, we can compute it as new
+ self.asset_id.compute_depreciation_board()
+
+ if computation_children_changed:
+ children = self.asset_id.children_ids
+ children.write({
+ 'method_number': asset_vals['method_number'],
+ 'method_period': asset_vals['method_period'],
+ 'asset_paused_days': self.asset_id.asset_paused_days,
+ })
+
+ for child in children:
+ if not self.env.context.get('resume_after_pause'):
+ child._create_move_before_date(self.date)
+ if child.depreciation_move_ids:
+ child.compute_depreciation_board(restart_date)
+ else:
+ child.compute_depreciation_board()
+ child._check_depreciations()
+ child.depreciation_move_ids.filtered(lambda move: move.state != 'posted')._post()
+ tracked_fields = self.env['account.asset'].fields_get(old_values.keys())
+ changes, tracking_value_ids = self.asset_id._mail_track(tracked_fields, old_values)
+ if changes:
+ self.asset_id.message_post(body=_('Depreciation board modified %s', self.name), tracking_value_ids=tracking_value_ids)
+ self.asset_id._check_depreciations()
+ self.asset_id.depreciation_move_ids.filtered(lambda move: move.state != 'posted')._post()
+ return {'type': 'ir.actions.act_window_close'}
+
+ def pause(self):
+ for record in self:
+ record.asset_id.pause(pause_date=record.date, message=self.name)
+
+ def sell_dispose(self):
+ self.ensure_one()
+ if self.gain_account_id == self.asset_id.account_depreciation_id or self.loss_account_id == self.asset_id.account_depreciation_id:
+ raise UserError(_("You cannot select the same account as the Depreciation Account"))
+ invoice_lines = self.env['account.move.line'] if self.modify_action == 'dispose' else self.invoice_line_ids
+ return self.asset_id.set_to_close(invoice_line_ids=invoice_lines, date=self.date, message=self.name)
+
+ def _get_own_book_value(self):
+ return self.value_residual + self.salvage_value
+
+ def _get_increase_original_value(self, residual_increase, salvage_increase):
+ return residual_increase + salvage_increase
+
+ def _get_new_asset_values(self, current_asset_book):
+ self.ensure_one()
+ new_residual = min(current_asset_book - min(self.salvage_value, self.asset_id.salvage_value), self.value_residual)
+ new_salvage = min(current_asset_book - new_residual, self.salvage_value)
+ return new_residual, new_salvage
diff --git a/dev_odex30_accounting/odex30_account_asset/wizard/asset_modify_views.xml b/dev_odex30_accounting/odex30_account_asset/wizard/asset_modify_views.xml
new file mode 100644
index 0000000..c36d943
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_asset/wizard/asset_modify_views.xml
@@ -0,0 +1,104 @@
+
+
+
+
+ wizard.asset.modify.form
+ asset.modify
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/__init__.py b/dev_odex30_accounting/odex30_account_auto_transfer/__init__.py
new file mode 100644
index 0000000..502c788
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+from . import models
+from . import demo
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/__manifest__.py b/dev_odex30_accounting/odex30_account_auto_transfer/__manifest__.py
new file mode 100644
index 0000000..71a683a
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/__manifest__.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+
+{
+ 'name': 'Account Automatic Transfers',
+ 'depends': ['odex30_account_accountant'],
+ 'description': """
+Account Automatic Transfers
+===========================
+Manage automatic transfers between your accounts.
+ """,
+ 'category': 'Odex30-Accounting/Odex30-Accounting',
+ 'author': "Expert Co. Ltd.",
+ 'website': "http://www.exp-sa.com",
+ 'data': [
+ 'security/account_auto_transfer_security.xml',
+ 'security/ir.model.access.csv',
+ 'data/cron.xml',
+ 'views/transfer_model_views.xml',
+ ],
+ 'auto_install': True,
+}
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_auto_transfer/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..5f93de8
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_auto_transfer/__pycache__/__init__.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/data/cron.xml b/dev_odex30_accounting/odex30_account_auto_transfer/data/cron.xml
new file mode 100644
index 0000000..a6b169b
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/data/cron.xml
@@ -0,0 +1,10 @@
+
+
+ Account automatic transfers: Perform transfers
+
+ code
+ model.action_cron_auto_transfer()
+ 1
+ days
+
+
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/demo/__init__.py b/dev_odex30_accounting/odex30_account_auto_transfer/demo/__init__.py
new file mode 100644
index 0000000..7eaefb9
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/demo/__init__.py
@@ -0,0 +1 @@
+from . import account_demo
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/demo/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_auto_transfer/demo/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..4638962
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_auto_transfer/demo/__pycache__/__init__.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/demo/__pycache__/account_demo.cpython-311.pyc b/dev_odex30_accounting/odex30_account_auto_transfer/demo/__pycache__/account_demo.cpython-311.pyc
new file mode 100644
index 0000000..3d62215
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_auto_transfer/demo/__pycache__/account_demo.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/demo/account_demo.py b/dev_odex30_accounting/odex30_account_auto_transfer/demo/account_demo.py
new file mode 100644
index 0000000..17893f3
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/demo/account_demo.py
@@ -0,0 +1,63 @@
+import time
+
+from odoo import api, _, models, Command
+
+
+class AccountChartTemplate(models.AbstractModel):
+ _inherit = "account.chart.template"
+
+ @api.model
+ def _get_demo_data(self, company=False):
+ demo_data = {
+ 'account.journal': {},
+ **super()._get_demo_data(company),
+ }
+ demo_data['account.journal'].update({
+ 'auto_transfer_journal': {
+ 'name': _("IFRS Automatic Transfers"),
+ 'code': "IFRSA",
+ 'type': 'general',
+ 'show_on_dashboard': False,
+ 'sequence': 1000,
+ },
+ })
+ demo_data['account.transfer.model'] = {
+ 'monthly_model': {
+ 'name': _("IFRS rent expense transfer"),
+ 'date_start': time.strftime('%Y-01-01'),
+ 'frequency': 'month',
+ 'journal_id': 'auto_transfer_journal',
+ 'account_ids': [self._get_demo_account('expense_rent', 'expense', company).id],
+ 'line_ids': [
+ Command.create({
+ 'account_id': self._get_demo_account('expense_rd', 'expense', company).id,
+ 'percent': 35.0,
+ }),
+ Command.create({
+ 'account_id': self._get_demo_account('expense_sales', 'expense_direct_cost', company).id,
+ 'percent': 65.0,
+ }),
+ ],
+ },
+ 'yearly_model': {
+ 'name': _("Yearly liabilites auto transfers"),
+ 'date_start': time.strftime('%Y-01-01'),
+ 'frequency': 'year',
+ 'journal_id': 'auto_transfer_journal',
+ 'account_ids': [Command.set([
+ self._get_demo_account('current_liabilities', 'liability_current', company).id,
+ self._get_demo_account('payable', 'liability_payable', company).id
+ ])],
+ 'line_ids': [
+ Command.create({
+ 'account_id': self._get_demo_account('payable', 'liability_payable', company).id,
+ 'percent': 77.5,
+ }),
+ Command.create({
+ 'account_id': self._get_demo_account('non_current_liabilities', 'liability_non_current', company).id,
+ 'percent': 22.5,
+ }),
+ ],
+ },
+ }
+ return demo_data
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/i18n/ar.po b/dev_odex30_accounting/odex30_account_auto_transfer/i18n/ar.po
new file mode 100644
index 0000000..c3d8bc6
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/i18n/ar.po
@@ -0,0 +1,479 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * odex30_account_auto_transfer
+#
+# Translators:
+# Wil Odoo, 2024
+# Mustafa J. Kadhem , 2024
+# Malaz Abuidris , 2024
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 18.0+e\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2024-09-25 09:26+0000\n"
+"PO-Revision-Date: 2024-09-25 09:43+0000\n"
+"Last-Translator: Malaz Abuidris , 2024\n"
+"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Language: ar\n"
+"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
+
+#. module: odex30_account_auto_transfer
+#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form
+msgid " to "
+msgstr " إلى "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model,name:odex30_account_auto_transfer.model_account_chart_template
+msgid "Account Chart Template"
+msgstr "نموذج مخطط الحساب "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model,name:odex30_account_auto_transfer.model_account_transfer_model
+msgid "Account Transfer Model"
+msgstr "نموذج تحويل الحساب "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model,name:odex30_account_auto_transfer.model_account_transfer_model_line
+msgid "Account Transfer Model Line"
+msgstr "بند نموذج تحويل الحساب "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.actions.server,name:odex30_account_auto_transfer.ir_cron_auto_transfer_ir_actions_server
+msgid "Account automatic transfers: Perform transfers"
+msgstr "تحويلات الحساب التلقائية: أداء التحويلات "
+
+#. module: odex30_account_auto_transfer
+#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form
+msgid "Activate"
+msgstr "تفعيل"
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__active
+msgid "Active"
+msgstr "نشط "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,help:odex30_account_auto_transfer.field_account_transfer_model_line__analytic_account_ids
+msgid ""
+"Adds a condition to only transfer the sum of the lines from the origin "
+"accounts that match these analytic accounts to the destination account"
+msgstr ""
+"يقوم بإضافة شرط لتحويل مجموع البنود من الحسابات الأصلية التي تطابق تلك "
+"الحسابات التحليلية إلى الحساب الهدف فقط "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,help:odex30_account_auto_transfer.field_account_transfer_model_line__partner_ids
+msgid ""
+"Adds a condition to only transfer the sum of the lines from the origin "
+"accounts that match these partners to the destination account"
+msgstr ""
+"يقوم بإضافة شرط لتحويل مجموع البنود من الحسابات الأصلية التي تطابق هؤلاء "
+"الشركاء إلى الحساب الهدف فقط "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__analytic_account_ids
+msgid "Analytic Filter"
+msgstr "عامل تصفية تحليلي "
+
+#. module: odex30_account_auto_transfer
+#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form
+#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_search
+msgid "Archived"
+msgstr "مؤرشف "
+
+#. module: odex30_account_auto_transfer
+#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form
+msgid "Automated Transfer"
+msgstr "التحويل التلقائي "
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0
+msgid "Automatic Transfer (%(percent)s%% from account %(origin_account)s)"
+msgstr "التحويل التلقائي (%(percent)s%% من الحساب %(origin_account)s) "
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0
+msgid "Automatic Transfer (-%s%%)"
+msgstr "التحويل التلقائي (-%s%%)"
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0
+msgid ""
+"Automatic Transfer (entries with analytic account(s): %(analytic_accounts)s "
+"and partner(s): %(partners)s)"
+msgstr ""
+"التحويل التلقائي (القيود التي بها حساب (حسابات) تحليلية: "
+"%(analytic_accounts)s والشريك (الشركاء): %(partners)s) "
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0
+msgid "Automatic Transfer (entries with analytic account(s): %s)"
+msgstr "التحويل التلقائي (القيود التي بها حساب (حسابات) تحليلية: %s)"
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0
+msgid "Automatic Transfer (entries with partner(s): %s)"
+msgstr "التحويل التلقائي (القيود التي بها شريك (شركاء): %s) "
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0
+msgid ""
+"Automatic Transfer (from account %(origin_account)s with analytic "
+"account(s): %(analytic_accounts)s and partner(s): %(partners)s)"
+msgstr ""
+"التحويل التلقائي (من الحساب %(origin_account)s الذي به حسابات تحليلية: "
+"%(analytic_accounts)s والشريك (الشركاء): %(partners)s) "
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0
+msgid ""
+"Automatic Transfer (from account %(origin_account)s with analytic "
+"account(s): %(analytic_accounts)s)"
+msgstr ""
+"التحويل التلقائي (من الحساب %(origin_account)s الذي به حسابات تحليلية: "
+"%(analytic_accounts)s) "
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0
+msgid ""
+"Automatic Transfer (from account %(origin_account)s with partner(s): "
+"%(partners)s)"
+msgstr ""
+"التحويل التلقائي (من الحساب %(origin_account)s الذي به الشركاء: "
+"%(partners)s) "
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0
+msgid "Automatic Transfer (to account %s)"
+msgstr "التحويل التلقائي (للحساب %s)"
+
+#. module: odex30_account_auto_transfer
+#: model:ir.actions.act_window,name:odex30_account_auto_transfer.transfer_model_action
+msgid "Automatic Transfers"
+msgstr "التحويلات التلقائية "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__company_id
+msgid "Company"
+msgstr "الشركة "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,help:odex30_account_auto_transfer.field_account_transfer_model__company_id
+msgid "Company related to this journal"
+msgstr "الشركة المتعلقة بهذه اليومية "
+
+#. module: odex30_account_auto_transfer
+#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form
+msgid "Compute Transfer"
+msgstr "حساب التحويل "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__create_uid
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__create_uid
+msgid "Created by"
+msgstr "أنشئ بواسطة"
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__create_date
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__create_date
+msgid "Created on"
+msgstr "أنشئ في"
+
+#. module: odex30_account_auto_transfer
+#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form
+msgid "Description"
+msgstr "الوصف"
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__account_id
+msgid "Destination Account"
+msgstr "حساب الوجهة"
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__line_ids
+msgid "Destination Accounts"
+msgstr "حسابات الوجهة "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__journal_id
+msgid "Destination Journal"
+msgstr "يومية الوجهة "
+
+#. module: odex30_account_auto_transfer
+#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form
+msgid "Disable"
+msgstr "تعطيل"
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields.selection,name:odex30_account_auto_transfer.selection__account_transfer_model__state__disabled
+msgid "Disabled"
+msgstr "معطل"
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__display_name
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__display_name
+msgid "Display Name"
+msgstr "اسم العرض "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__frequency
+msgid "Frequency"
+msgstr "معدل الحدوث "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.actions.act_window,name:odex30_account_auto_transfer.generated_transfers_action
+msgid "Generated Entries"
+msgstr "القيود المُنشأة "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__move_ids
+msgid "Generated Moves"
+msgstr "الحركات المُنشأة "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__id
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__id
+msgid "ID"
+msgstr "المُعرف"
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/demo/account_demo.py:0
+msgid "IFRS Automatic Transfers"
+msgstr "التحويلات التلقائية في IFRS "
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/demo/account_demo.py:0
+msgid "IFRS rent expense transfer"
+msgstr "تحويل نفقات الإيجار في IFRS "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model,name:odex30_account_auto_transfer.model_account_journal
+#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form
+msgid "Journal"
+msgstr "دفتر اليومية"
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model,name:odex30_account_auto_transfer.model_account_move
+msgid "Journal Entry"
+msgstr "قيد اليومية"
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model,name:odex30_account_auto_transfer.model_account_move_line
+msgid "Journal Item"
+msgstr "عنصر اليومية"
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__write_uid
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__write_uid
+msgid "Last Updated by"
+msgstr "آخر تحديث بواسطة"
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__write_date
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__write_date
+msgid "Last Updated on"
+msgstr "آخر تحديث في"
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields.selection,name:odex30_account_auto_transfer.selection__account_transfer_model__frequency__month
+msgid "Monthly"
+msgstr "شهرياً"
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__move_ids_count
+msgid "Move Ids Count"
+msgstr "تعداد مُعرفات الحركات "
+
+#. module: odex30_account_auto_transfer
+#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form
+msgid "Move Model"
+msgstr "نموذج الحركة "
+
+#. module: odex30_account_auto_transfer
+#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_tree
+msgid "Move Models"
+msgstr "نماذج الحركة "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__name
+msgid "Name"
+msgstr "الاسم"
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.constraint,message:odex30_account_auto_transfer.constraint_account_transfer_model_line_unique_account_by_transfer_model
+msgid "Only one account occurrence by transfer model"
+msgstr "يُسمح بوجود حساب واحد فقط لكل نموذج تحويل "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__account_ids
+#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form
+msgid "Origin Accounts"
+msgstr "الحسابات الأصلية "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_bank_statement_line__transfer_model_id
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_move__transfer_model_id
+msgid "Originating Model"
+msgstr "النموذج المُنشئ "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__partner_ids
+msgid "Partner Filter"
+msgstr "عامل تصفية الشريك "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__percent
+msgid "Percent"
+msgstr "بالمئة "
+
+#. module: odex30_account_auto_transfer
+#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form
+msgid "Percent (%)"
+msgstr "النسبة (%)"
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__percent_is_readonly
+msgid "Percent Is Readonly"
+msgstr "النسبة للقراءة فقط "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,help:odex30_account_auto_transfer.field_account_transfer_model_line__percent
+msgid ""
+"Percentage of the sum of lines from the origin accounts will be transferred "
+"to the destination account"
+msgstr ""
+"سوف يتم تحويل نسبة من مجموع البنود من الحسابات الأصلية إلى حساب الوجهة "
+
+#. module: odex30_account_auto_transfer
+#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form
+msgid "Period"
+msgstr "الفترة"
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields.selection,name:odex30_account_auto_transfer.selection__account_transfer_model__frequency__quarter
+msgid "Quarterly"
+msgstr "ربع سنوي"
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields.selection,name:odex30_account_auto_transfer.selection__account_transfer_model__state__in_progress
+msgid "Running"
+msgstr "جاري"
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__sequence
+msgid "Sequence"
+msgstr "تسلسل "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__date_start
+msgid "Start Date"
+msgstr "تاريخ البدء "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__state
+msgid "State"
+msgstr "الحالة "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__date_stop
+msgid "Stop Date"
+msgstr "تاريخ الإيقاف "
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0
+msgid "The analytic filter %s is duplicated"
+msgstr "تم إنشاء نسخة مطابقة من عامل التصفية التحليلي %s "
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0
+msgid ""
+"The partner filter %(partner_filter)s in combination with the analytic "
+"filter %(analytic_filter)s is duplicated"
+msgstr ""
+"تم إنشاء نسخة مطابقة لعامل تصفية الشريك %(partner_filter)s وجمعه مع الحساب "
+"التحليلي %(analytic_filter)s "
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0
+msgid "The partner filter %s is duplicated"
+msgstr "تم إنشاء نسخة مطابقة لعامل تصفية الشريك %s "
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0
+msgid "The total percentage (%s) should be less or equal to 100!"
+msgstr "يجب أن تكون النسبة الكلية (%s) أقل من أو تساوي 100! "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__total_percent
+msgid "Total Percent"
+msgstr "النسبة الكلية "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__transfer_model_id
+msgid "Transfer Model"
+msgstr "نموذج التحويل "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.ui.menu,name:odex30_account_auto_transfer.menu_auto_transfer
+#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form
+msgid "Transfers"
+msgstr "التحويلات "
+
+#. module: odex30_account_auto_transfer
+#: model:ir.model.fields.selection,name:odex30_account_auto_transfer.selection__account_transfer_model__frequency__year
+msgid "Yearly"
+msgstr "سنويًا"
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/demo/account_demo.py:0
+msgid "Yearly liabilites auto transfers"
+msgstr "التحويلات التلقائية للالتزامات السنوية "
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0
+msgid ""
+"You cannot delete an automatic transfer that has draft moves attached "
+"('%s'). Please delete them before deleting this transfer."
+msgstr ""
+"لا يمكنك حذف شحنة تلقائية بها حركات مرفقة بحالة المسودة ('%s')؛ يرجى حذفها "
+"قبل حذف هذه الشحنة. "
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0
+msgid ""
+"You cannot delete an automatic transfer that has posted moves attached "
+"('%s')."
+msgstr "لا يمكنك حذف شحنة تلقائية بها حركات مرفقة قد تم ترحيلها ('%s'). "
+
+#. module: odex30_account_auto_transfer
+#. odoo-python
+#: code:addons/odex30_account_auto_transfer/models/account_move_line.py:0
+msgid "You cannot set Tax on Automatic Transfer's entries."
+msgstr "لا يمكنك تعيين ضريبة في قيود التحويل التلقائي. "
+
+#. module: odex30_account_auto_transfer
+#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form
+msgid "e.g. Monthly Expense Transfer"
+msgstr "مثال: التحويل الشهري للنفقات "
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/__init__.py b/dev_odex30_accounting/odex30_account_auto_transfer/models/__init__.py
new file mode 100644
index 0000000..0664ea8
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/models/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+
+from . import account_journal
+from . import account_move
+from . import account_move_line
+from . import transfer_model
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..0f077a4
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/__init__.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/account_journal.cpython-311.pyc b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/account_journal.cpython-311.pyc
new file mode 100644
index 0000000..cbf4914
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/account_journal.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/account_move.cpython-311.pyc b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/account_move.cpython-311.pyc
new file mode 100644
index 0000000..eb76819
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/account_move.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/account_move_line.cpython-311.pyc b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/account_move_line.cpython-311.pyc
new file mode 100644
index 0000000..620c95e
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/account_move_line.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/transfer_model.cpython-311.pyc b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/transfer_model.cpython-311.pyc
new file mode 100644
index 0000000..2d583ba
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/transfer_model.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/account_journal.py b/dev_odex30_accounting/odex30_account_auto_transfer/models/account_journal.py
new file mode 100644
index 0000000..95f5402
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/models/account_journal.py
@@ -0,0 +1,11 @@
+from odoo import models, api
+from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
+
+
+class AccountJournal(models.Model):
+ _inherit = 'account.journal'
+
+ @api.ondelete(at_uninstall=True)
+ def _unlink_cascade_transfer_model(self):
+ if self.env.context.get(MODULE_UNINSTALL_FLAG): # only cascade when switching CoA
+ self.env['account.transfer.model'].search([('journal_id', 'in', self.ids)]).unlink()
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/account_move.py b/dev_odex30_accounting/odex30_account_auto_transfer/models/account_move.py
new file mode 100644
index 0000000..d4dd1e5
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/models/account_move.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+
+from odoo import fields, models
+
+
+class AccountMove(models.Model):
+ _inherit = 'account.move'
+
+ transfer_model_id = fields.Many2one('account.transfer.model', string="Originating Model")
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/account_move_line.py b/dev_odex30_accounting/odex30_account_auto_transfer/models/account_move_line.py
new file mode 100644
index 0000000..623c520
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/models/account_move_line.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, models, _
+from odoo.exceptions import UserError
+
+
+class AccountMoveLine(models.Model):
+ _inherit = 'account.move.line'
+
+ @api.constrains('tax_ids')
+ def _check_auto_transfer_line_ids_tax(self):
+ if any(line.move_id.transfer_model_id and line.tax_ids for line in self):
+ raise UserError(_("You cannot set Tax on Automatic Transfer's entries."))
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/transfer_model.py b/dev_odex30_accounting/odex30_account_auto_transfer/models/transfer_model.py
new file mode 100644
index 0000000..a4cb5f5
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/models/transfer_model.py
@@ -0,0 +1,531 @@
+# -*- coding: utf-8 -*-
+
+from datetime import date
+
+from dateutil.relativedelta import relativedelta
+
+from odoo import fields, models, api, _
+from odoo.exceptions import UserError, ValidationError
+from odoo.osv import expression
+from odoo.tools.float_utils import float_compare, float_is_zero
+
+
+class TransferModel(models.Model):
+ _name = "account.transfer.model"
+ _description = "Account Transfer Model"
+
+ # DEFAULTS
+ def _get_default_date_start(self):
+ company = self.env.company
+ return company.compute_fiscalyear_dates(date.today())['date_from'] if company else None
+
+ def _get_default_journal(self):
+ return self.env['account.journal'].search([
+ *self.env['account.journal']._check_company_domain(self.env.company),
+ ('type', '=', 'general'),
+ ], limit=1)
+
+ name = fields.Char(required=True)
+ active = fields.Boolean(default=True)
+ journal_id = fields.Many2one('account.journal', required=True, string="Destination Journal", default=_get_default_journal)
+ company_id = fields.Many2one('res.company', readonly=True, related='journal_id.company_id')
+ date_start = fields.Date(string="Start Date", required=True, default=_get_default_date_start)
+ date_stop = fields.Date(string="Stop Date", required=False)
+ frequency = fields.Selection([('month', 'Monthly'), ('quarter', 'Quarterly'), ('year', 'Yearly')],
+ required=True, default='month')
+ account_ids = fields.Many2many('account.account', 'account_model_rel', string="Origin Accounts", domain="[('account_type', '!=', 'off_balance')]")
+ line_ids = fields.One2many('account.transfer.model.line', 'transfer_model_id', string="Destination Accounts")
+ move_ids = fields.One2many('account.move', 'transfer_model_id', string="Generated Moves")
+ move_ids_count = fields.Integer(compute="_compute_move_ids_count")
+ total_percent = fields.Float(compute="_compute_total_percent", string="Total Percent", readonly=True)
+ state = fields.Selection([('disabled', 'Disabled'), ('in_progress', 'Running')], default='disabled', required=True)
+
+ def copy(self, default=None):
+ new_models = super().copy(default)
+ for old_model, new_model in zip(self, new_models):
+ new_model.account_ids += old_model.account_ids
+ old_model.line_ids.copy({'transfer_model_id': new_model.id})
+ return new_models
+
+ @api.ondelete(at_uninstall=False)
+ def _unlink_with_check_moves(self):
+ # Only unlink a transfer that has no posted/draft moves attached.
+ for transfer in self:
+ if transfer.move_ids_count > 0:
+ posted_moves = any(move.state == 'posted' for move in transfer.move_ids)
+ if posted_moves:
+ raise UserError(_("You cannot delete an automatic transfer that has posted moves attached ('%s').", transfer.name))
+ draft_moves = any(move.state == 'draft' for move in transfer.move_ids)
+ if draft_moves:
+ raise UserError(_("You cannot delete an automatic transfer that has draft moves attached ('%s'). "
+ "Please delete them before deleting this transfer.", transfer.name))
+
+ def action_archive(self):
+ self.action_disable()
+ return super().action_archive()
+
+ # COMPUTEDS / CONSTRAINS
+ @api.depends('move_ids')
+ def _compute_move_ids_count(self):
+ """ Compute the amount of move ids have been generated by this transfer model. """
+ for record in self:
+ record.move_ids_count = len(record.move_ids)
+
+ @api.constrains('line_ids')
+ def _check_line_ids_percent(self):
+ """ Check that the total percent is not bigger than 100.0 """
+ for record in self:
+ if not (0 < record.total_percent <= 100.0):
+ raise ValidationError(_('The total percentage (%s) should be less or equal to 100!', record.total_percent))
+
+ @api.constrains('line_ids')
+ def _check_line_ids_filters(self):
+ """ Check that the filters on the lines make sense """
+ for record in self:
+ combinations = []
+ for line in record.line_ids:
+ if line.partner_ids and line.analytic_account_ids:
+ for p in line.partner_ids:
+ for a in line.analytic_account_ids:
+ combination = (p.id, a.id)
+ if combination in combinations:
+ raise ValidationError(_(
+ "The partner filter %(partner_filter)s in combination with the analytic filter %(analytic_filter)s is duplicated",
+ partner_filter=p.display_name, analytic_filter=a.display_name,
+ ))
+ combinations.append(combination)
+ elif line.partner_ids:
+ for p in line.partner_ids:
+ combination = (p.id, None)
+ if combination in combinations:
+ raise ValidationError(_("The partner filter %s is duplicated", p.display_name))
+ combinations.append(combination)
+ elif line.analytic_account_ids:
+ for a in line.analytic_account_ids:
+ combination = (None, a.id)
+ if combination in combinations:
+ raise ValidationError(_("The analytic filter %s is duplicated", a.display_name))
+ combinations.append(combination)
+
+ @api.depends('line_ids')
+ def _compute_total_percent(self):
+ """ Compute the total percentage of all lines linked to this model. """
+ for record in self:
+ non_filtered_lines = record.line_ids.filtered(lambda l: not l.partner_ids and not l.analytic_account_ids)
+ if record.line_ids and not non_filtered_lines:
+ # Lines are only composed of filtered ones thus percentage does not matter, make it 100
+ record.total_percent = 100.0
+ else:
+ total_percent = sum(non_filtered_lines.mapped('percent'))
+ if float_compare(total_percent, 100.0, precision_digits=6) == 0:
+ total_percent = 100.0
+ record.total_percent = total_percent
+
+ # ACTIONS
+ def action_activate(self):
+ """ Put this move model in "in progress" state. """
+ return self.write({'state': 'in_progress'})
+
+ def action_disable(self):
+ """ Put this move model in "disabled" state. """
+ return self.write({'state': 'disabled'})
+
+ @api.model
+ def action_cron_auto_transfer(self):
+ """ Perform the automatic transfer for the all active move models. """
+ self.search([('state', '=', 'in_progress')]).action_perform_auto_transfer()
+
+ def action_perform_auto_transfer(self):
+ """ Perform the automatic transfer for the current recordset of models """
+ for record in self:
+ # If no account to ventilate or no account to ventilate into : nothing to do
+ if record.account_ids and record.line_ids:
+ today = date.today()
+ max_date = record.date_stop and min(today, record.date_stop) or today
+ start_date = record._determine_start_date()
+ next_move_date = record._get_next_move_date(start_date)
+
+ # (Re)Generate moves in draft untill today
+ # Journal entries will be recomputed everyday until posted.
+ while next_move_date <= max_date:
+ record._create_or_update_move_for_period(start_date, next_move_date)
+ start_date = next_move_date + relativedelta(days=1)
+ next_move_date = record._get_next_move_date(start_date)
+
+ # (Re)Generate move for one more period if needed
+ if not record.date_stop:
+ record._create_or_update_move_for_period(start_date, next_move_date)
+ elif today < record.date_stop:
+ record._create_or_update_move_for_period(start_date, min(next_move_date, record.date_stop))
+ return False
+
+ def _get_move_lines_base_domain(self, start_date, end_date):
+ """
+ Determine the domain to get all account move lines posted in a given period, for an account in origin accounts
+ :param start_date: the start date of the period
+ :param end_date: the end date of the period
+ :return: the computed domain
+ :rtype: list
+ """
+ self.ensure_one()
+ return [
+ ('account_id', 'in', self.account_ids.ids),
+ ('date', '>=', start_date),
+ ('date', '<=', end_date),
+ ('parent_state', '=', 'posted')
+ ]
+
+ # PROTECTEDS
+
+ def _create_or_update_move_for_period(self, start_date, end_date):
+ """
+ Create or update a move for a given period. This means (re)generates all the needed moves to execute the
+ transfers
+ :param start_date: the start date of the targeted period
+ :param end_date: the end date of the targeted period
+ :return: the created (or updated) move
+ """
+ self.ensure_one()
+ current_move = self._get_move_for_period(end_date)
+ line_values = self._get_auto_transfer_move_line_values(start_date, end_date)
+ if line_values:
+ if current_move is None:
+ current_move = self.env['account.move'].create({
+ 'ref': '%s: %s --> %s' % (self.name, str(start_date), str(end_date)),
+ 'date': end_date,
+ 'journal_id': self.journal_id.id,
+ 'transfer_model_id': self.id,
+ })
+
+ line_ids_values = [(0, 0, value) for value in line_values]
+ # unlink all old line ids
+ current_move.line_ids.unlink()
+ # recreate line ids
+ current_move.write({'line_ids': line_ids_values})
+ return current_move
+
+ def _get_move_for_period(self, end_date):
+ """ Get the generated move for a given period
+ :param end_date: the end date of the wished period, do not need the start date as the move will always be
+ generated with end date of a period as date
+ :return: a recordset containing the move found if any, else None
+ """
+ self.ensure_one()
+ # Move will always be generated with end_date of a period as date
+ domain = [
+ ('date', '=', end_date),
+ ('state', '=', 'draft'),
+ ('transfer_model_id', '=', self.id)
+ ]
+ current_moves = self.env['account.move'].search(domain, limit=1, order="date desc")
+ return current_moves[0] if current_moves else None
+
+ def _determine_start_date(self):
+ """ Determine the automatic transfer start date which is the last created move if any or the start date of the model """
+ self.ensure_one()
+ # Get last generated move date if any (to know when to start)
+ last_move_domain = [('transfer_model_id', '=', self.id), ('state', '=', 'posted'), ('company_id', '=', self.company_id.id)]
+ move_ids = self.env['account.move'].search(last_move_domain, order='date desc', limit=1)
+ return (move_ids[0].date + relativedelta(days=1)) if move_ids else self.date_start
+
+ def _get_next_move_date(self, date):
+ """ Compute the following date of automated transfer move, based on a date and the frequency """
+ self.ensure_one()
+ if self.frequency == 'month':
+ delta = relativedelta(months=1)
+ elif self.frequency == 'quarter':
+ delta = relativedelta(months=3)
+ else:
+ delta = relativedelta(years=1)
+ return date + delta - relativedelta(days=1)
+
+ def _get_auto_transfer_move_line_values(self, start_date, end_date):
+ """ Get all the transfer move lines values for a given period
+ :param start_date: the start date of the period
+ :param end_date: the end date of the period
+ :return: a list of dict representing the values of lines to create
+ :rtype: list
+ """
+ self.ensure_one()
+ values = []
+ # Get the balance of all moves from all selected accounts, grouped by accounts
+ filtered_lines = self.line_ids.filtered(lambda x: x.analytic_account_ids or x.partner_ids)
+ if filtered_lines:
+ values += filtered_lines._get_transfer_move_lines_values(start_date, end_date)
+
+ non_filtered_lines = self.line_ids - filtered_lines
+ if non_filtered_lines:
+ values += self._get_non_filtered_auto_transfer_move_line_values(non_filtered_lines, start_date, end_date)
+
+ return values
+
+ def _get_non_filtered_auto_transfer_move_line_values(self, lines, start_date, end_date):
+ """
+ Get all values to create move lines corresponding to the transfers needed by all lines without analytic
+ account or partner for a given period. It contains the move lines concerning destination accounts and
+ the ones concerning the origin accounts. This process all the origin accounts one after one.
+ :param lines: the move model lines to handle
+ :param start_date: the start date of the period
+ :param end_date: the end date of the period
+ :return: a list of dict representing the values to use to create the needed move lines
+ :rtype: list
+ """
+ self.ensure_one()
+ domain = expression.AND([
+ self._get_move_lines_base_domain(start_date, end_date),
+ [('partner_id', 'not in', self.line_ids.partner_ids.ids)],
+ [('analytic_distribution', 'not in', self.line_ids.analytic_account_ids.ids)],
+ ])
+ total_balance_account = self.env['account.move.line']._read_group(
+ domain,
+ ['account_id'],
+ ['balance:sum'],
+ )
+ # balance = debit - credit
+ # --> balance > 0 means a debit so it should be credited on the source account
+ # --> balance < 0 means a credit so it should be debited on the source account
+ values_list = []
+ for account, balance in total_balance_account:
+ initial_amount = abs(balance)
+ source_account_is_debit = balance >= 0
+ if not float_is_zero(initial_amount, precision_digits=9):
+ move_lines_values, amount_left = self._get_non_analytic_transfer_values(account, lines, end_date,
+ initial_amount,
+ source_account_is_debit)
+
+ # the line which credit/debit the source account
+ substracted_amount = initial_amount - amount_left
+ source_move_line = {
+ 'name': _('Automatic Transfer (-%s%%)', self.total_percent),
+ 'account_id': account.id,
+ 'date_maturity': end_date,
+ 'credit' if source_account_is_debit else 'debit': substracted_amount
+ }
+ values_list += move_lines_values
+ values_list.append(source_move_line)
+ return values_list
+
+ def _get_non_analytic_transfer_values(self, account, lines, write_date, amount, is_debit):
+ """
+ Get all values to create destination account move lines corresponding to the transfers needed by all lines
+ without analytic account for a given account.
+ :param account: the origin account to handle
+ :param write_date: the write date of the move lines
+ :param amount: the total amount to take care on the origin account
+ :type amount: float
+ :param is_debit: True if origin account has a debit balance, False if it's a credit
+ :type is_debit: bool
+ :return: a tuple containing the move lines values in a list and the amount left on the origin account after
+ processing as a float
+ :rtype: tuple
+ """
+ # if total ventilated is 100%
+ # then the last line should not compute in % but take the rest
+ # else
+ # it should compute in % (as the rest will stay on the source account)
+ self.ensure_one()
+ amount_left = amount
+
+ take_the_rest = self.total_percent == 100.0
+ amount_of_lines = len(lines)
+ values_list = []
+
+ for i, line in enumerate(lines):
+ if take_the_rest and i == amount_of_lines - 1:
+ line_amount = amount_left
+ amount_left = 0
+ else:
+ currency = self.journal_id.currency_id or self.company_id.currency_id
+ line_amount = currency.round((line.percent / 100.0) * amount)
+ amount_left -= line_amount
+
+ move_line = line._get_destination_account_transfer_move_line_values(account, line_amount, is_debit,
+ write_date)
+ values_list.append(move_line)
+
+ return values_list, amount_left
+
+
+class TransferModelLine(models.Model):
+ _name = "account.transfer.model.line"
+ _description = "Account Transfer Model Line"
+ _order = "sequence, id"
+
+ transfer_model_id = fields.Many2one('account.transfer.model', string="Transfer Model", required=True, ondelete='cascade')
+ account_id = fields.Many2one('account.account', string="Destination Account", required=True,
+ domain="[('account_type', '!=', 'off_balance')]")
+ percent = fields.Float(string="Percent", required=True, default=100, help="Percentage of the sum of lines from the origin accounts will be transferred to the destination account")
+ analytic_account_ids = fields.Many2many('account.analytic.account', string='Analytic Filter', help="Adds a condition to only transfer the sum of the lines from the origin accounts that match these analytic accounts to the destination account")
+ partner_ids = fields.Many2many('res.partner', string='Partner Filter', help="Adds a condition to only transfer the sum of the lines from the origin accounts that match these partners to the destination account")
+ percent_is_readonly = fields.Boolean(compute="_compute_percent_is_readonly")
+ sequence = fields.Integer("Sequence")
+
+ _sql_constraints = [
+ (
+ 'unique_account_by_transfer_model', 'UNIQUE(transfer_model_id, account_id)',
+ 'Only one account occurrence by transfer model')
+ ]
+
+ @api.onchange('analytic_account_ids', 'partner_ids')
+ def set_percent_if_analytic_account_ids(self):
+ """
+ Set percent to 100 if at least analytic account id is set.
+ """
+ for record in self:
+ if record.analytic_account_ids or record.partner_ids:
+ record.percent = 100
+
+ def _get_transfer_move_lines_values(self, start_date, end_date):
+ """
+ Get values to create the move lines to perform all needed transfers between accounts linked to current recordset
+ for a given period
+ :param start_date: the start date of the targeted period
+ :param end_date: the end date of the targeted period
+ :return: a list containing all the values needed to create the needed transfers
+ :rtype: list
+ """
+ transfer_values = []
+ # Avoid to transfer two times the same entry
+ already_handled_move_line_ids = []
+ for transfer_model_line in self:
+ domain = transfer_model_line._get_move_lines_domain(start_date, end_date, already_handled_move_line_ids)
+
+ if transfer_model_line.analytic_account_ids:
+ domain = expression.AND([
+ domain,
+ [('analytic_distribution', 'in', transfer_model_line.analytic_account_ids.ids)],
+ ])
+
+ total_balances = self.env['account.move.line']._read_group(
+ domain,
+ ['account_id'],
+ ['id:array_agg', 'balance:sum'],
+ )
+ for account, ids, balance in total_balances:
+ already_handled_move_line_ids += ids
+ if not float_is_zero(balance, precision_digits=9):
+ amount = abs(balance)
+ source_account_is_debit = balance > 0
+ transfer_values += transfer_model_line._get_transfer_values(account, amount, source_account_is_debit,
+ end_date)
+ return transfer_values
+
+ def _get_move_lines_domain(self, start_date, end_date, avoid_move_line_ids=None):
+ """
+ Determine the domain to get all account move lines posted in a given period corresponding to self move model
+ line.
+ :param start_date: the start date of the targeted period
+ :param end_date: the end date of the targeted period
+ :param avoid_move_line_ids: the account.move.line ids that should be excluded from the domain
+ :return: the computed domain
+ :rtype: list
+ """
+ self.ensure_one()
+ move_lines_domain = self.transfer_model_id._get_move_lines_base_domain(start_date, end_date)
+ if avoid_move_line_ids:
+ move_lines_domain.append(('id', 'not in', avoid_move_line_ids))
+ if self.partner_ids:
+ move_lines_domain.append(('partner_id', 'in', self.partner_ids.ids))
+ return move_lines_domain
+
+ def _get_transfer_values(self, account, amount, is_debit, write_date):
+ """
+ Get values to create the move lines to perform a transfer between self account and given account
+ :param account: the account
+ :param amount: the amount that is being transferred
+ :type amount: float
+ :param is_debit: True if the transferred amount is a debit, False if credit
+ :type is_debit: bool
+ :param write_date: the date to use for the move line writing
+ :return: a list containing the values to create the needed move lines
+ :rtype: list
+ """
+ self.ensure_one()
+ return [
+ self._get_destination_account_transfer_move_line_values(account, amount, is_debit, write_date),
+ self._get_origin_account_transfer_move_line_values(account, amount, is_debit, write_date)
+ ]
+
+ def _get_origin_account_transfer_move_line_values(self, origin_account, amount, is_debit,
+ write_date):
+ """
+ Get values to create the move line in the origin account side for a given transfer of a given amount from origin
+ account to a given destination account.
+ :param origin_account: the origin account
+ :param amount: the amount that is being transferred
+ :type amount: float
+ :param is_debit: True if the transferred amount is a debit, False if credit
+ :type is_debit: bool
+ :param write_date: the date to use for the move line writing
+ :return: a dict containing the values to create the move line
+ :rtype: dict
+ """
+ anal_accounts = self.analytic_account_ids and ', '.join(self.analytic_account_ids.mapped('name'))
+ partners = self.partner_ids and ', '.join(self.partner_ids.mapped('name'))
+ if anal_accounts and partners:
+ name = _("Automatic Transfer (entries with analytic account(s): %(analytic_accounts)s and partner(s): %(partners)s)", analytic_accounts=anal_accounts, partners=partners)
+ elif anal_accounts:
+ name = _("Automatic Transfer (entries with analytic account(s): %s)", anal_accounts)
+ elif partners:
+ name = _("Automatic Transfer (entries with partner(s): %s)", partners)
+ else:
+ name = _("Automatic Transfer (to account %s)", self.account_id.code)
+ return {
+ 'name': name,
+ 'account_id': origin_account.id,
+ 'date_maturity': write_date,
+ 'credit' if is_debit else 'debit': amount
+ }
+
+ def _get_destination_account_transfer_move_line_values(self, origin_account, amount, is_debit,
+ write_date):
+ """
+ Get values to create the move line in the destination account side for a given transfer of a given amount from
+ given origin account to destination account.
+ :param origin_account: the origin account
+ :param amount: the amount that is being transferred
+ :type amount: float
+ :param is_debit: True if the transferred amount is a debit, False if credit
+ :type is_debit: bool
+ :param write_date: the date to use for the move line writing
+ :return: a dict containing the values to create the move line
+ :rtype dict:
+ """
+ anal_accounts = self.analytic_account_ids and ', '.join(self.analytic_account_ids.mapped('name'))
+ partners = self.partner_ids and ', '.join(self.partner_ids.mapped('name'))
+ if anal_accounts and partners:
+ name = _(
+ "Automatic Transfer (from account %(origin_account)s with analytic account(s): %(analytic_accounts)s and partner(s): %(partners)s)",
+ origin_account=origin_account.code,
+ analytic_accounts=anal_accounts,
+ partners=partners,
+ )
+ elif anal_accounts:
+ name = _(
+ "Automatic Transfer (from account %(origin_account)s with analytic account(s): %(analytic_accounts)s)",
+ origin_account=origin_account.code,
+ analytic_accounts=anal_accounts
+ )
+ elif partners:
+ name = _(
+ "Automatic Transfer (from account %(origin_account)s with partner(s): %(partners)s)",
+ origin_account=origin_account.code,
+ partners=partners,
+ )
+ else:
+ name = _(
+ "Automatic Transfer (%(percent)s%% from account %(origin_account)s)",
+ percent=self.percent,
+ origin_account=origin_account.code,
+ )
+ return {
+ 'name': name,
+ 'account_id': self.account_id.id,
+ 'date_maturity': write_date,
+ 'debit' if is_debit else 'credit': amount
+ }
+
+ @api.depends('analytic_account_ids', 'partner_ids')
+ def _compute_percent_is_readonly(self):
+ for record in self:
+ record.percent_is_readonly = record.analytic_account_ids or record.partner_ids
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/security/account_auto_transfer_security.xml b/dev_odex30_accounting/odex30_account_auto_transfer/security/account_auto_transfer_security.xml
new file mode 100644
index 0000000..21ec454
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/security/account_auto_transfer_security.xml
@@ -0,0 +1,11 @@
+
+
+
+
+ Account Automatic Transfer
+
+
+ [('company_id', 'in', company_ids)]
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/security/ir.model.access.csv b/dev_odex30_accounting/odex30_account_auto_transfer/security/ir.model.access.csv
new file mode 100644
index 0000000..f214369
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/security/ir.model.access.csv
@@ -0,0 +1,7 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_account_transfer_model,access_account_transfer_model,odex30_account_auto_transfer.model_account_transfer_model,account.group_account_readonly,1,0,0,0
+access_account_transfer_model_manager,access_account_transfer_model_manager,odex30_account_auto_transfer.model_account_transfer_model,account.group_account_manager,1,1,1,1
+access_account_transfer_model_invoicing_payment,access_account_transfer_model_invoicing_payment,odex30_account_auto_transfer.model_account_transfer_model,account.group_account_invoice,1,0,1,0
+access_account_transfer_model_line,access_account_transfer_model_line,odex30_account_auto_transfer.model_account_transfer_model_line,account.group_account_readonly,1,0,0,0
+access_account_transfer_model_line_manager,access_account_transfer_model_line_manager,odex30_account_auto_transfer.model_account_transfer_model_line,account.group_account_manager,1,1,1,1
+access_account_transfer_model_line_invoicing_payment,access_account_transfer_model_line_invoicing_payment,odex30_account_auto_transfer.model_account_transfer_model_line,account.group_account_invoice,1,0,1,0
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/static/description/icon.png b/dev_odex30_accounting/odex30_account_auto_transfer/static/description/icon.png
new file mode 100644
index 0000000..a058ecb
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_auto_transfer/static/description/icon.png differ
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/static/description/icon.svg b/dev_odex30_accounting/odex30_account_auto_transfer/static/description/icon.svg
new file mode 100644
index 0000000..92b815f
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/static/description/icon.svg
@@ -0,0 +1 @@
+
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/tests/__init__.py b/dev_odex30_accounting/odex30_account_auto_transfer/tests/__init__.py
new file mode 100644
index 0000000..7bb7698
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/tests/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+from . import test_transfer_model
+from . import test_transfer_model_line
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/tests/account_auto_transfer_test_classes.py b/dev_odex30_accounting/odex30_account_auto_transfer/tests/account_auto_transfer_test_classes.py
new file mode 100644
index 0000000..c6d94fd
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/tests/account_auto_transfer_test_classes.py
@@ -0,0 +1,99 @@
+# -*- coding: utf-8 -*-
+from datetime import datetime
+from uuid import uuid4
+from odoo.tests import common
+
+from odoo.addons.account.tests.common import AccountTestInvoicingCommon
+
+
+class AccountAutoTransferTestCase(AccountTestInvoicingCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.journal = cls.env['account.journal'].create({'type': 'bank', 'name': 'bank', 'code': 'BANK'})
+ cls.transfer_model = cls.env['account.transfer.model'].create({
+ 'name': 'Test Transfer',
+ 'date_start': '2019-06-01',
+ 'frequency': 'month',
+ 'journal_id': cls.journal.id
+ })
+ cls.analytic_plan = cls.env['account.analytic.plan'].create({
+ 'name': 'A',
+ })
+
+ cls.master_account_index = 0
+ cls.slave_account_index = 1
+ cls.origin_accounts, cls.destination_accounts = cls._create_accounts(cls)
+
+ def _assign_origin_accounts(self):
+ self.transfer_model.write({
+ 'account_ids': [(6, 0, self.origin_accounts.ids)]
+ })
+
+ def _create_accounts(self, amount_of_master_accounts=2, amount_of_slave_accounts=4):
+ master_ids = self.env['account.account']
+
+ for i in range(amount_of_master_accounts):
+ self.master_account_index += 1
+ master_ids += self.env['account.account'].create({
+ 'name': 'MASTER %s' % self.master_account_index,
+ 'code': 'MA00%s' % self.master_account_index,
+ 'account_type': 'asset_receivable',
+ 'reconcile': True
+ })
+
+ slave_ids = self.env['account.account']
+ for i in range(amount_of_slave_accounts):
+ self.slave_account_index += 1
+ slave_ids += self.env['account.account'].create({
+ 'name': 'SLAVE %s' % self.slave_account_index,
+ 'code': 'SL000%s' % self.slave_account_index,
+ 'account_type': 'asset_receivable',
+ 'reconcile': True
+ })
+ return master_ids, slave_ids
+
+ def _create_analytic_account(self, code='ANAL01'):
+ return self.env['account.analytic.account'].create({'name': code, 'code': code, 'plan_id': self.analytic_plan.id})
+
+ def _create_partner(self, name="partner01"):
+ return self.env['res.partner'].create({'name': name})
+
+ def _create_basic_move(self, cred_account=None, deb_account=None, amount=0, date_str='2019-02-01',
+ partner_id=False, name=False, cred_analytic=False, deb_analytic=False,
+ transfer_model_id=False, journal_id=False, posted=True):
+ move_vals = {
+ 'date': date_str,
+ 'transfer_model_id': transfer_model_id,
+ 'line_ids': [
+ (0, 0, {
+ 'account_id': cred_account or self.origin_accounts[0].id,
+ 'credit': amount,
+ 'analytic_distribution': {cred_analytic: 100} if cred_analytic else {},
+ 'partner_id': partner_id,
+ }),
+ (0, 0, {
+ 'account_id': deb_account or self.origin_accounts[1].id,
+ 'analytic_distribution': {deb_analytic: 100} if deb_analytic else {},
+ 'debit': amount,
+ 'partner_id': partner_id,
+ }),
+ ]
+ }
+ if journal_id:
+ move_vals['journal_id'] = journal_id
+ move = self.env['account.move'].create(move_vals)
+ if posted:
+ move.action_post()
+ return move
+
+ def _add_transfer_model_line(self, account_id: int = False, percent: float = 100.0, analytic_account_ids: list = False, partner_ids: list = False):
+ account_id = account_id or self.destination_accounts[0].id
+ return self.env['account.transfer.model.line'].create({
+ 'percent': percent,
+ 'account_id': account_id,
+ 'transfer_model_id': self.transfer_model.id,
+ 'analytic_account_ids': analytic_account_ids and [(4, aa) for aa in analytic_account_ids],
+ 'partner_ids': partner_ids and [(4, p) for p in partner_ids],
+ })
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/tests/test_transfer_model.py b/dev_odex30_accounting/odex30_account_auto_transfer/tests/test_transfer_model.py
new file mode 100644
index 0000000..69645bc
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/tests/test_transfer_model.py
@@ -0,0 +1,537 @@
+# -*- coding: utf-8 -*-
+from datetime import datetime, timedelta
+from unittest.mock import patch, call
+from functools import reduce
+from itertools import chain
+from freezegun import freeze_time
+
+from dateutil.relativedelta import relativedelta
+from odoo.addons.odex30_account_auto_transfer.tests.account_auto_transfer_test_classes import AccountAutoTransferTestCase
+
+from odoo import Command, fields
+from odoo.models import UserError, ValidationError
+from odoo.tests import tagged
+
+# ############################################################################ #
+# FUNCTIONAL TESTS #
+# ############################################################################ #
+@tagged('post_install', '-at_install')
+class TransferModelTestFunctionalCase(AccountAutoTransferTestCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ # Model with 4 lines of 20%, 20% is left in origin accounts
+ cls.functional_transfer = cls.env['account.transfer.model'].create({
+ 'name': 'Test Functional Model',
+ 'date_start': '2019-01-01',
+ 'date_stop': datetime.today() + relativedelta(months=1),
+ 'journal_id': cls.journal.id,
+ 'account_ids': [(6, 0, cls.origin_accounts.ids)],
+ 'line_ids': [(0, 0, {
+ 'account_id': account.id,
+ 'percent': 20,
+ }) for account in cls.destination_accounts],
+ })
+ neutral_account = cls.env['account.account'].create({
+ 'name': 'Neutral Account',
+ 'code': 'NEUT',
+ 'account_type': 'income',
+ })
+ cls.analytic_accounts = reduce(lambda x, y: x + y, (cls._create_analytic_account(cls, name) for name in ('ANA1', 'ANA2', 'ANA3')))
+ cls.dates = ('2019-01-15', '2019-02-15')
+ # Create one line for each date...
+ for date in cls.dates:
+ # ...with each analytic account, and with no analytic account...
+ for an_account in chain(cls.analytic_accounts, [cls.env['account.analytic.account']]):
+ # ...in each origin account with a balance of 1000.
+ for account in cls.origin_accounts:
+ cls._create_basic_move(
+ cls,
+ deb_account=account.id,
+ deb_analytic=an_account.id,
+ cred_account=neutral_account.id,
+ amount=1000,
+ date_str=date,
+ )
+
+ def test_no_analytics(self):
+ # Balance is +8000 in each origin account
+ # 80% is transfered in 4 destination accounts in equal proprotions
+ self.functional_transfer.action_perform_auto_transfer()
+ # 1600 is left in each origin account
+ for account in self.origin_accounts:
+ self.assertEqual(sum(self.env['account.move.line'].search([('account_id', '=', account.id)]).mapped('balance')), 1600)
+ # 3200 has been transfered in each destination account
+ for account in self.destination_accounts:
+ self.assertEqual(sum(self.env['account.move.line'].search([('account_id', '=', account.id)]).mapped('balance')), 3200)
+ for date in self.dates:
+ # 2 move lines have been created in each account for each date
+ self.assertEqual(len(self.env['account.move.line'].search([('account_id', '=', account.id), ('date', '=', fields.Date.to_date(date) + relativedelta(day=31))])), 2)
+
+ def test_analytics(self):
+ # Each line with analytic accounts is set to 100%
+ self.functional_transfer.line_ids[0].analytic_account_ids = self.analytic_accounts[0:2]
+ self.functional_transfer.line_ids[1].analytic_account_ids = self.analytic_accounts[2]
+
+ self.functional_transfer.action_perform_auto_transfer()
+ # 1200 is left in each origin account (60% of 2 lines)
+ for account in self.origin_accounts:
+ self.assertEqual(sum(self.env['account.move.line'].search([('account_id', '=', account.id)]).mapped('balance')), 1200)
+ # 8000 has been transfered the first destination account (100% of 8 lines)
+ self.assertEqual(sum(self.env['account.move.line'].search([('account_id', '=', self.destination_accounts[0].id)]).mapped('balance')), 8000)
+ # 4000 has been transfered the first destination account (100% of 4 lines)
+ self.assertEqual(sum(self.env['account.move.line'].search([('account_id', '=', self.destination_accounts[1].id)]).mapped('balance')), 4000)
+ # 800 has been transfered in each of the last two destination account (20% of 4 lines)
+ self.assertEqual(sum(self.env['account.move.line'].search([('account_id', '=', self.destination_accounts[2].id)]).mapped('balance')), 800)
+ self.assertEqual(sum(self.env['account.move.line'].search([('account_id', '=', self.destination_accounts[3].id)]).mapped('balance')), 800)
+
+
+# ############################################################################ #
+# UNIT TESTS #
+# ############################################################################ #
+@tagged('post_install', '-at_install')
+class TransferModelTestCase(AccountAutoTransferTestCase):
+ @patch('odoo.addons.odex30_account_auto_transfer.models.transfer_model.TransferModel.action_perform_auto_transfer')
+ def test_action_cron_auto_transfer(self, patched):
+ TransferModel = self.env['account.transfer.model']
+ TransferModel.create({
+ 'name': 'Test Cron Model',
+ 'date_start': '2019-01-01',
+ 'date_stop': datetime.today() + relativedelta(months=1),
+ 'journal_id': self.journal.id
+ })
+ TransferModel.action_cron_auto_transfer()
+ patched.assert_called_once()
+
+ @patch('odoo.addons.odex30_account_auto_transfer.models.transfer_model.TransferModel._create_or_update_move_for_period')
+ @freeze_time('2022-01-01')
+ def test_action_perform_auto_transfer(self, patched):
+ self.transfer_model.date_start = datetime.strftime(datetime.today() + relativedelta(day=1), "%Y-%m-%d")
+ # - CASE 1 : normal case, acting on current period
+ self.transfer_model.action_perform_auto_transfer()
+ patched.assert_not_called() # create_or_update method should not be called for self.transfer_model as no account_ids and no line_ids
+
+ master_ids, slave_ids = self._create_accounts(1, 2)
+ self.transfer_model.write({'account_ids': [(6, 0, [master_ids.id])]})
+
+ self.transfer_model.action_perform_auto_transfer()
+ patched.assert_not_called() # create_or_update method should not be called for self.transfer_model as no line_ids
+
+ self.transfer_model.write({'line_ids': [
+ (0, 0, {
+ 'percent': 50.0,
+ 'account_id': slave_ids[0].id
+ }),
+ (0, 0, {
+ 'percent': 50.0,
+ 'account_id': slave_ids[1].id
+ })
+ ]})
+
+ self.transfer_model.action_perform_auto_transfer()
+ patched.assert_called_once() # create_or_update method should be called for self.transfer_model
+
+ # - CASE 2 : "old" case, acting on everything before now as nothing has been done yet
+ transfer_model = self.transfer_model.copy()
+ transfer_model.write({
+ 'date_start': transfer_model.date_start + relativedelta(months=-12)
+ })
+ initial_call_count = patched.call_count
+ transfer_model.action_perform_auto_transfer()
+ self.assertEqual(initial_call_count + 13, patched.call_count, '13 more calls should have been done')
+
+ @patch('odoo.addons.odex30_account_auto_transfer.models.transfer_model.TransferModel._get_auto_transfer_move_line_values')
+ def test__create_or_update_move_for_period(self, patched_get_auto_transfer_move_line_values):
+ # PREPARATION
+ master_ids, slave_ids = self._create_accounts(2, 0)
+ next_move_date = self.transfer_model._get_next_move_date(self.transfer_model.date_start)
+ patched_get_auto_transfer_move_line_values.return_value = [
+ {
+ 'account_id': master_ids[0].id,
+ 'date_maturity': next_move_date,
+ 'credit': 250.0,
+ },
+ {
+ 'account_id': master_ids[1].id,
+ 'date_maturity': next_move_date,
+ 'debit': 250.0,
+ }
+ ]
+
+ # There is no existing move, this is a brand new one
+ created_move = self.transfer_model._create_or_update_move_for_period(self.transfer_model.date_start, next_move_date)
+ self.assertEqual(len(created_move.line_ids), 2)
+ self.assertRecordValues(created_move, [{
+ 'date': next_move_date,
+ 'journal_id': self.transfer_model.journal_id.id,
+ 'transfer_model_id': self.transfer_model.id,
+ }])
+ self.assertRecordValues(created_move.line_ids.filtered(lambda l: l.credit), [{
+ 'account_id': master_ids[0].id,
+ 'date_maturity': next_move_date,
+ 'credit': 250.0,
+ }])
+ self.assertRecordValues(created_move.line_ids.filtered(lambda l: l.debit), [{
+ 'account_id': master_ids[1].id,
+ 'date_maturity': next_move_date,
+ 'debit': 250.0,
+ }])
+
+ patched_get_auto_transfer_move_line_values.return_value = [
+ {
+ 'account_id': master_ids[0].id,
+ 'date_maturity': next_move_date,
+ 'credit': 78520.0,
+ },
+ {
+ 'account_id': master_ids[1].id,
+ 'date_maturity': next_move_date,
+ 'debit': 78520.0,
+ }
+ ]
+
+ # Update the existing move but don't create a new one
+ amount_of_moves = self.env['account.move'].search_count([])
+ amount_of_move_lines = self.env['account.move.line'].search_count([])
+ updated_move = self.transfer_model._create_or_update_move_for_period(self.transfer_model.date_start, next_move_date)
+ self.assertEqual(amount_of_moves, self.env['account.move'].search_count([]), 'No move have been created')
+ self.assertEqual(amount_of_move_lines, self.env['account.move.line'].search_count([]),
+ 'No move line have been created (in fact yes but the old ones have been deleted)')
+ self.assertEqual(updated_move, created_move, 'Existing move has been updated')
+ self.assertRecordValues(updated_move.line_ids.filtered(lambda l: l.credit), [{
+ 'account_id': master_ids[0].id,
+ 'date_maturity': next_move_date,
+ 'credit': 78520.0,
+ }])
+ self.assertRecordValues(updated_move.line_ids.filtered(lambda l: l.debit), [{
+ 'account_id': master_ids[1].id,
+ 'date_maturity': next_move_date,
+ 'debit': 78520.0,
+ }])
+
+ def test__get_move_for_period(self):
+ # 2019-06-30 --> None as no move generated
+ date_to_test = datetime.strptime('2019-06-30', '%Y-%m-%d').date()
+ move_for_period = self.transfer_model._get_move_for_period(date_to_test)
+ self.assertIsNone(move_for_period, 'No move is generated yet')
+
+ # Generate a move
+ move_date = self.transfer_model._get_next_move_date(self.transfer_model.date_start)
+ already_generated_move = self.env['account.move'].create({
+ 'date': move_date,
+ 'journal_id': self.journal.id,
+ 'transfer_model_id': self.transfer_model.id
+ })
+ # 2019-06-30 --> None as generated move is generated for 01/07
+ move_for_period = self.transfer_model._get_move_for_period(date_to_test)
+ self.assertEqual(move_for_period, already_generated_move, 'Should be equal to the already generated move')
+
+ # 2019-07-01 --> The generated move
+ date_to_test += relativedelta(days=1)
+ move_for_period = self.transfer_model._get_move_for_period(date_to_test)
+ self.assertIsNone(move_for_period, 'The generated move is for the next period')
+
+ # 2019-07-02 --> None as generated move is generated for 01/07
+ date_to_test += relativedelta(days=1)
+ move_for_period = self.transfer_model._get_move_for_period(date_to_test)
+ self.assertIsNone(move_for_period, 'No move is generated yet for the next period')
+
+ def test__determine_start_date(self):
+ start_date = self.transfer_model._determine_start_date()
+ self.assertEqual(start_date, self.transfer_model.date_start, 'No moves generated yet, start date should be the start date of the transfer model')
+
+ move = self._create_basic_move(date_str='2019-07-01', journal_id=self.journal.id, transfer_model_id=self.transfer_model.id, posted=False)
+ start_date = self.transfer_model._determine_start_date()
+ self.assertEqual(start_date, self.transfer_model.date_start, 'A move generated but not posted, start date should be the start date of the transfer model')
+
+ move.action_post()
+ start_date = self.transfer_model._determine_start_date()
+ self.assertEqual(start_date, move.date + relativedelta(days=1), 'A move posted, start date should be the day after that move')
+
+ second_move = self._create_basic_move(date_str='2019-08-01', journal_id=self.journal.id, transfer_model_id=self.transfer_model.id, posted=False)
+ start_date = self.transfer_model._determine_start_date()
+ self.assertEqual(start_date, move.date + relativedelta(days=1), 'Two moves generated, start date should be the day after the last posted one')
+
+ second_move.action_post()
+ random_move = self._create_basic_move(date_str='2019-08-01', journal_id=self.journal.id)
+ start_date = self.transfer_model._determine_start_date()
+ self.assertEqual(start_date, second_move.date + relativedelta(days=1), 'Random move generated not linked to transfer model, start date should be the day after the last one linked to it')
+
+ def test__get_next_move_date(self):
+ experimentations = {
+ 'month': [
+ # date, expected date
+ (self.transfer_model.date_start, '2019-06-30'),
+ (fields.Date.to_date('2019-01-29'), '2019-02-27'),
+ (fields.Date.to_date('2019-01-30'), '2019-02-27'),
+ (fields.Date.to_date('2019-01-31'), '2019-02-27'),
+ (fields.Date.to_date('2019-02-28'), '2019-03-27'),
+ (fields.Date.to_date('2019-12-31'), '2020-01-30'),
+ ],
+ 'quarter': [
+ (self.transfer_model.date_start, '2019-08-31'),
+ (fields.Date.to_date('2019-01-31'), '2019-04-29'),
+ (fields.Date.to_date('2019-02-28'), '2019-05-27'),
+ (fields.Date.to_date('2019-12-31'), '2020-03-30'),
+ ],
+ 'year': [
+ (self.transfer_model.date_start, '2020-05-31'),
+ (fields.Date.to_date('2019-01-31'), '2020-01-30'),
+ (fields.Date.to_date('2019-02-28'), '2020-02-27'),
+ (fields.Date.to_date('2019-12-31'), '2020-12-30'),
+ ]
+ }
+
+ for frequency in experimentations:
+ self.transfer_model.write({'frequency': frequency})
+ for start_date, expected_date_str in experimentations[frequency]:
+ next_date = self.transfer_model._get_next_move_date(start_date)
+ self.assertEqual(next_date, fields.Date.to_date(expected_date_str),
+ 'Next date from %s should be %s' % (str(next_date), expected_date_str))
+
+ @patch('odoo.addons.odex30_account_auto_transfer.models.transfer_model.TransferModel._get_non_analytic_transfer_values')
+ def test__get_non_filtered_auto_transfer_move_line_values(self, patched_get_values):
+ start_date = fields.Date.to_date('2019-01-01')
+ self.transfer_model.write({'account_ids': [(6, 0, [ma.id for ma in self.origin_accounts])], })
+ end_date = fields.Date.to_date('2019-12-31')
+
+ move = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2019-12-01',
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {
+ 'debit': 4242.42,
+ 'credit': 0,
+ 'account_id': self.origin_accounts[0].id,
+ }),
+ (0, 0, {
+ 'debit': 8342.58,
+ 'credit': 0,
+ 'account_id': self.company_data.get('default_account_revenue').id,
+ }),
+ (0, 0, {
+ 'debit': 0,
+ 'credit': 0,
+ 'account_id': self.destination_accounts[0].id,
+ }),
+ (0, 0, {
+ 'debit': 0,
+ 'credit': 12585.0,
+ 'account_id': self.origin_accounts[1].id,
+ }),
+ ]
+ })
+ move.action_post()
+ amount_left = 10.0
+ patched_get_values.return_value = [{
+ 'name': "YO",
+ 'account_id': 1,
+ 'date_maturity': start_date,
+ 'debit': 123.45
+ }], amount_left
+
+ exp = [{
+ 'name': 'YO',
+ 'account_id': 1,
+ 'date_maturity': start_date,
+ 'debit': 123.45
+ }, {
+ 'name': 'Automatic Transfer (-%s%%)' % self.transfer_model.total_percent,
+ 'account_id': self.origin_accounts[0].id,
+ 'date_maturity': end_date,
+ 'credit': 4242.42 - amount_left
+ }, {
+ 'name': 'YO',
+ 'account_id': 1,
+ 'date_maturity': start_date,
+ 'debit': 123.45
+ }, {
+ 'name': 'Automatic Transfer (-%s%%)' % self.transfer_model.total_percent,
+ 'account_id': self.origin_accounts[1].id,
+ 'date_maturity': end_date,
+ 'debit': 12585.0 - amount_left
+ }]
+ res = self.transfer_model._get_non_filtered_auto_transfer_move_line_values([], start_date, end_date)
+ self.assertEqual(len(res), 4)
+ self.assertListEqual(exp, res)
+
+ @patch(
+ 'odoo.addons.odex30_account_auto_transfer'
+ '.models.transfer_model.TransferModelLine._get_destination_account_transfer_move_line_values')
+ def test__get_non_analytic_transfer_values(self, patched):
+ # Just need a transfer model line
+ percents = [45, 45]
+ self.transfer_model.write({
+ 'account_ids': [(6, 0, [ma.id for ma in self.origin_accounts])],
+ 'line_ids': [
+ (0, 0, {
+ 'percent': percents[0],
+ 'account_id': self.destination_accounts[0].id
+ }),
+ (0, 0, {
+ 'percent': percents[1],
+ 'account_id': self.destination_accounts[1].id
+ })
+ ]
+ })
+ account = self.origin_accounts[0]
+ write_date = fields.Date.to_date('2019-01-01')
+ lines = self.transfer_model.line_ids
+ amount_of_line = len(lines)
+ amount = 4242.0
+ is_debit = False
+ patched.return_value = {
+ 'name': "YO",
+ 'account_id': account.id,
+ 'date_maturity': write_date,
+ 'debit' if is_debit else 'credit': amount
+ }
+ expected_result_list = [patched.return_value] * 2
+ expected_result_amount = amount * ((100.0 - sum(percents)) / 100.0)
+
+ res = self.transfer_model._get_non_analytic_transfer_values(account, lines, write_date, amount, is_debit)
+ self.assertListEqual(res[0], expected_result_list)
+ self.assertAlmostEqual(res[1], expected_result_amount)
+ self.assertEqual(patched.call_count, amount_of_line)
+
+ # need to round amount to avoid failing float comparison (as magic mock uses "==" to compare args)
+ exp_calls = [call(account, round(amount * (line.percent / 100.0), 1), is_debit, write_date) for line in lines]
+ patched.assert_has_calls(exp_calls)
+
+ # Try now with 100% repartition
+ lines[0].write({'percent': 55.0})
+ res = self.transfer_model._get_non_analytic_transfer_values(account, lines, write_date, amount, is_debit)
+ self.assertAlmostEqual(res[1], 0.0)
+
+ # TEST CONSTRAINTS
+ def test__check_line_ids_percents(self):
+ with self.assertRaises(ValidationError):
+ transfer_model_lines = []
+ for i, percent in enumerate((50.0, 50.01)):
+ transfer_model_lines.append((0, 0, {
+ 'percent': percent,
+ 'account_id': self.destination_accounts[i].id
+ }))
+ self.transfer_model.write({
+ 'account_ids': [(6, 0, [ma.id for ma in self.origin_accounts])],
+ 'line_ids': transfer_model_lines
+ })
+
+ def test_unlink_of_transfer_with_no_moves(self):
+ """ Deletion of an automatic transfer that has no move should not raise an error. """
+
+ self.transfer_model.write({
+ 'account_ids': [Command.link(self.origin_accounts[0].id)],
+ 'line_ids': [
+ Command.create({
+ 'percent': 100,
+ 'account_id': self.destination_accounts[0].id
+ })
+ ]
+ })
+ self.transfer_model.action_activate()
+
+ self.assertEqual(self.transfer_model.move_ids_count, 0)
+ self.transfer_model.unlink()
+
+ def test_error_unlink_of_transfer_with_moves(self):
+ """ Deletion of an automatic transfer that has posted/draft moves should raise an error. """
+
+ self.transfer_model.write({
+ 'date_start': datetime.today() - relativedelta(day=1),
+ 'frequency': 'year',
+ 'account_ids': [Command.link(self.company_data['default_account_revenue'].id)],
+ 'line_ids': [
+ Command.create({
+ 'percent': 100,
+ 'account_id': self.destination_accounts[0].id
+ })
+ ]
+ })
+ self.transfer_model.action_activate()
+
+ # Add a transaction on the journal so that the move is not empty
+ self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': datetime.today(),
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'line1',
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'price_unit': 1000.0,
+ }),
+ ]
+ }).action_post()
+
+ # Generate draft moves
+ self.transfer_model.action_perform_auto_transfer()
+
+ error_message = "You cannot delete an automatic transfer that has draft moves*"
+ with self.assertRaisesRegex(UserError, error_message):
+ self.transfer_model.unlink()
+
+ # Post one of the moves
+ self.transfer_model.move_ids[0].action_post()
+
+ error_message = "You cannot delete an automatic transfer that has posted moves*"
+ with self.assertRaisesRegex(UserError, error_message):
+ self.transfer_model.unlink()
+
+ def test_disable_transfer_when_archived(self):
+ """ An automatic transfer in progress should be disabled when archived. """
+
+ self.transfer_model.action_activate()
+ self.assertEqual(self.transfer_model.state, 'in_progress')
+
+ self.transfer_model.action_archive()
+ self.assertEqual(self.transfer_model.state, 'disabled')
+
+ @freeze_time('2022-01-01')
+ def test_compute_transfer_lines_100_percent_transfer(self):
+ """ Transfer 100% of the source account in separate destinations. """
+ self.transfer_model.date_start = datetime.strftime(datetime.today() + relativedelta(day=1), "%Y-%m-%d")
+
+ _, slave_ids = self._create_accounts(0, 3)
+ self.transfer_model.write({
+ 'account_ids': [Command.link(self.company_data['default_account_revenue'].id)],
+ 'line_ids': [
+ Command.create({
+ 'percent': 15,
+ 'account_id': slave_ids[0].id
+ }),
+ Command.create({
+ 'percent': 42.50,
+ 'account_id': slave_ids[1].id
+ }),
+ Command.create({
+ 'percent': 42.50,
+ 'account_id': slave_ids[2].id
+ }),
+ ]
+ })
+ self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': datetime.today(),
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'line_xyz',
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'price_unit': 410.34,
+ }),
+ ]
+ }).action_post()
+ self.transfer_model.action_activate()
+ self.transfer_model.action_perform_auto_transfer()
+ lines = self.transfer_model.move_ids.line_ids
+ # 100% of the total amount
+ self.assertAlmostEqual(lines.filtered(lambda l: l.account_id == self.company_data['default_account_revenue']).debit, 410.34)
+ # 15% of the total amount
+ self.assertAlmostEqual(lines.filtered(lambda l: l.account_id == slave_ids[0]).credit, 61.55)
+ # 42.50% of the total amount
+ self.assertAlmostEqual(lines.filtered(lambda l: l.account_id == slave_ids[1]).credit, 174.39)
+ # the remaining amount of the total amount (42.50%)
+ self.assertAlmostEqual(lines.filtered(lambda l: l.account_id == slave_ids[2]).credit, 174.4)
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/tests/test_transfer_model_line.py b/dev_odex30_accounting/odex30_account_auto_transfer/tests/test_transfer_model_line.py
new file mode 100644
index 0000000..d4a340b
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/tests/test_transfer_model_line.py
@@ -0,0 +1,288 @@
+# -*- coding: utf-8 -*-
+
+from unittest.mock import patch
+
+from odoo.addons.odex30_account_auto_transfer.tests.account_auto_transfer_test_classes import AccountAutoTransferTestCase
+
+from odoo import fields
+from odoo.tests import tagged
+
+# ############################################################################ #
+# UNIT TESTS #
+# ############################################################################ #
+@tagged('post_install', '-at_install')
+class MoveModelLineTestCase(AccountAutoTransferTestCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls._assign_origin_accounts(cls)
+
+ def test__get_transfer_move_lines_values_same_aaccounts(self):
+ amounts = [4242.42, 1234.56]
+ aaccounts = [self._create_analytic_account('ANAL0' + str(i)) for i in range(2)]
+ self._create_basic_move(
+ cred_account=self.destination_accounts[0].id,
+ deb_account=self.origin_accounts[0].id,
+ amount=amounts[0],
+ deb_analytic=aaccounts[0].id
+ )
+ self._create_basic_move(
+ cred_account=self.destination_accounts[1].id,
+ deb_account=self.origin_accounts[0].id,
+ amount=amounts[1],
+ deb_analytic=aaccounts[1].id
+ )
+ transfer_model_line_1 = self._add_transfer_model_line(self.destination_accounts[0].id,
+ analytic_account_ids=[aaccounts[0].id, aaccounts[1].id])
+ transfer_model_line_2 = self._add_transfer_model_line(self.destination_accounts[1].id,
+ analytic_account_ids=[aaccounts[0].id])
+
+ transfer_models_lines = transfer_model_line_1 + transfer_model_line_2
+ args = [fields.Date.to_date('2019-01-01'), fields.Date.to_date('2019-12-19')]
+ res = transfer_models_lines._get_transfer_move_lines_values(*args)
+ exp = [{
+ 'name': 'Automatic Transfer (from account MA001 with analytic account(s): ANAL00, ANAL01)',
+ 'account_id': self.destination_accounts[0].id,
+ 'date_maturity': args[1],
+ 'debit': sum(amounts),
+ }, {
+ 'name': 'Automatic Transfer (entries with analytic account(s): ANAL00, ANAL01)',
+ 'account_id': self.origin_accounts[0].id,
+ 'date_maturity': args[1],
+ 'credit': sum(amounts),
+ }]
+ self.assertListEqual(exp, res,
+ 'Only first transfer model line should be handled, second should get 0 and thus not be added')
+
+ def test__get_transfer_move_lines_values(self):
+ amounts = [4242.0, 1234.56]
+ aaccounts = [self._create_analytic_account('ANAL0' + str(i)) for i in range(3)]
+ self._create_basic_move(
+ cred_account=self.destination_accounts[0].id,
+ deb_account=self.origin_accounts[0].id,
+ amount=amounts[0],
+ deb_analytic=aaccounts[0].id
+ )
+ self._create_basic_move(
+ cred_account=self.destination_accounts[1].id,
+ deb_account=self.origin_accounts[0].id,
+ amount=amounts[1],
+ deb_analytic=aaccounts[2].id
+ )
+ transfer_model_line_1 = self._add_transfer_model_line(self.destination_accounts[0].id,
+ analytic_account_ids=[aaccounts[0].id, aaccounts[1].id])
+ transfer_model_line_2 = self._add_transfer_model_line(self.destination_accounts[1].id,
+ analytic_account_ids=[aaccounts[2].id])
+
+ transfer_models_lines = transfer_model_line_1 + transfer_model_line_2
+ args = [fields.Date.to_date('2019-01-01'), fields.Date.to_date('2019-12-19')]
+ res = transfer_models_lines._get_transfer_move_lines_values(*args)
+ exp = [
+ {
+ 'name': 'Automatic Transfer (from account MA001 with analytic account(s): ANAL00, ANAL01)',
+ 'account_id': self.destination_accounts[0].id,
+ 'date_maturity': args[1],
+ 'debit': amounts[0],
+ },
+ {
+ 'name': 'Automatic Transfer (entries with analytic account(s): ANAL00, ANAL01)',
+ 'account_id': self.origin_accounts[0].id,
+ 'date_maturity': args[1],
+ 'credit': amounts[0],
+ },
+ {
+ 'name': 'Automatic Transfer (from account MA001 with analytic account(s): ANAL02)',
+ 'account_id': self.destination_accounts[1].id,
+ 'date_maturity': args[1],
+ 'debit': amounts[1],
+ },
+ {
+ 'name': 'Automatic Transfer (entries with analytic account(s): ANAL02)',
+ 'account_id': self.origin_accounts[0].id,
+ 'date_maturity': args[1],
+ 'credit': amounts[1],
+ }
+ ]
+ self.assertListEqual(exp, res)
+
+ @patch('odoo.addons.odex30_account_auto_transfer.models.transfer_model.TransferModel._get_move_lines_base_domain')
+ def test__get_move_lines_domain(self, patched):
+ return_val = [('bla', '=', 42)]
+ # we need to copy return val as there are edge effects due to mocking
+ # return_value is modified by the function call)
+ patched.return_value = return_val[:]
+ args = [fields.Date.to_date('2019-01-01'), fields.Date.to_date('2019-12-19')]
+ aaccount_1 = self._create_analytic_account('ANAL01')
+ aaccount_2 = self._create_analytic_account('ANAL02')
+ percent = 42.42
+ analytic_transfer_model_line = self._add_transfer_model_line(self.destination_accounts[0].id,
+ analytic_account_ids=[aaccount_1.id, aaccount_2.id])
+ percent_transfer_model_line = self._add_transfer_model_line(self.destination_accounts[1].id, percent=percent)
+
+ anal_res = analytic_transfer_model_line._get_move_lines_domain(*args)
+ anal_expected = return_val
+ patched.assert_called_once_with(*args)
+ self.assertListEqual(anal_res, anal_expected)
+ patched.reset_mock()
+
+ perc_res = percent_transfer_model_line._get_move_lines_domain(*args)
+ patched.assert_called_once_with(*args)
+ self.assertListEqual(perc_res, patched.return_value)
+
+ def test__get_origin_account_transfer_move_line_values(self):
+ percent = 92.42
+ transfer_model_line = self._add_transfer_model_line(self.destination_accounts[0].id, percent=percent)
+ origin_account = self.origin_accounts[0]
+ amount = 4200.42
+ is_debit = True
+ write_date = fields.Date.to_date('2019-12-19')
+ params = [origin_account, amount, is_debit, write_date]
+ result = transfer_model_line._get_origin_account_transfer_move_line_values(*params)
+ expected = {
+ 'name': 'Automatic Transfer (to account %s)' % self.destination_accounts[0].code,
+ 'account_id': origin_account.id,
+ 'date_maturity': write_date,
+ 'credit' if is_debit else 'debit': amount
+ }
+ self.assertDictEqual(result, expected)
+
+ def test__get_destination_account_transfer_move_line_values(self):
+ aaccount_1 = self._create_analytic_account('ANAL01')
+ aaccount_2 = self._create_analytic_account('ANAL02')
+ percent = 42.42
+ analytic_transfer_model_line = self._add_transfer_model_line(self.destination_accounts[0].id,
+ analytic_account_ids=[aaccount_1.id, aaccount_2.id])
+ percent_transfer_model_line = self._add_transfer_model_line(self.destination_accounts[1].id, percent=percent)
+ origin_account = self.origin_accounts[0]
+ amount = 4200
+ is_debit = True
+ write_date = fields.Date.to_date('2019-12-19')
+ params = [origin_account, amount, is_debit, write_date]
+ anal_result = analytic_transfer_model_line._get_destination_account_transfer_move_line_values(*params)
+ aaccount_names = ', '.join([aac.name for aac in [aaccount_1, aaccount_2]])
+ anal_expected_result = {
+ 'name': 'Automatic Transfer (from account %s with analytic account(s): %s)' % (
+ origin_account.code, aaccount_names),
+ 'account_id': self.destination_accounts[0].id,
+ 'date_maturity': write_date,
+ 'debit' if is_debit else 'credit': amount
+ }
+ self.assertDictEqual(anal_result, anal_expected_result)
+ percent_result = percent_transfer_model_line._get_destination_account_transfer_move_line_values(*params)
+ percent_expected_result = {
+ 'name': 'Automatic Transfer (%s%% from account %s)' % (percent, self.origin_accounts[0].code),
+ 'account_id': self.destination_accounts[1].id,
+ 'date_maturity': write_date,
+ 'debit' if is_debit else 'credit': amount
+ }
+ self.assertDictEqual(percent_result, percent_expected_result)
+
+ def test__get_transfer_move_lines_values_same_partner_ids(self):
+ """
+ Make sure we only process the account moves once.
+ Here the second line references a partner already handled in the first one.
+ The second transfer should thus not be apply on the account lines already handled by the first transfer.
+ """
+ amounts = [4242.42, 1234.56]
+ partner_ids = [self._create_partner('partner' + str(i))for i in range(2)]
+ self._create_basic_move(
+ cred_account=self.destination_accounts[0].id,
+ deb_account=self.origin_accounts[0].id,
+ amount=amounts[0],
+ partner_id=partner_ids[0].id,
+ date_str='2019-02-01'
+ )
+ self._create_basic_move(
+ cred_account=self.destination_accounts[1].id,
+ deb_account=self.origin_accounts[0].id,
+ amount=amounts[1],
+ partner_id=partner_ids[1].id,
+ date_str='2019-02-01'
+ )
+ self._create_basic_move(
+ cred_account=self.destination_accounts[0].id,
+ deb_account=self.origin_accounts[0].id,
+ amount=amounts[0],
+ date_str='2019-02-01'
+ )
+ transfer_model_line_1 = self._add_transfer_model_line(self.destination_accounts[0].id,
+ partner_ids=[partner_ids[0].id, partner_ids[1].id])
+ transfer_model_line_2 = self._add_transfer_model_line(self.destination_accounts[1].id,
+ partner_ids=[partner_ids[0].id])
+
+ transfer_models_lines = transfer_model_line_1 + transfer_model_line_2
+ args = [fields.Date.to_date('2019-01-01'), fields.Date.to_date('2019-12-19')]
+ res = transfer_models_lines._get_transfer_move_lines_values(*args)
+ exp = [{
+ 'name': 'Automatic Transfer (from account MA001 with partner(s): partner0, partner1)',
+ 'account_id': self.destination_accounts[0].id,
+ 'date_maturity': args[1],
+ 'debit': sum(amounts),
+ }, {
+ 'name': 'Automatic Transfer (entries with partner(s): partner0, partner1)',
+ 'account_id': self.origin_accounts[0].id,
+ 'date_maturity': args[1],
+ 'credit': sum(amounts),
+ }]
+ self.assertListEqual(exp, res,
+ 'Only first transfer model line should be handled, second should get 0 and thus not be added')
+
+ def test__get_transfer_move_lines_values_partner(self):
+ """
+ Create account moves and transfer, verify that the result of the auto transfer is correct.
+ """
+ amounts = [4242.0, 1234.56]
+ aaccounts = [self._create_analytic_account('ANAL00')]
+ partner_ids = [self._create_partner('partner' + str(i))for i in range(2)]
+ self._create_basic_move(
+ cred_account=self.destination_accounts[2].id,
+ deb_account=self.origin_accounts[0].id,
+ amount=amounts[0],
+ partner_id=partner_ids[0].id,
+ date_str='2019-02-01'
+ )
+ self._create_basic_move(
+ cred_account=self.destination_accounts[3].id,
+ deb_account=self.origin_accounts[0].id,
+ amount=amounts[1],
+ deb_analytic=aaccounts[0].id,
+ partner_id=partner_ids[1].id,
+ date_str='2019-02-01'
+ )
+ transfer_model_line_1 = self._add_transfer_model_line(self.destination_accounts[3].id,
+ analytic_account_ids=[aaccounts[0].id],
+ partner_ids=[partner_ids[1].id])
+ transfer_model_line_2 = self._add_transfer_model_line(self.destination_accounts[2].id,
+ partner_ids=[partner_ids[0].id])
+
+ transfer_models_lines = transfer_model_line_1 + transfer_model_line_2
+ args = [fields.Date.to_date('2019-01-01'), fields.Date.to_date('2019-12-19')]
+ res = transfer_models_lines._get_transfer_move_lines_values(*args)
+ exp = [
+ {
+ 'name': 'Automatic Transfer (from account MA001 with analytic account(s): ANAL00 and partner(s): partner1)',
+ 'account_id': self.destination_accounts[3].id,
+ 'date_maturity': args[1],
+ 'debit': amounts[1],
+ },
+ {
+ 'name': 'Automatic Transfer (entries with analytic account(s): ANAL00 and partner(s): partner1)',
+ 'account_id': self.origin_accounts[0].id,
+ 'date_maturity': args[1],
+ 'credit': amounts[1],
+ },
+ {
+ 'name': 'Automatic Transfer (from account MA001 with partner(s): partner0)',
+ 'account_id': self.destination_accounts[2].id,
+ 'date_maturity': args[1],
+ 'debit': amounts[0],
+ },
+ {
+ 'name': 'Automatic Transfer (entries with partner(s): partner0)',
+ 'account_id': self.origin_accounts[0].id,
+ 'date_maturity': args[1],
+ 'credit': amounts[0],
+ },
+ ]
+ self.assertListEqual(exp, res)
diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/views/transfer_model_views.xml b/dev_odex30_accounting/odex30_account_auto_transfer/views/transfer_model_views.xml
new file mode 100644
index 0000000..de695e1
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_auto_transfer/views/transfer_model_views.xml
@@ -0,0 +1,142 @@
+
+
+
+
+ account.auto.transfer.search
+ account.move
+
+
+
+
+
+
+
+
+
+
+
+ Automatic Transfers
+ account.transfer.model
+ list,form
+
+
+
+
+ Generated Entries
+ account.move
+ list,form
+
+
+
+
+
+
+
+
+
+
+ account.auto.transfer.model.list
+ account.transfer.model
+
+
+
+
+
+
+
+
+
+
+
+
+ account.auto.transfer.model.form
+ account.transfer.model
+
+
+
+
+
+ account.auto.transfer.model.search
+ account.transfer.model
+
+
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import/__init__.py
new file mode 100644
index 0000000..7749576
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_bank_statement_import/__init__.py
@@ -0,0 +1,4 @@
+# -*- encoding: utf-8 -*-
+
+from . import models
+from . import wizard
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/__manifest__.py b/dev_odex30_accounting/odex30_account_bank_statement_import/__manifest__.py
new file mode 100644
index 0000000..f524d31
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_bank_statement_import/__manifest__.py
@@ -0,0 +1,26 @@
+# -*- encoding: utf-8 -*-
+{
+ 'name': 'Account Bank Statement Import',
+ 'category': 'Odex30-Accounting/Odex30-Accounting',
+ 'version': '1.0',
+ 'depends': ['odex30_account_accountant', 'base_import'],
+ 'description': """Generic Wizard to Import Bank Statements.
+
+(This module does not include any type of import format.)
+
+OFX and QIF imports are available in Enterprise version.""",
+ 'data': [
+ 'views/account_bank_statement_import_view.xml',
+ ],
+ 'demo': [
+ 'demo/partner_bank.xml',
+ ],
+ 'installable': True,
+ 'auto_install': True,
+ 'assets': {
+ 'web.assets_backend': [
+ 'odex30_account_bank_statement_import/static/src/**/*',
+ ],
+ },
+
+}
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..580c2ae
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import/__pycache__/__init__.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/demo/partner_bank.xml b/dev_odex30_accounting/odex30_account_bank_statement_import/demo/partner_bank.xml
new file mode 100644
index 0000000..ab15c79
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_bank_statement_import/demo/partner_bank.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+ BE68539007547034
+
+
+
+
+
+ 00987654322
+
+
+
+
+
+ 10987654320
+
+
+
+
+
+ 10987654322
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/i18n/ar.po b/dev_odex30_accounting/odex30_account_bank_statement_import/i18n/ar.po
new file mode 100644
index 0000000..65b1fa4
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_bank_statement_import/i18n/ar.po
@@ -0,0 +1,201 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * odex30_account_bank_statement_import
+#
+# Translators:
+# Wil Odoo, 2024
+# Malaz Abuidris , 2025
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 18.0+e\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2024-09-25 09:26+0000\n"
+"PO-Revision-Date: 2024-09-25 09:43+0000\n"
+"Last-Translator: Malaz Abuidris , 2025\n"
+"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Language: ar\n"
+"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0
+msgid "%d transactions had already been imported and were ignored."
+msgstr "تم استيراد %d معاملات بالفعل وتجاهلها. "
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0
+msgid "1 transaction had already been imported and was ignored."
+msgstr "تم تجاهل 1 معاملة سبق استيرادها بالفعل."
+
+#. module: odex30_account_bank_statement_import
+#: model:ir.model.constraint,message:odex30_account_bank_statement_import.constraint_account_bank_statement_line_unique_import_id
+msgid "A bank account transactions can be imported only once!"
+msgstr "يمكن استيراد معاملات الحساب البنكي مرة واحدة فقط! "
+
+#. module: odex30_account_bank_statement_import
+#: model:ir.model,name:odex30_account_bank_statement_import.model_account_bank_statement_line
+msgid "Bank Statement Line"
+msgstr "بند كشف الحساب البنكي"
+
+#. module: odex30_account_bank_statement_import
+#: model:ir.model,name:odex30_account_bank_statement_import.model_account_setup_bank_manual_config
+msgid "Bank setup manual config"
+msgstr "التهيئة اليدوية لإعدادات البنك "
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0
+msgid ""
+"Cannot find in which journal import this statement. Please manually select a"
+" journal."
+msgstr ""
+"لقد تعذر إيجاد دفتر اليومية الذي يتم توريد كشف الحساب المحدد إليه. الرجاء "
+"اختيار دفتر اليومية يدوياً. "
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_bank_statement.py:0
+msgid "Click \"New\" or upload a %s."
+msgstr "اضغط على\"جديد\" أو قم برفع %s. "
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0
+msgid ""
+"Could not make sense of the given file.\n"
+"Did you install the module to support this type of file?"
+msgstr ""
+" تعذر فهم طبيعة الملف المحدد.\n"
+"هل قمت بتثبيت التطبيق المناسب لدعم هذا النوع من الملفات؟ "
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0
+msgid "Go to Apps"
+msgstr "الذهاب إلى التطبيقات "
+
+#. module: odex30_account_bank_statement_import
+#: model_terms:ir.ui.view,arch_db:odex30_account_bank_statement_import.journal_dashboard_view_inherit
+msgid "Import File"
+msgstr "استيراد ملف"
+
+#. module: odex30_account_bank_statement_import
+#: model:ir.model.fields,field_description:odex30_account_bank_statement_import.field_account_bank_statement_line__unique_import_id
+msgid "Import ID"
+msgstr "معرف الاستيراد"
+
+#. module: odex30_account_bank_statement_import
+#. odoo-javascript
+#: code:addons/odex30_account_bank_statement_import/static/src/account_bank_statement_import_model.js:0
+msgid "Import Template for Bank Statements"
+msgstr "استيراد قالب لكشوفات الحسابات البنكية "
+
+#. module: odex30_account_bank_statement_import
+#: model:ir.model,name:odex30_account_bank_statement_import.model_account_journal
+msgid "Journal"
+msgstr "دفتر اليومية"
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0
+msgid "Manual (or import %(import_formats)s)"
+msgstr "يدوي (أو استيراد %(import_formats)s) "
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0
+msgid "No attachment was provided"
+msgstr "لم يتم توفير مرفق"
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0
+msgid "No currency found matching '%s'."
+msgstr "لم يمكن العثور على أي عملة تطابق '%s'."
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_bank_statement.py:0
+msgid "No transactions matching your filters were found."
+msgstr "لم يتم العثور على أي معاملات تطابق عوامل التصفية الخاصة بك. "
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_bank_statement.py:0
+msgid "Nothing to do here!"
+msgstr "لا شيء لتفعله هنا! "
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0
+msgid ""
+"The account of this statement (%(account)s) is not the same as the journal "
+"(%(journal)s)."
+msgstr ""
+"الحساب في كشف الحساب البنكي (%(account)s) مختلف عن حساب دفتر اليومية "
+"(%(journal)s). "
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0
+msgid ""
+"The currency of the bank statement (%(code)s) is not the same as the "
+"currency of the journal (%(journal)s)."
+msgstr ""
+"العملة في كشف الحساب البنكي (%(code)s) مختلفة عن العملة في دفتر اليومية "
+"(%(journal)s). "
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0
+msgid "The following files could not be imported:\n"
+msgstr "تعذر استيراد الملفات التالية: \n"
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0
+msgid ""
+"This file doesn't contain any statement for account %s.\n"
+"If it contains transactions for more than one account, it must be imported on each of them."
+msgstr ""
+"لا يحتوي هذا الملف على أي كشف للحساب %s.\n"
+"إذا كان يحتوي على معاملات لأكثر من حساب، فيجب استيراده في كل منها. "
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0
+msgid ""
+"This file doesn't contain any transaction for account %s.\n"
+"If it contains transactions for more than one account, it must be imported on each of them."
+msgstr ""
+"لا يحتوي هذا الملف على أي معاملات للحساب %s.\n"
+"إذا كان يحتوي على معاملات لأكثر من حساب، فيجب استيراده في كل منها. "
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0
+msgid "View successfully imported statements"
+msgstr "عرض كشوفات الحساب البنكية التي تم استيرادها بنجاح "
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0
+msgid "You already have imported that file."
+msgstr "لقد قمت باستيراد هذا الملف بالفعل."
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0
+msgid "You have to set a Default Account for the journal: %s"
+msgstr "عليك تعيين حساب افتراضي لليومية: %s"
+
+#. module: odex30_account_bank_statement_import
+#. odoo-python
+#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0
+msgid "You uploaded an invalid or empty file."
+msgstr "لقد قمت برفع ملف فارغ أو غير صالح. "
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/models/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import/models/__init__.py
new file mode 100644
index 0000000..5b36696
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_bank_statement_import/models/__init__.py
@@ -0,0 +1,2 @@
+from . import account_bank_statement
+from . import account_journal
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import/models/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..c591a80
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import/models/__pycache__/__init__.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/models/__pycache__/account_bank_statement.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import/models/__pycache__/account_bank_statement.cpython-311.pyc
new file mode 100644
index 0000000..f6cb64f
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import/models/__pycache__/account_bank_statement.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/models/__pycache__/account_journal.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import/models/__pycache__/account_journal.cpython-311.pyc
new file mode 100644
index 0000000..d98c0e6
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import/models/__pycache__/account_journal.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/models/account_bank_statement.py b/dev_odex30_accounting/odex30_account_bank_statement_import/models/account_bank_statement.py
new file mode 100644
index 0000000..6391252
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_bank_statement_import/models/account_bank_statement.py
@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+
+from odoo import fields, models, _
+
+from markupsafe import Markup
+
+import logging
+_logger = logging.getLogger(__name__)
+
+
+class AccountBankStatementLine(models.Model):
+ _inherit = "account.bank.statement.line"
+
+ # Ensure transactions can be imported only once (if the import format provides unique transaction ids)
+ unique_import_id = fields.Char(string='Import ID', readonly=True, copy=False)
+
+ _sql_constraints = [
+ ('unique_import_id', 'unique (unique_import_id)', 'A bank account transactions can be imported only once!')
+ ]
+
+ def _action_open_bank_reconciliation_widget(self, extra_domain=None, default_context=None, name=None, kanban_first=True):
+ res = super()._action_open_bank_reconciliation_widget(extra_domain, default_context, name, kanban_first)
+ res['help'] = Markup("
{}
{} {}
").format(
+ _('Nothing to do here!'),
+ _('No transactions matching your filters were found.'),
+ _('Click "New" or upload a %s.', ", ".join(self.env['account.journal']._get_bank_statements_available_import_formats())),
+ )
+ return res
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/models/account_journal.py b/dev_odex30_accounting/odex30_account_bank_statement_import/models/account_journal.py
new file mode 100644
index 0000000..114ea1d
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_bank_statement_import/models/account_journal.py
@@ -0,0 +1,299 @@
+# -*- coding: utf-8 -*-
+from odoo import models, tools, _
+from odoo.addons.base.models.res_bank import sanitize_account_number
+from odoo.exceptions import UserError, RedirectWarning
+
+
+class AccountJournal(models.Model):
+ _inherit = "account.journal"
+
+ def _get_bank_statements_available_import_formats(self):
+ """ Returns a list of strings representing the supported import formats.
+ """
+ return []
+
+ def __get_bank_statements_available_sources(self):
+ rslt = super(AccountJournal, self).__get_bank_statements_available_sources()
+ formats_list = self._get_bank_statements_available_import_formats()
+ if formats_list:
+ formats_list.sort()
+ import_formats_str = ', '.join(formats_list)
+ rslt.append(("file_import", _("Manual (or import %(import_formats)s)", import_formats=import_formats_str)))
+ return rslt
+
+ def create_document_from_attachment(self, attachment_ids=None):
+ journal = self or self.browse(self.env.context.get('default_journal_id'))
+ if journal.type in ('bank', 'credit', 'cash'):
+ attachments = self.env['ir.attachment'].browse(attachment_ids)
+ if not attachments:
+ raise UserError(_("No attachment was provided"))
+ return journal._import_bank_statement(attachments)
+ return super().create_document_from_attachment(attachment_ids)
+
+ def _import_bank_statement(self, attachments):
+ """ Process the file chosen in the wizard, create bank statement(s) and go to reconciliation. """
+ if any(not a.raw for a in attachments):
+ raise UserError(_("You uploaded an invalid or empty file."))
+
+ statement_ids_all = []
+ notifications_all = {}
+ errors = {}
+ # Let the appropriate implementation module parse the file and return the required data
+ # The active_id is passed in context in case an implementation module requires information about the wizard state (see QIF)
+ for attachment in attachments:
+ try:
+ currency_code, account_number, stmts_vals = self._parse_bank_statement_file(attachment)
+ # Check raw data
+ self._check_parsed_data(stmts_vals, account_number)
+ # Try to find the currency and journal in odoo
+ journal = self._find_additional_data(currency_code, account_number)
+ # If no journal found, ask the user about creating one
+ if not journal.default_account_id:
+ raise UserError(_('You have to set a Default Account for the journal: %s', journal.name))
+ # Prepare statement data to be used for bank statements creation
+ stmts_vals = self._complete_bank_statement_vals(stmts_vals, journal, account_number, attachment)
+ # Create the bank statements
+ statement_ids, dummy, notifications = self._create_bank_statements(stmts_vals)
+ statement_ids_all.extend(statement_ids)
+
+ # Now that the import worked out, set it as the bank_statements_source of the journal
+ if journal.bank_statements_source != 'file_import':
+ # Use sudo() because only 'account.group_account_manager'
+ # has write access on 'account.journal', but 'account.group_account_user'
+ # must be able to import bank statement files
+ journal.sudo().bank_statements_source = 'file_import'
+
+ msg = ""
+ for notif in notifications:
+ msg += (
+ f"{notif['message']}"
+ )
+ if notifications:
+ notifications_all[attachment.name] = msg
+ except (UserError, RedirectWarning) as e:
+ errors[attachment.name] = e.args[0]
+
+ statements = self.env['account.bank.statement'].browse(statement_ids_all)
+ line_to_reconcile = statements.line_ids
+ if line_to_reconcile:
+ # 'limit_time_real_cron' defaults to -1.
+ # Manual fallback applied for non-POSIX systems where this key is disabled (set to None).
+ cron_limit_time = tools.config['limit_time_real_cron'] or -1
+ limit_time = cron_limit_time if 0 < cron_limit_time < 180 else 180
+ line_to_reconcile._cron_try_auto_reconcile_statement_lines(limit_time=limit_time)
+
+ result = self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
+ extra_domain=[('statement_id', 'in', statements.ids)],
+ default_context={
+ 'search_default_not_matched': True,
+ 'default_journal_id': statements[:1].journal_id.id,
+ 'notifications': notifications_all,
+ },
+ )
+
+ if errors:
+ error_msg = _("The following files could not be imported:\n")
+ error_msg += "\n".join([f"- {attachment_name}: {msg}" for attachment_name, msg in errors.items()])
+ if statements:
+ self.env.cr.commit() # save the correctly uploaded statements to the db before raising the errors
+ raise RedirectWarning(error_msg, result, _('View successfully imported statements'))
+ else:
+ raise UserError(error_msg)
+ return result
+
+ def _parse_bank_statement_file(self, attachment) -> tuple:
+ """ Each module adding a file support must extends this method. It processes the file if it can, returns super otherwise, resulting in a chain of responsability.
+ This method parses the given file and returns the data required by the bank statement import process, as specified below.
+ rtype: triplet (if a value can't be retrieved, use None)
+ - currency code: string (e.g: 'EUR')
+ The ISO 4217 currency code, case insensitive
+ - account number: string (e.g: 'BE1234567890')
+ The number of the bank account which the statement belongs to
+ - bank statements data: list of dict containing (optional items marked by o) :
+ - 'name': string (e.g: '000000123')
+ - 'date': date (e.g: 2013-06-26)
+ -o 'balance_start': float (e.g: 8368.56)
+ -o 'balance_end_real': float (e.g: 8888.88)
+ - 'transactions': list of dict containing :
+ - 'name': string (e.g: 'KBC-INVESTERINGSKREDIET 787-5562831-01')
+ - 'date': date
+ - 'amount': float
+ - 'unique_import_id': string
+ -o 'account_number': string
+ Will be used to find/create the res.partner.bank in odoo
+ -o 'note': string
+ -o 'partner_name': string
+ -o 'ref': string
+ """
+ raise RedirectWarning(
+ message=_("Could not make sense of the given file.\nDid you install the module to support this type of file?"),
+ action=self.env.ref('base.open_module_tree').id,
+ button_text=_("Go to Apps"),
+ additional_context={
+ 'search_default_name': 'account_bank_statement_import',
+ 'search_default_extra': True,
+ },
+ )
+
+ def _check_parsed_data(self, stmts_vals, account_number):
+ """ Basic and structural verifications """
+ if len(stmts_vals) == 0:
+ raise UserError(_(
+ 'This file doesn\'t contain any statement for account %s.\nIf it contains transactions for more than one account, it must be imported on each of them.',
+ account_number,
+ ))
+
+ no_st_line = True
+ for vals in stmts_vals:
+ if vals['transactions'] and len(vals['transactions']) > 0:
+ no_st_line = False
+ break
+ if no_st_line:
+ raise UserError(_(
+ 'This file doesn\'t contain any transaction for account %s.\nIf it contains transactions for more than one account, it must be imported on each of them.',
+ account_number,
+ ))
+
+ def _statement_import_check_bank_account(self, account_number):
+ # Needed for CH to accommodate for non-unique account numbers
+ sanitized_acc_number = self.bank_account_id.sanitized_acc_number.split(" ")[0]
+ # Needed for BNP France
+ if len(sanitized_acc_number) == 27 and len(account_number) == 11 and sanitized_acc_number[:2].upper() == "FR":
+ return sanitized_acc_number[14:-2] == account_number
+
+ # Needed for Credit Lyonnais (LCL)
+ if len(sanitized_acc_number) == 27 and len(account_number) == 7 and sanitized_acc_number[:2].upper() == "FR":
+ return sanitized_acc_number[18:-2] == account_number
+
+ return sanitized_acc_number == account_number
+
+ def _find_additional_data(self, currency_code, account_number):
+ """ Look for the account.journal using values extracted from the
+ statement and make sure it's consistent.
+ """
+ company_currency = self.env.company.currency_id
+ currency = None
+ sanitized_account_number = sanitize_account_number(account_number)
+
+ if currency_code:
+ currency = self.env['res.currency'].search([('name', '=ilike', currency_code)], limit=1)
+ if not currency:
+ raise UserError(_("No currency found matching '%s'.", currency_code))
+ if currency == company_currency:
+ currency = False
+
+ journal = self
+ if account_number:
+ # No bank account on the journal : create one from the account number of the statement
+ if journal and not journal.bank_account_id:
+ journal.set_bank_account(account_number)
+ # No journal passed to the wizard : try to find one using the account number of the statement
+ elif not journal:
+ journal = self.search([('bank_account_id.sanitized_acc_number', '=', sanitized_account_number)])
+ if not journal:
+ # Sometimes the bank returns only part of the full account number (e.g. local account number instead of full IBAN)
+ partial_match = self.search([('bank_account_id.sanitized_acc_number', 'ilike', sanitized_account_number)])
+ if len(partial_match) == 1:
+ journal = partial_match
+ # Already a bank account on the journal : check it's the same as on the statement
+ else:
+ if not self._statement_import_check_bank_account(sanitized_account_number):
+ raise UserError(_('The account of this statement (%(account)s) is not the same as the journal (%(journal)s).', account=account_number, journal=journal.bank_account_id.acc_number))
+
+ # If importing into an existing journal, its currency must be the same as the bank statement
+ if journal:
+ journal_currency = journal.currency_id or journal.company_id.currency_id
+ if currency is None:
+ currency = journal_currency
+ if currency and currency != journal_currency:
+ statement_cur_code = not currency and company_currency.name or currency.name
+ journal_cur_code = not journal_currency and company_currency.name or journal_currency.name
+ raise UserError(_('The currency of the bank statement (%(code)s) is not the same as the currency of the journal (%(journal)s).', code=statement_cur_code, journal=journal_cur_code))
+
+ if not journal:
+ raise UserError(_('Cannot find in which journal import this statement. Please manually select a journal.'))
+ return journal
+
+ def _complete_bank_statement_vals(self, stmts_vals, journal, account_number, attachment):
+ for st_vals in stmts_vals:
+ if not st_vals.get('reference'):
+ st_vals['reference'] = attachment.name
+ for line_vals in st_vals['transactions']:
+ line_vals['journal_id'] = journal.id
+ unique_import_id = line_vals.get('unique_import_id')
+ if unique_import_id:
+ sanitized_account_number = sanitize_account_number(account_number)
+ line_vals['unique_import_id'] = (sanitized_account_number and sanitized_account_number + '-' or '') + str(journal.id) + '-' + unique_import_id
+
+ if not line_vals.get('partner_bank_id'):
+ # Find the partner and his bank account or create the bank account. The partner selected during the
+ # reconciliation process will be linked to the bank when the statement is closed.
+ identifying_string = line_vals.get('account_number')
+ if identifying_string:
+ if line_vals.get('partner_id'):
+ partner_bank = self.env['res.partner.bank'].search([
+ ('acc_number', '=', identifying_string),
+ ('partner_id', '=', line_vals['partner_id'])
+ ])
+ else:
+ partner_bank = self.env['res.partner.bank'].search([
+ ('acc_number', '=', identifying_string),
+ ('company_id', 'in', (False, journal.company_id.id))
+ ])
+ # If multiple partners share the same account number, do not try to guess and just avoid setting it
+ if partner_bank and len(partner_bank) == 1:
+ line_vals['partner_bank_id'] = partner_bank.id
+ line_vals['partner_id'] = partner_bank.partner_id.id
+ return stmts_vals
+
+ def _create_bank_statements(self, stmts_vals, raise_no_imported_file=True):
+ """ Create new bank statements from imported values, filtering out already imported transactions, and returns data used by the reconciliation widget """
+ BankStatement = self.env['account.bank.statement']
+ BankStatementLine = self.env['account.bank.statement.line']
+
+ # Filter out already imported transactions and create statements
+ statement_ids = []
+ statement_line_ids = []
+ ignored_statement_lines_import_ids = []
+ for st_vals in stmts_vals:
+ filtered_st_lines = []
+ for line_vals in st_vals['transactions']:
+ if (line_vals['amount'] != 0
+ and ('unique_import_id' not in line_vals
+ or not line_vals['unique_import_id']
+ or not bool(BankStatementLine.sudo().search([('unique_import_id', '=', line_vals['unique_import_id'])], limit=1)))):
+ filtered_st_lines.append(line_vals)
+ else:
+ ignored_statement_lines_import_ids.append(line_vals)
+ if st_vals.get('balance_start') is not None:
+ st_vals['balance_start'] += float(line_vals['amount'])
+
+ if len(filtered_st_lines) > 0:
+ # Remove values that won't be used to create records
+ st_vals.pop('transactions', None)
+ # Create the statement
+ st_vals['line_ids'] = [[0, False, line] for line in filtered_st_lines]
+ statement = BankStatement.with_context(default_journal_id=self.id).create(st_vals)
+ if not statement.name:
+ statement.name = st_vals['reference']
+ statement_ids.append(statement.id)
+ statement_line_ids.extend(statement.line_ids.ids)
+
+ # Create the report.
+ if statement.is_complete and not self._context.get('skip_pdf_attachment_generation'):
+ statement.action_generate_attachment()
+
+ if len(statement_line_ids) == 0 and raise_no_imported_file:
+ raise UserError(_('You already have imported that file.'))
+
+ # Prepare import feedback
+ notifications = []
+ num_ignored = len(ignored_statement_lines_import_ids)
+ if num_ignored > 0:
+ notifications += [{
+ 'type': 'warning',
+ 'message': _("%d transactions had already been imported and were ignored.", num_ignored)
+ if num_ignored > 1
+ else _("1 transaction had already been imported and was ignored."),
+ }]
+ return statement_ids, statement_line_ids, notifications
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/static/csv/account.bank.statement.csv b/dev_odex30_accounting/odex30_account_bank_statement_import/static/csv/account.bank.statement.csv
new file mode 100644
index 0000000..7be40fd
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_bank_statement_import/static/csv/account.bank.statement.csv
@@ -0,0 +1,6 @@
+Journal,Name,Date,Starting Balance,Ending Balance,Statement lines / Date,Statement lines / Label,Statement lines / Partner,Statement lines / Reference,Statement lines / Amount,Statement lines / Amount Currency,Statement lines / Currency
+Bank,Statement May 01,2017-05-15,100,5124.5,2017-05-10,INV/2017/0001,,#01,4610,,
+,,,,,2017-05-11,Payment bill 20170521,,#02,-100,,
+,,,,,2017-05-15,INV/2017/0003 discount 2% early payment,,#03,514.5,,
+Bank,Statement May 02,2017-05-30,5124.5,9847.35,2017-05-30,INV/2017/0002 + INV/2017/0004,,#01,5260,,
+,,,,,2017-05-31,Payment bill EUR 001234565,,#02,-537.15,-500,EUR
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/static/description/icon_src.svg b/dev_odex30_accounting/odex30_account_bank_statement_import/static/description/icon_src.svg
new file mode 100644
index 0000000..664381e
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_bank_statement_import/static/description/icon_src.svg
@@ -0,0 +1,178 @@
+
+
\ No newline at end of file
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/account_bank_statement_import_model.js b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/account_bank_statement_import_model.js
new file mode 100644
index 0000000..99b6246
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/account_bank_statement_import_model.js
@@ -0,0 +1,18 @@
+/** @odoo-module **/
+
+import { BaseImportModel } from "@base_import/import_model";
+import { patch } from "@web/core/utils/patch";
+import { _t } from "@web/core/l10n/translation";
+
+patch(BaseImportModel.prototype, {
+ async init() {
+ await super.init(...arguments);
+
+ if (this.resModel === "account.bank.statement") {
+ this.importTemplates.push({
+ label: _t("Import Template for Bank Statements"),
+ template: "/odex30_account_bank_statement_import/static/csv/account.bank.statement.csv",
+ });
+ }
+ }
+});
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/finish_buttons.js b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/finish_buttons.js
new file mode 100644
index 0000000..72c1af2
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/finish_buttons.js
@@ -0,0 +1,10 @@
+/** @odoo-module **/
+import { patch } from "@web/core/utils/patch";
+import { AccountFileUploader } from "@account/components/account_file_uploader/account_file_uploader";
+import { BankRecFinishButtons } from "@odex30_account_accountant/components/bank_reconciliation/finish_buttons";
+
+patch(BankRecFinishButtons, {
+ components: {
+ AccountFileUploader,
+ }
+})
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/finish_buttons.xml b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/finish_buttons.xml
new file mode 100644
index 0000000..f9a6334
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/finish_buttons.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/kanban.js b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/kanban.js
new file mode 100644
index 0000000..61fece5
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/kanban.js
@@ -0,0 +1,42 @@
+/** @odoo-module **/
+import { registry } from "@web/core/registry";
+import { AccountFileUploader } from "@account/components/account_file_uploader/account_file_uploader";
+import { UploadDropZone } from "@account/components/upload_drop_zone/upload_drop_zone";
+import { BankRecKanbanView, BankRecKanbanController, BankRecKanbanRenderer } from "@odex30_account_accountant/components/bank_reconciliation/kanban";
+import { useState } from "@odoo/owl";
+
+export class BankRecKanbanUploadController extends BankRecKanbanController {
+ static components = {
+ ...BankRecKanbanController.components,
+ AccountFileUploader,
+ }
+}
+
+export class BankRecUploadKanbanRenderer extends BankRecKanbanRenderer {
+ static template = "account.BankRecKanbanUploadRenderer";
+ static components = {
+ ...BankRecKanbanRenderer.components,
+ UploadDropZone,
+ };
+ setup() {
+ super.setup();
+ this.dropzoneState = useState({
+ visible: false,
+ });
+ }
+
+ onDragStart(ev) {
+ if (ev.dataTransfer.types.includes("Files")) {
+ this.dropzoneState.visible = true
+ }
+ }
+}
+
+export const BankRecKanbanUploadView = {
+ ...BankRecKanbanView,
+ Controller: BankRecKanbanUploadController,
+ Renderer: BankRecUploadKanbanRenderer,
+ buttonTemplate: "account.BankRecKanbanButtons",
+};
+
+registry.category("views").add('bank_rec_widget_kanban', BankRecKanbanUploadView, { force: true });
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/kanban.xml b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/kanban.xml
new file mode 100644
index 0000000..9252348
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/kanban.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ onDragStart
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/list.js b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/list.js
new file mode 100644
index 0000000..8668138
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/list.js
@@ -0,0 +1,43 @@
+/** @odoo-module */
+
+import { registry } from "@web/core/registry";
+import { ListRenderer } from "@web/views/list/list_renderer";
+import { AccountFileUploader } from "@account/components/account_file_uploader/account_file_uploader";
+import { UploadDropZone } from "@account/components/upload_drop_zone/upload_drop_zone";
+import { bankRecListView, BankRecListController } from "@odex30_account_accountant/components/bank_reconciliation/list";
+import { useState } from "@odoo/owl";
+
+export class BankRecListUploadController extends BankRecListController {
+ static components = {
+ ...BankRecListController.components,
+ AccountFileUploader,
+ }
+}
+
+export class BankRecListUploadRenderer extends ListRenderer {
+ static template = "account.BankRecListUploadRenderer";
+ static components = {
+ ...ListRenderer.components,
+ UploadDropZone,
+ }
+
+ setup() {
+ super.setup();
+ this.dropzoneState = useState({ visible: false });
+ }
+
+ onDragStart(ev) {
+ if (ev.dataTransfer.types.includes("Files")) {
+ this.dropzoneState.visible = true
+ }
+ }
+}
+
+export const bankRecListUploadView = {
+ ...bankRecListView,
+ Controller: BankRecListUploadController,
+ Renderer: BankRecListUploadRenderer,
+ buttonTemplate: "account.BankRecListUploadButtons",
+}
+
+registry.category("views").add("bank_rec_list", bankRecListUploadView, { force: true });
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/list.xml b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/list.xml
new file mode 100644
index 0000000..0db8ef5
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/list.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ onDragStart
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/views/account_bank_statement_import_view.xml b/dev_odex30_accounting/odex30_account_bank_statement_import/views/account_bank_statement_import_view.xml
new file mode 100644
index 0000000..2fb4234
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_bank_statement_import/views/account_bank_statement_import_view.xml
@@ -0,0 +1,27 @@
+
+
+
+
+ account.journal.dashboard.kanban.inherit
+ account.journal
+
+
+
+
+ Use budgets to compare actual with expected revenues and costs
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_budget/views/budget_line_view.xml b/dev_odex30_accounting/odex30_account_budget/views/budget_line_view.xml
new file mode 100644
index 0000000..fd02080
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_budget/views/budget_line_view.xml
@@ -0,0 +1,94 @@
+
+
+
+
+ account.budget.line.search
+ budget.line
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ budget.line.list
+ budget.line
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ budget.line.form
+ budget.line
+
+
+
+
+
+
+ budget.line.pivot
+ budget.line
+
+
+
+
+
+
+
+
+
+
+ budget.line.graph
+ budget.line
+
+
+
+
+
+
+
+
+
+
+ Budgets Analysis
+ budget.line
+ list,form,pivot,graph
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_budget/views/purchase_views.xml b/dev_odex30_accounting/odex30_account_budget/views/purchase_views.xml
new file mode 100644
index 0000000..be371c9
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_budget/views/purchase_views.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ purchase.order.form.inherit.account.budget
+ purchase.order
+
+
+
+
+
+
+
+
+
+
+
+ is_above_budget
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_budget/wizards/__init__.py b/dev_odex30_accounting/odex30_account_budget/wizards/__init__.py
new file mode 100644
index 0000000..b50e0e0
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_budget/wizards/__init__.py
@@ -0,0 +1,2 @@
+
+from . import budget_split_wizard
diff --git a/dev_odex30_accounting/odex30_account_budget/wizards/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_budget/wizards/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..5f66dcc
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_budget/wizards/__pycache__/__init__.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_budget/wizards/__pycache__/budget_split_wizard.cpython-311.pyc b/dev_odex30_accounting/odex30_account_budget/wizards/__pycache__/budget_split_wizard.cpython-311.pyc
new file mode 100644
index 0000000..d09e0ce
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_budget/wizards/__pycache__/budget_split_wizard.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_budget/wizards/budget_split_wizard.py b/dev_odex30_accounting/odex30_account_budget/wizards/budget_split_wizard.py
new file mode 100644
index 0000000..b629b9b
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_budget/wizards/budget_split_wizard.py
@@ -0,0 +1,64 @@
+from dateutil.relativedelta import relativedelta
+
+from odoo import fields, models, _
+from odoo.tools import date_utils, format_date
+from itertools import product
+from functools import partial
+
+
+class BudgetSplitWizard(models.TransientModel):
+ _name = 'budget.split.wizard'
+ _description = 'Budget Split Wizard'
+
+ date_from = fields.Date(string='Start Date', required=True)
+ date_to = fields.Date(string='End Date', required=True)
+ period = fields.Selection([
+ ('month', 'Month'),
+ ('quarter', 'Quarter'),
+ ('year', 'Year')
+ ], string='Period', required=True, default='year')
+ analytical_plan_ids = fields.Many2many('account.analytic.plan', string='Analytic Plans', required=True)
+
+ def default_get(self, fields_list):
+ defaults = super().default_get(fields_list)
+ if 'date_from' in fields_list and not defaults.get('date_from'):
+ defaults['date_from'] = date_utils.start_of(fields.Date.context_today(self), 'year')
+ if 'date_to' in fields_list and not defaults.get('date_to'):
+ defaults['date_to'] = date_utils.end_of(fields.Date.context_today(self), 'year')
+ return defaults
+
+ def action_budget_split(self):
+ self.ensure_one()
+ account_dict = {rec._column_name(): rec.account_ids.ids for rec in self.analytical_plan_ids}
+ budget_lines = [(0, 0, line) for line in [dict(zip(account_dict.keys(), combination)) for combination in product(*account_dict.values())]]
+ if self.period == 'year':
+ name = partial(format_date, self.env, date_format='yyyy')
+ step = relativedelta(years=1)
+ elif self.period == 'month':
+ name = partial(format_date, self.env, date_format='MMMM yyyy')
+ step = relativedelta(months=1)
+ elif self.period == 'quarter':
+ name = partial(format_date, self.env, date_format='QQQ yyyy')
+ step = relativedelta(months=3)
+
+ budget_vals = []
+ for date_from in date_utils.date_range(self.date_from, self.date_to, step):
+ date_to = date_from + step - relativedelta(days=1)
+ if date_to > self.date_to:
+ break
+ budget_vals.append({
+ 'name': _('Budget %s', name(date_from)),
+ 'date_from': date_from,
+ 'date_to': date_to,
+ 'budget_type': 'expense',
+ 'budget_line_ids': budget_lines,
+ })
+ budgets = self.env['budget.analytic'].create(budget_vals)
+ return {
+ 'name': _('Budgets'),
+ 'view_mode': 'list',
+ 'res_model': 'budget.line',
+ 'type': 'ir.actions.act_window',
+ 'domain': [('id', 'in', budgets.budget_line_ids.ids)],
+ 'context': {'group_by': 'budget_analytic_id'},
+ }
diff --git a/dev_odex30_accounting/odex30_account_budget/wizards/budget_split_wizard_view.xml b/dev_odex30_accounting/odex30_account_budget/wizards/budget_split_wizard_view.xml
new file mode 100644
index 0000000..176812c
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_budget/wizards/budget_split_wizard_view.xml
@@ -0,0 +1,38 @@
+
+
+
+ account_budget.budget_split_wizard.form
+ budget.split.wizard
+
+
+
+
+
+
+ Generate Budget
+ ir.actions.act_window
+ budget.split.wizard
+ form
+ new
+ {'dialog_size': 'medium'}
+
+
diff --git a/dev_odex30_accounting/odex30_account_followup/__init__.py b/dev_odex30_accounting/odex30_account_followup/__init__.py
new file mode 100644
index 0000000..2ae6446
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_followup/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models
+from . import wizard
diff --git a/dev_odex30_accounting/odex30_account_followup/__manifest__.py b/dev_odex30_accounting/odex30_account_followup/__manifest__.py
new file mode 100644
index 0000000..b02471e
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_followup/__manifest__.py
@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+{
+ 'name': 'Payment Follow-up Management',
+ 'version': '1.1',
+ 'category': 'Accounting/Accounting',
+ 'description': """
+Module to automate letters for unpaid invoices, with multi-level recalls.
+=========================================================================
+
+You can define your multiple levels of recall through the menu:
+---------------------------------------------------------------
+ Configuration / Follow-up / Follow-up Levels
+
+Once it is defined, you can automatically print recalls every day through simply clicking on the menu:
+------------------------------------------------------------------------------------------------------
+ Payment Follow-Up / Send Email and letters
+
+It will generate a PDF / send emails / set activities according to the different levels
+of recall defined. You can define different policies for different companies.
+
+""",
+ 'website': 'https://www.odoo.com/app/invoicing',
+ 'depends': ['mail', 'sms', 'odex30_account_reports'],
+ 'data': [
+ 'security/account_followup_security.xml',
+ 'security/ir.model.access.csv',
+ 'security/sms_security.xml',
+ 'data/account_followup_data.xml',
+ 'data/cron.xml',
+ 'wizard/followup_manual_reminder_views.xml',
+ 'wizard/followup_missing_information.xml',
+ 'views/account_followup_views.xml',
+ 'views/account_followup_line_views.xml',
+ 'views/partner_view.xml',
+ 'views/report_followup.xml',
+ ],
+ 'demo': [
+ 'demo/account_followup_demo.xml'
+ ],
+ 'installable': True,
+ 'auto_install': True,
+ 'license': 'OEEL-1',
+ 'assets': {
+ 'odex30_account_followup.assets_followup_report': [
+ ('include', 'web._assets_helpers'),
+ 'web/static/src/scss/pre_variables.scss',
+ 'web/static/lib/bootstrap/scss/_variables.scss',
+ 'web/static/lib/bootstrap/scss/_variables-dark.scss',
+ 'web/static/lib/bootstrap/scss/_maps.scss',
+ ('include', 'web._assets_bootstrap_backend'),
+ 'web/static/fonts/fonts.scss',
+ ],
+ 'web.assets_backend': [
+ 'odex30_account_followup/static/src/components/**/*.js',
+ 'odex30_account_followup/static/src/components/**/*.scss',
+ 'odex30_account_followup/static/src/components/**/*.xml',
+ ],
+ }
+}
diff --git a/dev_odex30_accounting/odex30_account_followup/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_followup/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..d270da8
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_followup/__pycache__/__init__.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_followup/data/account_followup_data.xml b/dev_odex30_accounting/odex30_account_followup/data/account_followup_data.xml
new file mode 100644
index 0000000..07df78d
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_followup/data/account_followup_data.xml
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+ Payment Reminder
+
+ {{ object._get_followup_responsible().email_formatted }}
+ {{ (object.company_id or object._get_followup_responsible().company_id).name }} Payment Reminder - {{ object.commercial_company_name }}
+
+
+
+ Dear (),
+ Dear ,
+
+ It has come to our attention that you have an outstanding balance of
+
+ We kindly request that you take necessary action to settle this amount within the next 8 days.
+
+
+ If you have already made the payment after receiving this message, please disregard it.
+ Our accounting department is available if you require any assistance or have any questions.
+
+ Thank you for your cooperation.
+
+ Sincerely,
+
+
+
+
+
+
+ --
+
+
+
+
+
+ Dear (),
+ Dear ,
+
+ We are disappointed to see that despite sending a reminder, that your account is now seriously overdue.
+
+ It is essential that immediate payment is made, otherwise we will have to consider placing a stop on your account which means that we will no longer be able to supply your company with (goods/services). Please, take appropriate measures in order to carry out this payment in the next 8 days.
+
+ If there is a problem with paying invoice that we are not aware of, do not hesitate to contact our accounting department, so that we can resolve the matter quickly.
+
+ Details of due payments is printed below.
+
+ Best Regards,
+
+
+
+
+
+
+ --
+
+
+
+
+
+
+
+
+ 30 Days
+ 30
+
+ True
+
+
+
+
+ Customers Statement
+ account_report_followup
+ {'model': 'account.followup.report'}
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_followup/data/cron.xml b/dev_odex30_accounting/odex30_account_followup/data/cron.xml
new file mode 100644
index 0000000..4299a0f
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_followup/data/cron.xml
@@ -0,0 +1,12 @@
+
+
+
+ Account Report Followup; Execute followup
+ 1
+ days
+
+
+ model._cron_execute_followup()
+ code
+
+
diff --git a/dev_odex30_accounting/odex30_account_followup/demo/account_followup_demo.xml b/dev_odex30_accounting/odex30_account_followup/demo/account_followup_demo.xml
new file mode 100644
index 0000000..a8182b9
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_followup/demo/account_followup_demo.xml
@@ -0,0 +1,64 @@
+
+
+
+
+ 40 Days
+ 40
+
+
+ True
+
+ Call the customer on the phone!
+
+Urge the customer to pay overdue invoices, still not settled despite several reminders.
+
+Give 8 days for full payment, or legal action for the recovery of the debt will be taken without further notice.
+
+
+
+ Fourth reminder followup
+
+ {{ object._get_followup_responsible().email_formatted }}
+ {{ (object.company_id or object._get_followup_responsible().company_id).name }} Payment Reminder - {{ object.commercial_company_name }}
+
+
+
+ Dear (),
+ Dear ,
+
+ Despite several reminders, your account is still not settled.
+ Unless full payment is made in next 8 days, then legal action for the recovery of the debt will be taken without further notice.
+ I trust that this action will prove unnecessary and details of due payments is printed below.
+ In case of any queries concerning this matter, do not hesitate to contact our accounting department.
+
+ Best Regards,
+
+
+
+
+
+
+
+ --
+
+
+
+
\n"
+" Dear (),\n"
+" Dear ,\n"
+" \n"
+" It has come to our attention that you have an outstanding balance of \n"
+" \n"
+" We kindly request that you take necessary action to settle this amount within the next 8 days.\n"
+" \n"
+"
\n"
+" If you have already made the payment after receiving this message, please disregard it.\n"
+" Our accounting department is available if you require any assistance or have any questions.\n"
+" \n"
+" Thank you for your cooperation.\n"
+" \n"
+" Sincerely,\n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" --\n"
+" \n"
+" \n"
+" \n"
+" \n"
+"
\n"
+" Dear (),\n"
+" Dear ,\n"
+" \n"
+" Despite several reminders, your account is still not settled.\n"
+" Unless full payment is made in next 8 days, then legal action for the recovery of the debt will be taken without further notice.\n"
+" I trust that this action will prove unnecessary and details of due payments is printed below.\n"
+" In case of any queries concerning this matter, do not hesitate to contact our accounting department.\n"
+" \n"
+" Best Regards,\n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" --\n"
+" \n"
+" \n"
+" \n"
+"
\n"
+" Dear (),\n"
+" Dear ,\n"
+" \n"
+" We are disappointed to see that despite sending a reminder, that your account is now seriously overdue.\n"
+" \n"
+" It is essential that immediate payment is made, otherwise we will have to consider placing a stop on your account which means that we will no longer be able to supply your company with (goods/services). Please, take appropriate measures in order to carry out this payment in the next 8 days.\n"
+" \n"
+" If there is a problem with paying invoice that we are not aware of, do not hesitate to contact our accounting department, so that we can resolve the matter quickly.\n"
+" \n"
+" Details of due payments is printed below.\n"
+" \n"
+" Best Regards,\n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" --\n"
+" \n"
+" \n"
+" \n"
+"
\n"
+"
\n"
+" "
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.res_partner_view_form
+msgid "Customer Statement"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.res_partner_view_form
+msgid ""
+"Preferred address for follow-up reports. Selected by default when you "
+"send reminders about overdue invoices."
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.template_followup_report
+msgid ""
+"\n"
+" Pending Invoices\n"
+" "
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.constraint,message:odex30_account_followup.constraint_odex30_account_followup_followup_line_uniq_name
+msgid ""
+"A follow-up action name must be unique. This name is already set to another "
+"action."
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields.selection,name:odex30_account_followup.selection__odex30_account_followup_followup_line__activity_default_responsible_type__account_manager
+msgid "Account Manager"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.actions.server,name:odex30_account_followup.ir_cron_auto_post_draft_entry_ir_actions_server
+msgid "Account Report Followup; Execute followup"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+msgid "Actions"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_tree
+msgid "Activity"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+msgid "Activity Notes"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__activity_type_id
+msgid "Activity Type"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.template_followup_report
+msgid "Add a note"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Add contacts to notify..."
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__additional_follower_ids
+msgid "Add followers"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.followup_filter_info_template
+msgid "Address"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__type
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__type
+msgid "Address Type"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__join_invoices
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__join_invoices
+msgid "Attach Invoices"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__attachment_ids
+msgid "Attachment"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__auto_execute
+#: model:ir.model.fields.selection,name:odex30_account_followup.selection__res_partner__followup_reminder_type__automatic
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_line_filter
+msgid "Automatic"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__body_has_template_value
+msgid "Body content is the same as the template"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__can_edit_body
+msgid "Can Edit Body"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Cancel"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.missing_information_view_form
+msgid "Close"
+msgstr ""
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid "Communication"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__company_id
+msgid "Company"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model,name:odex30_account_followup.model_res_partner
+msgid "Contact"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+msgid "Content Template"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__body
+msgid "Contents"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__create_uid
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__create_uid
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_missing_information_wizard__create_uid
+msgid "Created by"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__create_date
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__create_date
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_missing_information_wizard__create_date
+msgid "Created on"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.followup_filter_info_template
+msgid "Customer ref:"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.actions.client,name:odex30_account_followup.action_odex30_account_followup
+msgid "Customers Statement"
+msgstr ""
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid "Date"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.followup_filter_info_template
+msgid "Date:"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.constraint,message:odex30_account_followup.constraint_odex30_account_followup_followup_line_days_uniq
+msgid "Days of the follow-up lines must be different per company"
+msgstr ""
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid ""
+"Dear %s,\n"
+"\n"
+"\n"
+"Exception made if there was a mistake of ours, it seems that the following amount stays unpaid. Please, take appropriate measures in order to carry out this payment in the next 8 days.\n"
+"\n"
+"Would your payment have been carried out after this mail was sent, please ignore this message. Do not hesitate to contact our accounting department.\n"
+"\n"
+"Best Regards,\n"
+"\n"
+msgstr ""
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid ""
+"Dear client, we kindly remind you that you still have unpaid invoices. "
+"Please check them and take appropriate action. %s"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.actions.act_window,help:odex30_account_followup.action_odex30_account_followup_line_definition_form
+msgid "Define follow-up levels and their related actions"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.followup_filter_info_template
+msgid "Demo Ref"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__name
+msgid "Description"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,help:odex30_account_followup.field_odex30_account_followup_followup_line__activity_default_responsible_type
+msgid ""
+"Determine who will be assigned to the activity:\n"
+"- Follow-up Responsible (default)\n"
+"- Salesperson: Sales Person defined on the invoice\n"
+"- Account Manager: Sales Person defined on the customer"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__display_name
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__display_name
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_missing_information_wizard__display_name
+msgid "Display Name"
+msgstr ""
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid "Due Date"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__delay
+msgid "Due Days"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__email
+msgid "Email"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Email Recipients"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Email Subject"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__email_recipient_ids
+msgid "Extra Recipients"
+msgstr ""
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/res_partner.py:0
+msgid "Follow-up %(partner)s - %(date)s.pdf"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields.selection,name:odex30_account_followup.selection__res_partner__type__followup
+msgid "Follow-up Address"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model,name:odex30_account_followup.model_odex30_account_followup_followup_line
+msgid "Follow-up Criteria"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_account_move_line__followup_line_id
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__followup_line_id
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__followup_line_id
+msgid "Follow-up Level"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.actions.act_window,name:odex30_account_followup.action_odex30_account_followup_line_definition_form
+#: model:ir.ui.menu,name:odex30_account_followup.odex30_account_followup_menu
+msgid "Follow-up Levels"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model,name:odex30_account_followup.model_odex30_account_followup_report
+msgid "Follow-up Report"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields.selection,name:odex30_account_followup.selection__odex30_account_followup_followup_line__activity_default_responsible_type__followup
+msgid "Follow-up Responsible"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__followup_status
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__followup_status
+msgid "Follow-up Status"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_tree
+msgid "Follow-up Steps"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.report_followup_print_all
+msgid "Follow-up details"
+msgstr ""
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid "Follow-up letter generated"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model,name:odex30_account_followup.model_odex30_account_followup_missing_information_wizard
+msgid "Followup missing information wizard"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.actions.act_window,help:odex30_account_followup.action_odex30_account_followup_line_definition_form
+msgid ""
+"For each step, specify the actions to be taken and delay in days. It is\n"
+" possible to use print and e-mail templates to send specific messages to\n"
+" the customer."
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:mail.template,name:odex30_account_followup.demo_followup_email_template_4
+msgid "Fourth reminder followup"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__has_moves
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__has_moves
+msgid "Has Moves"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__id
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__id
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_missing_information_wizard__id
+msgid "ID"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,help:odex30_account_followup.field_odex30_account_followup_followup_line__additional_follower_ids
+msgid ""
+"If set, those users will be added as followers on the partner and receive "
+"notifications about any email reply made by the partner on the reminder "
+"email."
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields.selection,name:odex30_account_followup.selection__res_partner__followup_status__in_need_of_action
+msgid "In need of action"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_partner_property_form_followup
+msgid "Invoice Follow-ups"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.template_followup_report
+msgid "Invoices Analysis"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__is_mail_template_editor
+msgid "Is Editor"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model,name:odex30_account_followup.model_account_move_line
+msgid "Journal Item"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Kind reminder!"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__lang
+msgid "Language"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__write_uid
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__write_uid
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_missing_information_wizard__write_uid
+msgid "Last Updated by"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__write_date
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__write_date
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_missing_information_wizard__write_date
+msgid "Last Updated on"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Letter/Email Content"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__mail_template_id
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__template_id
+msgid "Mail Template"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields.selection,name:odex30_account_followup.selection__res_partner__followup_reminder_type__manual
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_line_filter
+msgid "Manual"
+msgstr ""
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/res_partner.py:0
+#: code:addons/odex30_account_followup/wizard/followup_missing_information.py:0
+msgid "Missing information"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.line_template
+msgid "Name"
+msgstr ""
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/res_partner.py:0
+msgid "Next Reminder Date set to %s"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__followup_next_action_date
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__followup_next_action_date
+msgid "Next reminder"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields.selection,name:odex30_account_followup.selection__res_partner__followup_status__no_action_needed
+msgid "No action needed"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,help:odex30_account_followup.field_res_partner__followup_next_action_date
+#: model:ir.model.fields,help:odex30_account_followup.field_res_users__followup_next_action_date
+msgid ""
+"No follow-up action will be taken before this date.\n"
+" Sending a reminder will set this date depending on the levels configuration, and you can change it manually."
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__activity_note
+msgid "Note"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+msgid "Notification"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,help:odex30_account_followup.field_odex30_account_followup_manual_reminder__lang
+msgid ""
+"Optional translation language (ISO code) to select when sending out an "
+"email. If not set, the english version will be used. This should usually be "
+"a placeholder expression that provides the appropriate language, e.g. {{ "
+"object.partner_id.lang }}."
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+msgid "Options"
+msgstr ""
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+#: model:ir.model.fields,field_description:odex30_account_followup.field_account_move_line__invoice_origin
+msgid "Origin"
+msgstr ""
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/res_partner.py:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.customer_statements_search_view
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_partner_property_form_followup
+msgid "Overdue Invoices"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__partner_id
+msgid "Partner"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:mail.template,name:odex30_account_followup.email_template_followup_1
+msgid "Payment Reminder"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__print
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Print"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.actions.report,name:odex30_account_followup.action_report_followup
+msgid "Print Follow-up Letter"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.actions.server,name:odex30_account_followup.action_account_reports_customer_statements_do_followup
+msgid "Process Follow-ups"
+msgstr ""
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid "Reference"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+msgid "Remind"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__followup_reminder_type
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__followup_reminder_type
+msgid "Reminders"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__render_model
+msgid "Rendering Model"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model,name:odex30_account_followup.model_ir_actions_report
+msgid "Report Action"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.customer_statements_search_view
+msgid "Requires Follow-up"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__activity_default_responsible_type
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__followup_responsible_id
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__followup_responsible_id
+msgid "Responsible"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "SMS"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields.selection,name:odex30_account_followup.selection__odex30_account_followup_followup_line__activity_default_responsible_type__salesperson
+msgid "Salesperson"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__create_activity
+msgid "Schedule Activity"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_line_filter
+msgid "Search Follow-up"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:mail.template,name:odex30_account_followup.demo_followup_email_template_2
+msgid "Second reminder followup"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_partner_property_form_followup
+msgid "Send"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Send & Print"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__send_email
+msgid "Send Email"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__send_sms
+msgid "Send SMS Message"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.actions.act_window,name:odex30_account_followup.manual_reminder_action
+msgid "Send and Print"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__sms
+msgid "Sms"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__sms_body
+msgid "Sms Body"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Sms Content"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__sms_template_id
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__sms_template_id
+msgid "Sms Template"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__subject
+msgid "Subject"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__activity_summary
+msgid "Summary"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.table_header_template_followup_report
+msgid "Table Header"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.line_template
+msgid "Table Value"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,help:odex30_account_followup.field_account_move_line__invoice_origin
+msgid "The document(s) that generated the invoice."
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,help:odex30_account_followup.field_odex30_account_followup_followup_line__delay
+msgid ""
+"The number of days after the due date of the invoice to wait before sending "
+"the reminder. Can be negative if you want to send the reminder before the "
+"invoice due date."
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,help:odex30_account_followup.field_res_partner__followup_responsible_id
+#: model:ir.model.fields,help:odex30_account_followup.field_res_users__followup_responsible_id
+msgid ""
+"The responsible assigned to manual followup activities, if defined in the "
+"level."
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.customer_statements_tree_view
+msgid "Total"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__total_all_due
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__total_all_due
+msgid "Total All Due"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__total_all_overdue
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__total_all_overdue
+msgid "Total All Overdue"
+msgstr ""
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__total_due
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__total_due
+msgid "Total Due"
+msgstr ""
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__total_overdue
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__total_overdue
+msgid "Total Overdue"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__unpaid_invoice_ids
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__unpaid_invoice_ids
+msgid "Unpaid Invoice"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__unpaid_invoices_count
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__unpaid_invoices_count
+msgid "Unpaid Invoices Count"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__unreconciled_aml_ids
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__unreconciled_aml_ids
+msgid "Unreconciled Aml"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.missing_information_view_form
+msgid "View partners"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.missing_information_view_form
+msgid ""
+"We were not able to process some of the automated follow-up actions due to "
+"missing information on the partners."
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model.fields.selection,name:odex30_account_followup.selection__res_partner__followup_status__with_overdue_invoices
+msgid "With overdue invoices"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:ir.model,name:odex30_account_followup.model_odex30_account_followup_manual_reminder
+msgid "Wizard for sending manual reminders to clients"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Write your message here..."
+msgstr ""
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid ""
+"You are trying to send an Email, but no follow-up contact has any email "
+"address set for customer '%s'"
+msgstr ""
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid ""
+"You are trying to send an SMS, but no follow-up contact has any mobile/phone"
+" number set for customer '%s'"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+msgid "days after due date"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+msgid "e.g. First Reminder Email"
+msgstr ""
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid "payment reminder"
+msgstr ""
+
+#. module: odex30_account_followup
+#: model:mail.template,subject:odex30_account_followup.demo_followup_email_template_2
+#: model:mail.template,subject:odex30_account_followup.demo_followup_email_template_4
+#: model:mail.template,subject:odex30_account_followup.email_template_followup_1
+msgid ""
+"{{ (object.company_id or object._get_followup_responsible().company_id).name"
+" }} Payment Reminder - {{ object.commercial_company_name }}"
+msgstr ""
diff --git a/dev_odex30_accounting/odex30_account_followup/i18n/ar.po b/dev_odex30_accounting/odex30_account_followup/i18n/ar.po
new file mode 100644
index 0000000..3e7966e
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_followup/i18n/ar.po
@@ -0,0 +1,1235 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * odex30_account_followup
+#
+# Translators:
+# Wil Odoo, 2024
+# Malaz Abuidris , 2025
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 18.0+e\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2025-11-21 18:47+0000\n"
+"PO-Revision-Date: 2024-09-25 09:44+0000\n"
+"Last-Translator: Malaz Abuidris , 2025\n"
+"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n"
+"Language: ar\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
+"&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid "%(company)s Payment Reminder - %(partner)s"
+msgstr "%(company)s تذكير الدفع - %(partner)s "
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup.py:0
+msgid "%(delay)s days (copy of %(name)s)"
+msgstr "%(delay)s أيام (نسخة من %(name)s) "
+
+#. module: odex30_account_followup
+#: model:ir.actions.report,print_report_name:odex30_account_followup.action_report_followup
+msgid "'Follow-up ' + object.display_name"
+msgstr "'المتابعة' + object.display_name "
+
+#. module: odex30_account_followup
+#: model:odex30_account_followup.followup.line,name:odex30_account_followup.demo_followup_line1
+msgid "15 Days"
+msgstr "15 يوماً"
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.followup_filter_info_template
+msgid "2023-09-06"
+msgstr "2023-09-06"
+
+#. module: odex30_account_followup
+#: model:odex30_account_followup.followup.line,name:odex30_account_followup.demo_followup_line2
+msgid "30 Days"
+msgstr "30 يوماً"
+
+#. module: odex30_account_followup
+#: model:odex30_account_followup.followup.line,name:odex30_account_followup.demo_followup_line3
+msgid "40 Days"
+msgstr "40 يوم "
+
+#. module: odex30_account_followup
+#: model:odex30_account_followup.followup.line,name:odex30_account_followup.demo_followup_line4
+msgid "50 Days"
+msgstr "50 يوم "
+
+#. module: odex30_account_followup
+#: model:odex30_account_followup.followup.line,name:odex30_account_followup.demo_followup_line5
+msgid "60 Days"
+msgstr "60 يوم "
+
+#. module: odex30_account_followup
+#: model:mail.template,body_html:odex30_account_followup.email_template_followup_1
+msgid ""
+"
\n"
+"
\n"
+" Dear (),\n"
+" Dear ,"
+"\n"
+" \n"
+" It has come to our attention that you have an "
+"outstanding balance of \n"
+" \n"
+" We kindly request that you take necessary action to "
+"settle this amount within the next 8 days.\n"
+" \n"
+"
\n"
+" If you have already made the payment after receiving "
+"this message, please disregard it.\n"
+" Our accounting department is available if you "
+"require any assistance or have any questions.\n"
+" \n"
+" Thank you for your cooperation.\n"
+" \n"
+" Sincerely,\n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" --\n"
+" \n"
+" \n"
+" \n"
+" \n"
+"
\n"
+" Dear (),\n"
+" Dear ,"
+"\n"
+" \n"
+" Despite several reminders, your account is still not "
+"settled.\n"
+" Unless full payment is made in next 8 days, then "
+"legal action for the recovery of the debt will be taken without further "
+"notice.\n"
+" I trust that this action will prove unnecessary and "
+"details of due payments is printed below.\n"
+" In case of any queries concerning this matter, do "
+"not hesitate to contact our accounting department.\n"
+" \n"
+" Best Regards,\n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" --\n"
+" \n"
+" \n"
+" \n"
+"
\n"
+"
\n"
+" "
+msgstr ""
+"
\n"
+"
\n"
+" عزيزنا "
+"()،\n"
+" عزيزنا ،\n"
+" \n"
+" على الرغم من التذكيرات العديدة، لا يزال الحساب غير "
+"مسوى.\n"
+" سيتم اتخاذ الإجراءات القانونية لتحصيل الدين دون أي "
+"إشعارات إضافية، إلا في حال دفع المبلغ كاملاً خلال الـ8 أيام القادمة.\n"
+" نحن على ثقة من عدم حاجتنا إلى اتخاذ ذلك الإجراء، "
+"وستجد تفاصيل الدفع مطبوعة أدناه.\n"
+" لا تتردد في النواصل مع قسم المحاسبة إذا كانت لديك أي "
+"أسئلة أو استفسارات متعلقة بالأمر.\n"
+" \n"
+" مع أطيب التحيات،\n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" --\n"
+" \n"
+" \n"
+" \n"
+"
\n"
+" Dear (),\n"
+" Dear ,"
+"\n"
+" \n"
+" We are disappointed to see that despite sending a "
+"reminder, that your account is now seriously overdue.\n"
+" \n"
+" It is essential that immediate payment is made, "
+"otherwise we will have to consider placing a stop on your account which "
+"means that we will no longer be able to supply your company with (goods/"
+"services). Please, take appropriate measures in order to carry out this "
+"payment in the next 8 days.\n"
+" \n"
+" If there is a problem with paying invoice that we "
+"are not aware of, do not hesitate to contact our accounting department, so "
+"that we can resolve the matter quickly.\n"
+" \n"
+" Details of due payments is printed below.\n"
+" \n"
+" Best Regards,\n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" --\n"
+" \n"
+" \n"
+" \n"
+"
\n"
+"
\n"
+" "
+msgstr ""
+"
\n"
+"
\n"
+" عزيزنا "
+"()،\n"
+" عزيزنا ،\n"
+" \n"
+" يؤسفنا أن نرى أنه على الرغم من التذكير الذي قمنا "
+"بإرساله، لا يزال حسابك متأخر الدفع كثيراً.\n"
+" \n"
+" يجب أن تتم عملية الدفع فوراً وإلا سنضطر إلى إيقاف "
+"حسابك، مما يعني أننا لن نكون قادرين على تزويد شركتك (بالبضائع/الخدمات).يرجى "
+"اتخاذ الإجراءات اللازمة لدفع ذلك المبلغ خلال الـ 8 أيام المقبلة.\n"
+" \n"
+" إذا كنت تواجه مشكلة لا نعلم عنها في دفع الفاتورة، لا "
+"تتردد في التواصل مع قسم المحاسبة لدينا حتى نتمكن من حل المشكلة بسرعة.\n"
+" \n"
+" تفاصيل الدفع المتأخر مطبوعة أدناه.\n"
+" \n"
+" مع أطيب التحيات،\n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" \n"
+" --\n"
+" \n"
+" \n"
+" \n"
+"
\n"
+"
\n"
+" "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.res_partner_view_form
+msgid "Customer Statement"
+msgstr "كشف حساب العميل "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.res_partner_view_form
+msgid ""
+"Preferred address for follow-up reports. Selected by default when you "
+"send reminders about overdue invoices."
+msgstr ""
+"العنوان المفضل لتقارير المتابعة. يتم اختياره افتراضياً عندما تقوم "
+"بإرسال تذكيرات حول الفواتير المتأخرة. "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.template_followup_report
+msgid ""
+"\n"
+" Pending Invoices\n"
+" "
+msgstr ""
+"\n"
+" فواتير قيد الانتظار\n"
+" "
+
+#. module: odex30_account_followup
+#: model:ir.model.constraint,message:odex30_account_followup.constraint_odex30_account_followup_followup_line_uniq_name
+msgid ""
+"A follow-up action name must be unique. This name is already set to another "
+"action."
+msgstr ""
+"يجب أن يكون اسم إجراء المتابعة فريداً. هذا الاسم مستخدَم في إجراء آخر بالفعل. "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields.selection,name:odex30_account_followup.selection__odex30_account_followup_followup_line__activity_default_responsible_type__account_manager
+msgid "Account Manager"
+msgstr "إدارة حساب المستخدم"
+
+#. module: odex30_account_followup
+#: model:ir.actions.server,name:odex30_account_followup.ir_cron_auto_post_draft_entry_ir_actions_server
+msgid "Account Report Followup; Execute followup"
+msgstr "متابعة تقرير الحساب؛ لتنفيذ المتابعة "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+msgid "Actions"
+msgstr "الإجراءات"
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_tree
+msgid "Activity"
+msgstr "النشاط"
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+msgid "Activity Notes"
+msgstr "ملاحظات النشاط "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__activity_type_id
+msgid "Activity Type"
+msgstr "نوع النشاط"
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.template_followup_report
+msgid "Add a note"
+msgstr "إضافة ملاحظة"
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Add contacts to notify..."
+msgstr "إضافة جهات اتصال لإشعارهم..."
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__additional_follower_ids
+msgid "Add followers"
+msgstr "إضافة متابعين "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.followup_filter_info_template
+msgid "Address"
+msgstr "العنوان"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__type
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__type
+msgid "Address Type"
+msgstr "نوع العنوان"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__join_invoices
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__join_invoices
+msgid "Attach Invoices"
+msgstr "إرفاق الفواتير "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__attachment_ids
+msgid "Attachment"
+msgstr "مرفق"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__auto_execute
+#: model:ir.model.fields.selection,name:odex30_account_followup.selection__res_partner__followup_reminder_type__automatic
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_line_filter
+msgid "Automatic"
+msgstr "تلقائي"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__body_has_template_value
+msgid "Body content is the same as the template"
+msgstr "محتوى المتن هو نفس القالب "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__can_edit_body
+msgid "Can Edit Body"
+msgstr "يمكن تحرير النص "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Cancel"
+msgstr "إلغاء"
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.missing_information_view_form
+msgid "Close"
+msgstr "إغلاق"
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid "Communication"
+msgstr "التواصل "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__company_id
+msgid "Company"
+msgstr "الشركة "
+
+#. module: odex30_account_followup
+#: model:ir.model,name:odex30_account_followup.model_res_partner
+msgid "Contact"
+msgstr "جهة الاتصال"
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+msgid "Content Template"
+msgstr "قالب المحتوى "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__body
+msgid "Contents"
+msgstr "المحتويات"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__create_uid
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__create_uid
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_missing_information_wizard__create_uid
+msgid "Created by"
+msgstr "أنشئ بواسطة"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__create_date
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__create_date
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_missing_information_wizard__create_date
+msgid "Created on"
+msgstr "أنشئ في"
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.followup_filter_info_template
+msgid "Customer ref:"
+msgstr "مرجع العميل: "
+
+#. module: odex30_account_followup
+#: model:ir.actions.client,name:odex30_account_followup.action_odex30_account_followup
+msgid "Customers Statement"
+msgstr "كشوفات حسابات العملاء "
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid "Date"
+msgstr "التاريخ"
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.followup_filter_info_template
+msgid "Date:"
+msgstr "التاريخ:"
+
+#. module: odex30_account_followup
+#: model:ir.model.constraint,message:odex30_account_followup.constraint_odex30_account_followup_followup_line_days_uniq
+msgid "Days of the follow-up lines must be different per company"
+msgstr "يجب أن تكون أيام بنود المتابعة مختلفة لدى كل شركة "
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid ""
+"Dear %s,\n"
+"\n"
+"\n"
+"Exception made if there was a mistake of ours, it seems that the following "
+"amount stays unpaid. Please, take appropriate measures in order to carry out "
+"this payment in the next 8 days.\n"
+"\n"
+"Would your payment have been carried out after this mail was sent, please "
+"ignore this message. Do not hesitate to contact our accounting department.\n"
+"\n"
+"Best Regards,\n"
+"\n"
+msgstr ""
+"عزيزنا %s،\n"
+"\n"
+"\n"
+"يبدو أن المبلغ التالي لا يزال غير مدفوع، إلا إذا كان الخطأ من طرفنا. الرجاء "
+"اتخاذ الإجراءات المناسبة لترحيل هذا الدفع في غضون الـ8 أيام القادمة.\n"
+"\n"
+"إذا تم ترحيل الدفع بعد أن تم إرسال هذا البريد الإلكتروني، رجاءً تجاهَل هذه "
+"الرسالة. لا تتردد في التواصل مع قسم المحاسبة لدينا.\n"
+"\n"
+"مع أطيب التحيات،\n"
+"\n"
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid ""
+"Dear client, we kindly remind you that you still have unpaid invoices. "
+"Please check them and take appropriate action. %s"
+msgstr ""
+"عزيزنا العميل، نود تذكيرك بأنه لا تزال لديك فواتير غير مدفوعة. يرجى التحقق "
+"منها ثم اتخاذ الإجراء اللازم. %s "
+
+#. module: odex30_account_followup
+#: model_terms:ir.actions.act_window,help:odex30_account_followup.action_odex30_account_followup_line_definition_form
+msgid "Define follow-up levels and their related actions"
+msgstr "قم بتحديد مستويات المتابعة والإجراءات المتعلقة بها "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.followup_filter_info_template
+msgid "Demo Ref"
+msgstr "المرجع التجريبي "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:account_followup.field_odex30_account_followup_followup_line__name
+msgid "Description"
+msgstr "الوصف"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,help:odex30_account_followup.field_odex30_account_followup_followup_line__activity_default_responsible_type
+msgid ""
+"Determine who will be assigned to the activity:\n"
+"- Follow-up Responsible (default)\n"
+"- Salesperson: Sales Person defined on the invoice\n"
+"- Account Manager: Sales Person defined on the customer"
+msgstr ""
+"قم بتحديد من سيتم تعيينه للقيام بالنشاط: \n"
+"- مسؤول المتابعة (افتراضي) \n"
+"- مندوب المبيعات: مندوب المبيعات المحدد في الفاتورة \n"
+"- مدير الحساب: مندوب المبيعات المحدد لدى العميل "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__display_name
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__display_name
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_missing_information_wizard__display_name
+msgid "Display Name"
+msgstr "اسم العرض "
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid "Due Date"
+msgstr "موعد إجراء المكالمة "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__delay
+msgid "Due Days"
+msgstr "أيام الاستحقاق"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__email
+msgid "Email"
+msgstr "البريد الإلكتروني"
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Email Recipients"
+msgstr "مستلمو البريد الإلكتروني "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Email Subject"
+msgstr "موضوع البريد الإلكتروني "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__email_recipient_ids
+msgid "Extra Recipients"
+msgstr "المستلمون الإضافيون "
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/res_partner.py:0
+msgid "Follow-up %(partner)s - %(date)s.pdf"
+msgstr "%(partner)s المتابعة - %(date)s.pdf "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields.selection,name:odex30_account_followup.selection__res_partner__type__followup
+msgid "Follow-up Address"
+msgstr "عنوان المتابعة "
+
+#. module: odex30_account_followup
+#: model:ir.model,name:odex30_account_followup.model_odex30_account_followup_followup_line
+msgid "Follow-up Criteria"
+msgstr "معايير المتابعة"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_account_move_line__followup_line_id
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__followup_line_id
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__followup_line_id
+msgid "Follow-up Level"
+msgstr "مستوى المتابعة"
+
+#. module: odex30_account_followup
+#: model:ir.actions.act_window,name:odex30_account_followup.action_odex30_account_followup_line_definition_form
+#: model:ir.ui.menu,name:odex30_account_followup.odex30_account_followup_menu
+msgid "Follow-up Levels"
+msgstr "مستويات المتابعة"
+
+#. module: odex30_account_followup
+#: model:ir.model,name:odex30_account_followup.model_odex30_account_followup_report
+msgid "Follow-up Report"
+msgstr "تقرير المتابعة"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields.selection,name:odex30_account_followup.selection__odex30_account_followup_followup_line__activity_default_responsible_type__followup
+msgid "Follow-up Responsible"
+msgstr "مسؤول المتابعة"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__followup_status
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__followup_status
+msgid "Follow-up Status"
+msgstr "حالة المتابعة "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_tree
+msgid "Follow-up Steps"
+msgstr "خطوات المتابعة"
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.report_followup_print_all
+msgid "Follow-up details"
+msgstr "تفاصيل المتابعة "
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid "Follow-up letter generated"
+msgstr "تم إنشاء خطاب المتابعة "
+
+#. module: odex30_account_followup
+#: model:ir.model,name:odex30_account_followup.model_odex30_account_followup_missing_information_wizard
+msgid "Followup missing information wizard"
+msgstr "معالج متابعة المعلومات الناقصة "
+
+#. module: odex30_account_followup
+#: model_terms:ir.actions.act_window,help:odex30_account_followup.action_odex30_account_followup_line_definition_form
+msgid ""
+"For each step, specify the actions to be taken and delay in days. It is\n"
+" possible to use print and e-mail templates to send specific "
+"messages to\n"
+" the customer."
+msgstr ""
+"لكل خطوة، حدد الإجراءات التي يجب اتخاذها ومهلة التأخير بالأيام.\n"
+" من الممكن استخدام قوالب الطباعة والبريد الإلكتروني لإرسال رسائل "
+"محددة\n"
+" للعميل. "
+
+#. module: odex30_account_followup
+#: model:mail.template,name:odex30_account_followup.demo_followup_email_template_4
+msgid "Fourth reminder followup"
+msgstr "متابعة التذكير الرابع "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__has_moves
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__has_moves
+msgid "Has Moves"
+msgstr "يحتوي على تحركات "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__id
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__id
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_missing_information_wizard__id
+msgid "ID"
+msgstr "المُعرف"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,help:odex30_account_followup.field_odex30_account_followup_followup_line__additional_follower_ids
+msgid ""
+"If set, those users will be added as followers on the partner and receive "
+"notifications about any email reply made by the partner on the reminder "
+"email."
+msgstr ""
+"إذا تم تعيينه، فستتم إضافة هؤلاء المستخدمين كمتابعين للوكيل وسيتلقون إشعارات "
+"حول أي رد عبر البريد الإلكتروني يرسله الوكيل على رسالة التذكير الإلكترونية. "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields.selection,name:odex30_account_followup.selection__res_partner__followup_status__in_need_of_action
+msgid "In need of action"
+msgstr "بحاجة إلى اتخاذ إجراء "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_partner_property_form_followup
+msgid "Invoice Follow-ups"
+msgstr "المتابعة بشأن الفواتير "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.template_followup_report
+msgid "Invoices Analysis"
+msgstr "تحليل الفواتير"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__is_mail_template_editor
+msgid "Is Editor"
+msgstr "محرر "
+
+#. module: odex30_account_followup
+#: model:ir.model,name:odex30_account_followup.model_account_move_line
+msgid "Journal Item"
+msgstr "عنصر اليومية"
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Kind reminder!"
+msgstr "تذكير! "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__lang
+msgid "Language"
+msgstr "اللغة"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__write_uid
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__write_uid
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_missing_information_wizard__write_uid
+msgid "Last Updated by"
+msgstr "آخر تحديث بواسطة"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__write_date
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__write_date
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_missing_information_wizard__write_date
+msgid "Last Updated on"
+msgstr "آخر تحديث في"
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Letter/Email Content"
+msgstr "محتوى الرسالة/البريد الإلكتروني "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__mail_template_id
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__template_id
+msgid "Mail Template"
+msgstr "قالب البريد الإلكتروني "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields.selection,name:odex30_account_followup.selection__res_partner__followup_reminder_type__manual
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_line_filter
+msgid "Manual"
+msgstr "يدوي"
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/res_partner.py:0
+#: code:addons/odex30_account_followup/wizard/followup_missing_information.py:0
+msgid "Missing information"
+msgstr "المعلومات الناقصة "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.line_template
+msgid "Name"
+msgstr "الاسم"
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/res_partner.py:0
+msgid "Next Reminder Date set to %s"
+msgstr "تم تعيين تاريخ التذكير التالي لـ %s"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__followup_next_action_date
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__followup_next_action_date
+msgid "Next reminder"
+msgstr "التذكير التالي"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields.selection,name:odex30_account_followup.selection__res_partner__followup_status__no_action_needed
+msgid "No action needed"
+msgstr "لا حاجة لاتخاذ إجراء "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,help:odex30_account_followup.field_res_partner__followup_next_action_date
+#: model:ir.model.fields,help:odex30_account_followup.field_res_users__followup_next_action_date
+msgid ""
+"No follow-up action will be taken before this date.\n"
+" Sending a reminder will set this date depending on the "
+"levels configuration, and you can change it manually."
+msgstr ""
+"لن يتم اتخاذ أي إجراء متابعة قبل هذا التاريخ.\n"
+" سيؤدي إرسال تذكير إلى تعيين هذا التاريخ بناءً على تهيئة "
+"المستويات، ويمكنك تغييره يدوياً. "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__activity_note
+msgid "Note"
+msgstr "الملاحظات"
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+msgid "Notification"
+msgstr "إشعار "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,help:odex30_account_followup.field_odex30_account_followup_manual_reminder__lang
+msgid ""
+"Optional translation language (ISO code) to select when sending out an "
+"email. If not set, the english version will be used. This should usually be "
+"a placeholder expression that provides the appropriate language, e.g. "
+"{{ object.partner_id.lang }}."
+msgstr ""
+"لغة الترجمة الاختيارية (كود ISO) لاختيارها عند إرسال بريد إلكتروني. إذا لم "
+"يتم تعيينها، سوف تُستخدم النسخة باللغة الإنجليزية. عادة ما يكون ذلك تمثيلاً "
+"للعنصر النائب المسؤول عن التزويد باللغة المناسبة، مثال: "
+"{{ object.partner_id.lang }}. "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+msgid "Options"
+msgstr "الخيارات"
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+#: model:ir.model.fields,field_description:odex30_account_followup.field_account_move_line__invoice_origin
+msgid "Origin"
+msgstr "الأصل "
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/res_partner.py:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.customer_statements_search_view
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_partner_property_form_followup
+msgid "Overdue Invoices"
+msgstr "الفواتير المتأخرة "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__partner_id
+msgid "Partner"
+msgstr "الشريك"
+
+#. module: odex30_account_followup
+#: model:mail.template,name:odex30_account_followup.email_template_followup_1
+msgid "Payment Reminder"
+msgstr "تذكير الدفع "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__print
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Print"
+msgstr "طباعة"
+
+#. module: odex30_account_followup
+#: model:ir.actions.report,name:odex30_account_followup.action_report_followup
+msgid "Print Follow-up Letter"
+msgstr "طباعة رسالة المتابعة"
+
+#. module: odex30_account_followup
+#: model:ir.actions.server,name:odex30_account_followup.action_account_reports_customer_statements_do_followup
+msgid "Process Follow-ups"
+msgstr "المتابعة بشأن العملية "
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid "Reference"
+msgstr "الرقم المرجعي "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+msgid "Remind"
+msgstr "تذكير "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__followup_reminder_type
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__followup_reminder_type
+msgid "Reminders"
+msgstr "التذكيرات"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__render_model
+msgid "Rendering Model"
+msgstr "نموذج التكوين "
+
+#. module: odex30_account_followup
+#: model:ir.model,name:odex30_account_followup.model_ir_actions_report
+msgid "Report Action"
+msgstr "إجراء التقرير"
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.customer_statements_search_view
+msgid "Requires Follow-up"
+msgstr "يتطلب المتابعة "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__activity_default_responsible_type
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__followup_responsible_id
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__followup_responsible_id
+msgid "Responsible"
+msgstr "المسؤول "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "SMS"
+msgstr "الرسائل النصية القصيرة "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields.selection,name:odex30_account_followup.selection__odex30_account_followup_followup_line__activity_default_responsible_type__salesperson
+msgid "Salesperson"
+msgstr "مندوب المبيعات "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__create_activity
+msgid "Schedule Activity"
+msgstr "جدولة نشاط "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_line_filter
+msgid "Search Follow-up"
+msgstr "البحث عن المتابعات "
+
+#. module: odex30_account_followup
+#: model:mail.template,name:odex30_account_followup.demo_followup_email_template_2
+msgid "Second reminder followup"
+msgstr "متابعة التذكير الثاني "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_partner_property_form_followup
+msgid "Send"
+msgstr "إرسال"
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Send & Print"
+msgstr "إرسال وطباعة"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__send_email
+msgid "Send Email"
+msgstr "إرسال بريد إلكتروني"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__send_sms
+msgid "Send SMS Message"
+msgstr "إرسال رسالة نصية قصيرة "
+
+#. module: odex30_account_followup
+#: model:ir.actions.act_window,name:odex30_account_followup.manual_reminder_action
+msgid "Send and Print"
+msgstr "إرسال وطباعة"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__sms
+msgid "Sms"
+msgstr "رسالة نصية قصيرة "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__sms_body
+msgid "Sms Body"
+msgstr "متن الرسالة النصية القصيرة "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Sms Content"
+msgstr "محتوى الرسالة النصية القصيرة "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__sms_template_id
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__sms_template_id
+msgid "Sms Template"
+msgstr "قالب الرسائل النصية القصيرة "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_manual_reminder__subject
+msgid "Subject"
+msgstr "الموضوع "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_odex30_account_followup_followup_line__activity_summary
+msgid "Summary"
+msgstr "الملخص"
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.table_header_template_followup_report
+msgid "Table Header"
+msgstr "ترويسة الجدول "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.line_template
+msgid "Table Value"
+msgstr "قيمة الجدول "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,help:odex30_account_followup.field_account_move_line__invoice_origin
+msgid "The document(s) that generated the invoice."
+msgstr "المستند (المستندات) التي أنشأت الفاتورة. "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,help:odex30_account_followup.field_odex30_account_followup_followup_line__delay
+msgid ""
+"The number of days after the due date of the invoice to wait before sending "
+"the reminder. Can be negative if you want to send the reminder before the "
+"invoice due date."
+msgstr ""
+"أقصى مهلة من الأيام بعد تاريخ استحقاق الفاتورة قبل إرسال رسالة التذكير. يمكن "
+"أن تكون القيمة سالبة إذا أردت إرسال تذكير قبل تاريخ استحقاق الفاتورة. "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,help:odex30_account_followup.field_res_partner__followup_responsible_id
+#: model:ir.model.fields,help:odex30_account_followup.field_res_users__followup_responsible_id
+msgid ""
+"The responsible assigned to manual followup activities, if defined in the "
+"level."
+msgstr ""
+"المسؤول الذي تم تعيينه لأنشطة المتابعة اليدوية، إذا كان محدداً في المستوى. "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.customer_statements_tree_view
+msgid "Total"
+msgstr "الإجمالي"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__total_all_due
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__total_all_due
+msgid "Total All Due"
+msgstr "إجمالي المستحقات "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__total_all_overdue
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__total_all_overdue
+msgid "Total All Overdue"
+msgstr "إجمالي المستحقات المتأخرة "
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__total_due
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__total_due
+msgid "Total Due"
+msgstr "الإجمالي المستحق"
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__total_overdue
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__total_overdue
+msgid "Total Overdue"
+msgstr "الإجمالي المتأخر"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__unpaid_invoice_ids
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__unpaid_invoice_ids
+msgid "Unpaid Invoice"
+msgstr "فاتورة غير مدفوعة"
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__unpaid_invoices_count
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__unpaid_invoices_count
+msgid "Unpaid Invoices Count"
+msgstr "عدد الفواتير غير المدفوعة "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_partner__unreconciled_aml_ids
+#: model:ir.model.fields,field_description:odex30_account_followup.field_res_users__unreconciled_aml_ids
+msgid "Unreconciled Aml"
+msgstr "المبلغ غير المسوى "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.missing_information_view_form
+msgid "View partners"
+msgstr "عرض الشركاء "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.missing_information_view_form
+msgid ""
+"We were not able to process some of the automated follow-up actions due to "
+"missing information on the partners."
+msgstr ""
+"لم نتمكن من معالجة بعض إجراءات المتابعة المؤتمتة نظراً لبعض المعلومات الناقصة "
+"لدى الشركاء. "
+
+#. module: odex30_account_followup
+#: model:ir.model.fields.selection,name:odex30_account_followup.selection__res_partner__followup_status__with_overdue_invoices
+msgid "With overdue invoices"
+msgstr "مع الفواتير المتأخرة"
+
+#. module: odex30_account_followup
+#: model:ir.model,name:odex30_account_followup.model_odex30_account_followup_manual_reminder
+msgid "Wizard for sending manual reminders to clients"
+msgstr "معالج لإرسال التذكيرات اليدوية إلى العملاء "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.manual_reminder_view_form
+msgid "Write your message here..."
+msgstr "اكتب رسالتك هنا... "
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid ""
+"You are trying to send an Email, but no follow-up contact has any email "
+"address set for customer '%s'"
+msgstr ""
+"أنت تحاول إرسال بريد إلكتروني، ولكن ليس لجهات اتصال المتابعة عناوين بريد "
+"إلكتروني معدّة للعميل \"%s\" "
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid ""
+"You are trying to send an SMS, but no follow-up contact has any mobile/phone "
+"number set for customer '%s'"
+msgstr ""
+"أنت تحاول إرسال رسالة نصية قصيرة، ولكن ليس لجهات اتصال المتابعة أرقام هواتف "
+"معدّة للعميل \"%s\" "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+msgid "days after due date"
+msgstr "أيام بعد تاريخ الاستحقاق "
+
+#. module: odex30_account_followup
+#: model_terms:ir.ui.view,arch_db:odex30_account_followup.view_odex30_account_followup_followup_line_form
+msgid "e.g. First Reminder Email"
+msgstr "مثال: البريد الإلكتروني التذكيري الأول "
+
+#. module: odex30_account_followup
+#. odoo-python
+#: code:addons/odex30_account_followup/models/odex30_account_followup_report.py:0
+msgid "payment reminder"
+msgstr "تذكير بالدفع "
+
+#. module: odex30_account_followup
+#: model:mail.template,subject:odex30_account_followup.demo_followup_email_template_2
+#: model:mail.template,subject:odex30_account_followup.demo_followup_email_template_4
+#: model:mail.template,subject:odex30_account_followup.email_template_followup_1
+msgid ""
+"{{ (object.company_id or "
+"object._get_followup_responsible().company_id).name }} Payment Reminder - "
+"{{ object.commercial_company_name }}"
+msgstr ""
+"{{ (object.company_id or "
+"object._get_followup_responsible().company_id).name }} Payment Reminder - "
+"{{ object.commercial_company_name }}"
+
+#~ msgid ""
+#~ "
\n"
+#~ "
\n"
+#~ " Dear "
+#~ "(),\n"
+#~ " Dear ,\n"
+#~ " \n"
+#~ " It has come to our attention that you "
+#~ "have an outstanding balance of \n"
+#~ " \n"
+#~ " We kindly request that you take necessary action "
+#~ "to settle this amount within the next 8 days.\n"
+#~ " \n"
+#~ "
\n"
+#~ " If you have already made the payment after "
+#~ "receiving this message, please disregard it.\n"
+#~ " Our accounting department is available if you "
+#~ "require any assistance or have any questions.\n"
+#~ " \n"
+#~ " Thank you for your cooperation.\n"
+#~ " \n"
+#~ " Sincerely,\n"
+#~ " \n"
+#~ " \n"
+#~ " \n"
+#~ " \n"
+#~ " \n"
+#~ " \n"
+#~ " --\n"
+#~ " \n"
+#~ " \n"
+#~ " \n"
+#~ " \n"
+#~ "
\n"
+#~ " "
+#~ msgstr ""
+#~ "
\n"
+#~ "
\n"
+#~ " عزيزنا "
+#~ "()،\n"
+#~ " عزيزنا ،\n"
+#~ " \n"
+#~ " يبدو أنه لديك مبلغ مستحق قيمته \n"
+#~ " \n"
+#~ " نرجو منك أخذ الإجراءات اللازمة لتسوية هذا المبلغ "
+#~ "خلال الـ8 أيام القادمة.\n"
+#~ " \n"
+#~ "
+
+
diff --git a/dev_odex30_accounting/odex30_account_followup/static/src/components/download_close_wizard_action/download_close_wizard_action.js b/dev_odex30_accounting/odex30_account_followup/static/src/components/download_close_wizard_action/download_close_wizard_action.js
new file mode 100644
index 0000000..d8a621b
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_followup/static/src/components/download_close_wizard_action/download_close_wizard_action.js
@@ -0,0 +1,16 @@
+/** @odoo-module **/
+
+import { browser } from "@web/core/browser/browser";
+import { registry } from "@web/core/registry";
+
+export async function downloadAndCloseFollowupWizardAction(env, action) {
+ const url = action.params.url
+ try {
+ browser.open(url);
+ }
+ finally {
+ return { type: "ir.actions.act_window_close" };
+ }
+}
+
+registry.category("actions").add("close_followup_wizard", downloadAndCloseFollowupWizardAction);
diff --git a/dev_odex30_accounting/odex30_account_followup/tests/__init__.py b/dev_odex30_accounting/odex30_account_followup/tests/__init__.py
new file mode 100644
index 0000000..bd8f67d
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_followup/tests/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+
+from . import common
+from . import test_account_followup
+from . import test_followup_report
diff --git a/dev_odex30_accounting/odex30_account_followup/tests/common.py b/dev_odex30_accounting/odex30_account_followup/tests/common.py
new file mode 100644
index 0000000..4ee50a1
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_followup/tests/common.py
@@ -0,0 +1,14 @@
+from odoo.addons.account.tests.common import AccountTestInvoicingCommon
+
+
+class TestAccountFollowupCommon(AccountTestInvoicingCommon):
+ def assertPartnerFollowup(self, partner, status, line):
+ partner.invalidate_recordset(['followup_status', 'followup_line_id'])
+ # Since we are querying multiple times with data changes in the same transaction (for the purpose of tests),
+ # we need to invalidated the cache in database
+ self.env.cr.cache.pop('res_partner_all_followup', None)
+ res = partner._query_followup_data()
+ self.assertEqual(res.get(partner.id, {}).get('followup_status'), status)
+ self.assertEqual(res.get(partner.id, {}).get('followup_line_id'), line.id if line else None)
+ self.assertEqual(partner.followup_status, status or 'no_action_needed')
+ self.assertEqual(partner.followup_line_id.id if partner.followup_line_id else None, line.id if line else None)
diff --git a/dev_odex30_accounting/odex30_account_followup/tests/test_account_followup.py b/dev_odex30_accounting/odex30_account_followup/tests/test_account_followup.py
new file mode 100644
index 0000000..df72c8b
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_followup/tests/test_account_followup.py
@@ -0,0 +1,530 @@
+# -*- coding: utf-8 -*-
+from unittest.mock import patch
+from freezegun import freeze_time
+from odoo import Command, fields
+from odoo.tests import tagged
+from odoo.addons.odex30_account_followup.tests.common import TestAccountFollowupCommon
+from odoo.addons.mail.tests.common import MailCommon
+from dateutil.relativedelta import relativedelta
+
+
+@tagged('post_install', '-at_install')
+class TestAccountFollowupReports(TestAccountFollowupCommon, MailCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.env['account_followup.followup.line'].search([]).unlink()
+
+ wkhtmltopdf_patcher = patch.object(
+ cls.env.registry['ir.actions.report'],
+ '_run_wkhtmltopdf',
+ lambda *args, **kwargs: b"0"
+ )
+ wkhtmltopdf_patcher.start()
+ cls.addClassCleanup(wkhtmltopdf_patcher.stop)
+
+ def create_followup(self, delay):
+ return self.env['account_followup.followup.line'].create({
+ 'name': f'followup {delay}',
+ 'delay': delay,
+ 'send_email': False,
+ 'company_id': self.company_data['company'].id
+ })
+
+ def create_invoice(self, date, partner = None):
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': date,
+ 'invoice_date_due': date,
+ 'partner_id': partner.id if partner else self.partner_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 500,
+ 'tax_ids': [],
+ })]
+ })
+ invoice.action_post()
+ return invoice
+
+ def test_followup_responsible(self):
+ """
+ Test that the responsible is correctly set
+ """
+ self.first_followup_line = self.create_followup(delay=-10)
+
+ user1 = self.env['res.users'].create({
+ 'name': 'A User',
+ 'login': 'a_user',
+ 'email': 'a@user.com',
+ 'groups_id': [(6, 0, [self.env.ref('account.group_account_user').id])]
+ })
+ user2 = self.env['res.users'].create({
+ 'name': 'Another User',
+ 'login': 'another_user',
+ 'email': 'another@user.com',
+ 'groups_id': [(6, 0, [self.env.ref('account.group_account_user').id])]
+ })
+ # 1- no info, use current user
+ self.assertEqual(self.partner_a._get_followup_responsible(), self.env.user)
+
+ # 2- set invoice user
+ invoice1 = self.init_invoice('out_invoice', partner=self.partner_a,
+ invoice_date=fields.Date.from_string('2000-01-01'),
+ amounts=[2000])
+ invoice2 = self.init_invoice('out_invoice', partner=self.partner_a,
+ invoice_date=fields.Date.from_string('2000-01-01'),
+ amounts=[1000])
+ invoice1.invoice_user_id = user1
+ invoice2.invoice_user_id = user2
+ (invoice1 + invoice2).action_post()
+ # Should pick invoice_user_id of the most delayed move, with highest residual amount in case of tie (invoice1)
+ self.assertEqual(self.partner_a._get_followup_responsible(), user1)
+ # If user1 is archived, it shouldn't be selected as responsible
+ user1.active = False
+ self.assertEqual(self.partner_a._get_followup_responsible(), self.env.user)
+ user1.active = True
+
+ self.partner_a.followup_line_id = self.first_followup_line
+
+ # 3- A followup responsible user has been set on the partner
+ self.partner_a.followup_responsible_id = user2
+ self.assertEqual(self.partner_a._get_followup_responsible(), user2)
+
+ # 4- Modify the default responsible on followup level
+ self.partner_a.followup_line_id.activity_default_responsible_type = 'salesperson'
+ self.assertEqual(self.partner_a._get_followup_responsible(), user1)
+
+ self.partner_a.followup_line_id.activity_default_responsible_type = 'account_manager'
+ self.partner_a.user_id = user2
+ self.assertEqual(self.partner_a._get_followup_responsible(), self.partner_a.user_id)
+
+ def test_followup_line_and_status(self):
+ self.first_followup_line = self.create_followup(delay=-10)
+ self.second_followup_line = self.create_followup(delay=10)
+ self.third_followup_line = self.create_followup(delay=15)
+
+ self.create_invoice('2022-01-02')
+
+ with freeze_time('2021-12-20'):
+ # Today < due date + delay first followup level (negative delay -> reminder before due date)
+ self.assertPartnerFollowup(self.partner_a, 'no_action_needed', self.first_followup_line)
+
+ with freeze_time('2021-12-24'):
+ # Today = due date + delay first followup level
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', self.first_followup_line)
+
+ # followup_next_action_date not exceeded but no invoice is overdue,
+ # we should not be in status 'with_overdue_invoices' but 'no action needed'
+ self.partner_a.followup_next_action_date = fields.Date.from_string('2021-12-25')
+ self.assertPartnerFollowup(self.partner_a, 'no_action_needed', self.first_followup_line)
+
+ with freeze_time('2022-01-13'):
+ # Today > due date + delay second followup level but first followup level not processed yet
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', self.first_followup_line)
+
+ self.partner_a._execute_followup_partner(options={'snailmail': False})
+ # Due date exceeded but first followup level processed
+ # followup_next_action_date set in 20 days (delay 2nd level - delay 1st level = 10 - (-10) = 20)
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', self.second_followup_line)
+ self.assertEqual(self.partner_a.followup_next_action_date, fields.Date.from_string('2022-02-02'))
+
+ with freeze_time('2022-02-03'):
+ # followup_next_action_date exceeded and invoice not reconciled yet
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', self.second_followup_line)
+ # execute second followup
+ self.partner_a._execute_followup_partner(options={'snailmail': False})
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', self.third_followup_line)
+ self.assertEqual(self.partner_a.followup_next_action_date, fields.Date.from_string('2022-02-08'))
+
+ with freeze_time('2022-02-09'):
+ # followup_next_action_date exceeded and invoice not reconciled yet
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', self.third_followup_line)
+ # execute third followup
+ self.partner_a._execute_followup_partner(options={'snailmail': False})
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', self.third_followup_line)
+ self.assertEqual(self.partner_a.followup_next_action_date, fields.Date.from_string('2022-02-14'))
+
+ with freeze_time('2022-02-15'):
+ # followup_next_action_date exceeded and invoice not reconciled yet
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', self.third_followup_line)
+ # executing the third followup again should do nothing as all the aml are linked to it
+ followup_executed = self.partner_a._execute_followup_partner(options={'snailmail': False})
+ self.assertFalse(followup_executed)
+
+ # create a new overdue invoice
+ self.create_invoice('2022-01-03')
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', self.third_followup_line)
+ # the third followup should be executed
+ followup_executed = self.partner_a._execute_followup_partner(options={'snailmail': False})
+ self.assertTrue(followup_executed)
+ self.assertEqual(self.partner_a.followup_next_action_date, fields.Date.from_string('2022-02-20'))
+
+ self.env['account.payment.register'].create({
+ 'line_ids': self.partner_a.unreconciled_aml_ids,
+ })._create_payments()
+ self.assertPartnerFollowup(self.partner_a, None, None)
+
+ def test_followup_multiple_invoices(self):
+ followup_10 = self.create_followup(delay=10)
+ followup_15 = self.create_followup(delay=15)
+ followup_30 = self.create_followup(delay=30)
+
+ self.create_invoice('2022-01-01')
+ self.create_invoice('2022-01-02')
+
+ # 9 days are not passed yet for the first followup level, current delay is 10-0=10
+ with freeze_time('2022-01-10'):
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_10)
+
+ # 10 days passed, current delay is 10-0=10, need to take action
+ with freeze_time('2022-01-11'):
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', followup_10)
+ self.partner_a._execute_followup_partner(options={'snailmail': False})
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_15)
+
+ # action taken 4 days ago, current delay is 15-10=5, nothing needed
+ with freeze_time('2022-01-15'):
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_15)
+
+ # action taken 5 days ago, current delay is 15-10=5, need to take action
+ with freeze_time('2022-01-16'):
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', followup_15)
+ self.partner_a._execute_followup_partner(options={'snailmail': False})
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_30)
+
+ # action taken 14 days ago, current delay is 30-15=15, nothing needed
+ with freeze_time('2022-01-30'):
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_30)
+
+ # action taken 15 days ago, current delay is 30-15=15, need to take action
+ with freeze_time('2022-01-31'):
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', followup_30)
+ self.partner_a._execute_followup_partner(options={'snailmail': False})
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_30)
+
+ # action taken 13 days ago, current delay is 15 (same on repeat), nothing needed
+ with freeze_time('2022-02-14'):
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_30)
+
+ # action taken 14 days ago, current delay is 15 (same on repeat), need to take action
+ with freeze_time('2022-02-15'):
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', followup_30)
+ self.partner_a._execute_followup_partner(options={'snailmail': False})
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_30)
+
+ def test_followup_multiple_invoices_with_first_payment(self):
+ # Test the behavior of multiple invoices when the first one is paid
+ followup_10 = self.create_followup(delay=10)
+ followup_15 = self.create_followup(delay=15)
+
+ invoice_01 = self.create_invoice('2022-01-01')
+ self.create_invoice('2022-01-02')
+
+ # 9 days are not passed yet for the first followup level, current delay is 10-0=10
+ with freeze_time('2022-01-10'):
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_10)
+
+ # 10 days passed, current delay is 10-0=10, need to take action
+ with freeze_time('2022-01-11'):
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', followup_10)
+ # followup level for the second invoice shouldn't change since it's only 9 days overdue
+ self.partner_a._execute_followup_partner(options={'snailmail': False})
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_15)
+
+ self.env['account.payment.register'].create({
+ 'line_ids': invoice_01.line_ids.filtered(lambda l: l.display_type == 'payment_term'),
+ })._create_payments()
+
+ # partner followup level goes back to 10 days after paying the first invoice
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_10)
+
+ # action taken 4 days ago, current delay is 15-10=5, nothing needed
+ with freeze_time('2022-01-15'):
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_10)
+
+ # action taken 5 days ago, current delay is 15-10=5, need to take action
+ with freeze_time('2022-01-16'):
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', followup_10)
+ self.partner_a._execute_followup_partner(options={'snailmail': False})
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_15)
+
+ def test_followup_multiple_invoices_with_last_payment(self):
+ # Test the behavior of multiple invoices when the last one is paid
+ # Should behave exactly like test_followup_multiple_invoices_with_first_payment
+ # because the followup is done at the same time.
+ followup_10 = self.create_followup(delay=10)
+ followup_15 = self.create_followup(delay=15)
+ followup_30 = self.create_followup(delay=30)
+
+ self.create_invoice('2022-01-01')
+ invoice_02 = self.create_invoice('2022-01-02')
+
+ # 9 days are not passed yet for the first followup level, current delay is 10-0=10
+ with freeze_time('2022-01-10'):
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_10)
+
+ # 10 days passed, current delay is 10-0=10, need to take action
+ with freeze_time('2022-01-11'):
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', followup_10)
+ self.partner_a._execute_followup_partner(options={'snailmail': False})
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_15)
+
+ self.env['account.payment.register'].create({
+ 'line_ids': invoice_02.line_ids.filtered(lambda l: l.display_type == 'payment_term'),
+ })._create_payments()
+
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_15)
+
+ # action taken 4 days ago, current delay is 15-10=5, nothing needed
+ with freeze_time('2022-01-15'):
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_15)
+
+ # action taken 5 days ago, current delay is 15-10=5, need to take action
+ with freeze_time('2022-01-16'):
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', followup_15)
+ self.partner_a._execute_followup_partner(options={'snailmail': False})
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_30)
+
+ def test_followup_status_entry_lines(self):
+ """
+ Creating an entry should not affect the followups as there is no concept of due date with this flow.
+ """
+ self.followup_line = self.create_followup(delay=10)
+
+ with freeze_time('2022-01-02'):
+ invoice = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2022-01-02'),
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'line1',
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'debit': 500.0,
+ 'credit': 0.0,
+ }),
+ Command.create({
+ 'name': 'counterpart line',
+ 'account_id': self.company_data['default_account_receivable'].id,
+ 'debit': 0.0,
+ 'credit': 500.0,
+ })
+ ]
+ })
+ invoice.action_post()
+
+ with freeze_time('2022-01-13'):
+ self.assertPartnerFollowup(self.partner_a, 'no_action_needed', self.followup_line)
+
+ def test_followup_contacts(self):
+ followup_contacts = self.partner_a._get_all_followup_contacts()
+ billing_contact = self.env['res.partner'].browse(self.partner_a.address_get(['invoice'])['invoice'])
+ self.assertEqual(billing_contact, followup_contacts)
+
+ followup_partner_1 = self.env['res.partner'].create({
+ 'name': 'followup partner 1',
+ 'parent_id': self.partner_a.id,
+ 'type': 'followup',
+ })
+ followup_partner_2 = self.env['res.partner'].create({
+ 'name': 'followup partner 2',
+ 'parent_id': self.partner_a.id,
+ 'type': 'followup',
+ })
+ expected_partners = followup_partner_1 + followup_partner_2
+ followup_contacts = self.partner_a._get_all_followup_contacts()
+ self.assertEqual(expected_partners, followup_contacts)
+
+ def test_followup_cron(self):
+ cron = self.env.ref('account_followup.ir_cron_auto_post_draft_entry')
+ followup_10 = self.create_followup(delay=10)
+ followup_10.auto_execute = True
+
+ self.create_invoice('2022-01-01')
+
+ # Check that no followup is automatically done if there is no action needed
+ with freeze_time('2022-01-10'), patch.object(type(self.env['res.partner']), '_send_followup') as patched:
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_10)
+ cron.method_direct_trigger()
+ patched.assert_not_called()
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_10)
+
+ # Check that the action is taken one and only one time when there is an action needed
+ with freeze_time('2022-01-11'), patch.object(type(self.env['res.partner']), '_send_followup') as patched:
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', followup_10)
+ cron.method_direct_trigger()
+ patched.assert_called_once()
+ self.assertPartnerFollowup(self.partner_a, 'with_overdue_invoices', followup_10)
+
+ def test_onchange_residual_amount(self):
+ '''
+ Test residual onchange on account move lines: the residual amount is
+ computed using an sql query. This test makes sure the computation also
+ works properly during onchange (on records having a NewId).
+ '''
+ invoice = self.create_invoice('2016-01-01')
+ self.create_invoice('2016-01-02')
+
+ self.env['account.payment.register'].with_context(active_ids=invoice.ids, active_model='account.move').create({
+ 'payment_date': invoice.date,
+ 'amount': 100,
+ })._create_payments()
+
+ self.assertRecordValues(self.partner_a, [{'total_due': 900.0}])
+ self.assertRecordValues(self.partner_a.unreconciled_aml_ids.sorted(), [
+ {'amount_residual_currency': 500.0},
+ {'amount_residual_currency': 400.0},
+ ])
+
+ self.assertRecordValues(self.partner_a.unreconciled_aml_ids.sorted(), [
+ {'amount_residual_currency': 500.0},
+ {'amount_residual_currency': 400.0},
+ ])
+
+ def test_compute_total_due(self):
+ self.create_invoice('2016-01-01')
+ self.create_invoice('2017-01-01')
+ self.create_invoice(fields.Date.today() + relativedelta(months=1))
+ self.env['account.move'].create([{
+ 'move_type': 'in_invoice',
+ 'invoice_date': date,
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 500,
+ 'tax_ids': [],
+ })]
+ } for date in ('2016-01-01', '2017-01-01', fields.Date.today() + relativedelta(months=1))]).action_post()
+
+ self.assertRecordValues(self.partner_a, [{
+ 'total_due': 1500.0,
+ 'total_overdue': 1000.0,
+ 'total_all_due': 0.0,
+ 'total_all_overdue': 0.0,
+ }])
+
+ def test_send_followup_no_due_date(self):
+ """
+ test sending a followup report with an empty due date field
+ """
+ self.create_followup(delay=0)
+ self.create_invoice('2022-01-01')
+ self.partner_a.unreconciled_aml_ids.write({
+ 'date_maturity': False,
+ })
+
+ self.partner_a._execute_followup_partner(options={
+ 'partner_id': self.partner_a.id,
+ 'manual_followup': True,
+ 'snailmail': False,
+ })
+
+ def test_followup_copy_data(self):
+ """
+ Test followup report by:
+ - Duplicating a single record with no default
+ - Duplicating several records at the same time
+ """
+ followup_50 = self.create_followup(delay=50)
+ followup_60 = self.create_followup(delay=60)
+
+ # Duplicate single record with no default values
+ followup_50_duplicate = followup_50.copy()
+ self.assertTrue(followup_50_duplicate)
+ self.assertEqual(followup_50_duplicate.name, f'75 days (copy of {followup_50.name})')
+
+ # Duplicate multiple records at the same time with no default values
+ multiple_followup_records = followup_50 + followup_60
+ multiple_followup_records_duplicate = multiple_followup_records.copy()
+ self.assertTrue(multiple_followup_records_duplicate)
+ self.assertEqual(multiple_followup_records_duplicate[0].name, f'90 days (copy of {followup_50.name})')
+ self.assertEqual(multiple_followup_records_duplicate[1].name, f'105 days (copy of {followup_60.name})')
+
+ def test_manual_reminder_get_template_mail_addresses(self):
+ """
+ When opening account_followup.manual_reminder, the partner should always be in `email_recipients_ids`
+ When adding a template, the template's partner_to, email_cc and email_to should be added to `email_recipient_ids` as well
+ """
+ mail_partner = self.env['res.partner'].create({
+ 'name': 'Mai Lang',
+ 'email': 'mail.ang@test.com',
+ })
+ mail_cc = self.env['res.partner'].create({
+ 'name': 'John Carmac',
+ 'email': 'john.carmac@example.me',
+ })
+
+ mail_template = self.env['mail.template'].create({
+ 'name': 'reminder',
+ 'model_id': self.env['ir.model']._get_id('res.partner'),
+ 'email_cc': mail_cc.email,
+ })
+
+ reminder = self.env['account_followup.manual_reminder'].with_context(
+ active_model='res.partner',
+ active_ids=mail_partner.id,
+ ).create({'template_id': mail_template.id})
+
+ self.assertTrue(mail_partner in reminder.email_recipient_ids, "Mai Lang should be in the Email Recipients List")
+
+ reminder.template_id = mail_template
+
+ self.assertTrue(mail_cc in reminder.email_recipient_ids, "John Carmac should be in the Email Recipients list.")
+ self.assertTrue(mail_partner in reminder.email_recipient_ids, "Mai Lang should still be in the Email Recipients List")
+
+ def test_overdue_invoices_action_domain_includes_children_partners(self):
+ """
+ When checking overdue invoices for a company (partner), the action [action_open_overdue_entries] domain should also include
+ the overdue invoices of its children partners.
+ """
+
+ # Step 1: Create contacts
+ parent_contact = self.env['res.partner'].create({
+ 'name': 'Parent Contact',
+ 'is_company': True,
+ })
+ child_contact = self.env['res.partner'].create({
+ 'name': 'Child Contact',
+ 'parent_id': parent_contact.id,
+ })
+
+ # Step 2: Create an invoice for the child contact
+ invoice_date = fields.Date.today() - relativedelta(months=1)
+ invoice = self.create_invoice(invoice_date, child_contact)
+ invoice.invoice_date_due = invoice_date
+
+ # Step 3: Verify follow-up status and overdue invoices of the parent contact
+ action = parent_contact.action_open_overdue_entries()
+ overdue_invoices = self.env['account.move'].search(action['domain'])
+ self.assertIn(invoice.id, overdue_invoices.ids)
+ self.assertEqual(parent_contact.followup_status, 'with_overdue_invoices')
+
+ def test_followup_template_recipients_with_cron(self):
+ """
+ tests that when a mail_cc is defined on a template,
+ even if the action is ran from a cron (and de facto from `res.partner.send_followup_email`)
+ the email is correctly sent
+ Completes `test_manual_reminder_get_template_mail_addresses`
+ """
+ self.partner_a.email = "test@test.com"
+ mail_cc = self.env['res.partner'].create({
+ 'name': 'John Carmac',
+ 'email': 'john.carmac@example.me',
+ })
+ mail_template = self.env['mail.template'].create({
+ 'name': 'reminder',
+ 'model_id': self.env['ir.model']._get_id('res.partner'),
+ 'email_cc': mail_cc.email,
+ })
+ followup_10 = self.create_followup(delay=10)
+ followup_10.mail_template_id = mail_template
+ self.create_invoice('2025-05-01')
+
+ with freeze_time('2025-05-12'), self.mock_mail_gateway(mail_unlink_sent=False):
+ options = {
+ 'followup_line': followup_10,
+ 'partner_id': self.partner_a.id,
+ }
+ self.partner_a.send_followup_email(options=options)
+ self.assertMailMail(mail_cc, 'sent', author=self.env.user.partner_id)
diff --git a/dev_odex30_accounting/odex30_account_followup/tests/test_followup_report.py b/dev_odex30_accounting/odex30_account_followup/tests/test_followup_report.py
new file mode 100644
index 0000000..d7b5331
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_followup/tests/test_followup_report.py
@@ -0,0 +1,1113 @@
+# -*- coding: utf-8 -*-
+from contextlib import contextmanager
+from freezegun import freeze_time
+from unittest.mock import patch
+
+from odoo.tests import Form, tagged
+from odoo.addons.odex30_account_reports.tests.common import TestAccountReportsCommon
+from odoo.addons.odex30_account_followup.tests.common import TestAccountFollowupCommon
+from odoo.tools.misc import file_open
+from odoo import Command, fields
+
+
+@tagged('post_install', '-at_install')
+class TestAccountFollowupReports(TestAccountReportsCommon, TestAccountFollowupCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.partner_a.email = 'partner_a@mypartners.xyz'
+ cls.report = cls.env.ref('odex30_account_reports.followup_report')
+
+ def test_followup_report(self):
+ ''' Test report lines when printing the follow-up report. '''
+ # Init options.
+ report = self.env['account.followup.report']
+ options = {
+ 'partner_id': self.partner_a.id,
+ 'multi_currency': True,
+ }
+
+ # 2016-01-01: First invoice, partially paid.
+
+ invoice_1 = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 500,
+ 'tax_ids': [],
+ })]
+ })
+ invoice_1.action_post()
+
+ payment_1 = self.env['account.move'].create({
+ # pylint: disable=C0326
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 200.0, 'account_id': self.company_data['default_account_receivable'].id}),
+ (0, 0, {'debit': 200.0, 'credit': 0.0, 'account_id': self.company_data['default_journal_bank'].default_account_id.id}),
+ ],
+ })
+ payment_1.action_post()
+
+ (payment_1 + invoice_1).line_ids\
+ .filtered(lambda line: line.account_id == self.company_data['default_account_receivable'])\
+ .reconcile()
+
+ with freeze_time('2016-01-01'):
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ report._get_followup_report_lines(options),
+ # Name Date, Due Date, Doc. Total Due
+ [ 0, 1, 2, 3, 5],
+ [
+ ('INV/2016/00001', '01/01/2016', '01/01/2016', '', 300.0),
+ ('', '', '', '', 300.0),
+ ],
+ options,
+ )
+
+ # 2016-01-05: Credit note due at 2016-01-10.
+
+ invoice_2 = self.env['account.move'].create({
+ 'move_type': 'out_refund',
+ 'invoice_date': '2016-01-05',
+ 'invoice_date_due': '2016-01-10',
+ 'partner_id': self.partner_a.id,
+ 'invoice_payment_term_id': False,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 200,
+ 'tax_ids': [],
+ })]
+ })
+ invoice_2.action_post()
+
+ with freeze_time('2016-01-05'):
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ report._get_followup_report_lines(options),
+ # Name Date, Due Date, Doc. Total Due
+ [ 0, 1, 2, 3, 5],
+ [
+ ('RINV/2016/00001', '01/05/2016', '01/10/2016', '', -200.0),
+ ('INV/2016/00001', '01/01/2016', '01/01/2016', '', 300.0),
+ ('', '', '', '', 100.0),
+ ('', '', '', '', 300.0),
+ ],
+ options,
+ )
+
+ # 2016-01-15: Draft invoice + previous credit note reached the date_maturity + first invoice reached the delay
+ # of the first followup level.
+
+ self.env['account.move'].create({
+ 'move_type': 'out_refund',
+ 'invoice_date': '2016-01-15',
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 1000,
+ 'tax_ids': [],
+ })]
+ })
+
+ with freeze_time('2016-01-15'):
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ report._get_followup_report_lines(options),
+ # Name Date, Due Date, Doc. Total Due
+ [ 0, 1, 2, 3, 5],
+ [
+ ('RINV/2016/00001', '01/05/2016', '01/10/2016', '', -200.0),
+ ('INV/2016/00001', '01/01/2016', '01/01/2016', '', 300.0),
+ ('', '', '', '', 100.0),
+ ('', '', '', '', 100.0),
+ ],
+ options,
+ )
+
+ # Trigger the followup report notice.
+
+ invoice_attachments = self.env['ir.attachment']
+ for invoice in invoice_1 + invoice_2:
+ invoice_attachment = self.env['ir.attachment'].create({
+ 'name': 'some_attachment.pdf',
+ 'res_id': invoice.id,
+ 'res_model': 'account.move',
+ 'datas': 'test',
+ 'type': 'binary',
+ })
+ invoice_attachments += invoice_attachment
+ invoice._message_set_main_attachment_id(invoice_attachment)
+
+ self.partner_a._compute_unpaid_invoices()
+ options.update(
+ attachment_ids=invoice_attachments.ids,
+ email=True,
+ manual_followup=True,
+ join_invoices=True,
+ )
+ with patch.object(type(self.env['mail.mail']), 'unlink', lambda self: None):
+ with patch.object(self.env.registry['account.report'], 'export_to_pdf', autospec=True, side_effect=lambda *args, **kwargs: {'file_name': 'fake_partner_ledger.pdf', 'file_content': b'', 'file_type': 'pdf'}):
+ self.partner_a.execute_followup(options)
+ sent_attachments = self.env['mail.message'].search([('partner_ids', '=', self.partner_a.id)]).attachment_ids
+ self.assertEqual(sent_attachments.mapped('name'), ['some_attachment.pdf', 'some_attachment.pdf', f'{self.partner_a.name} - fake_partner_ledger.pdf'])
+
+ options.update(
+ attachment_ids=[],
+ email=False,
+ join_invoices=False,
+ )
+ with (
+ patch.object(self.env.registry['mail.mail'], 'unlink', lambda self: None),
+ patch.object(
+ self.env.registry['res.partner'],
+ '_get_partner_account_report_attachment',
+ autospec=True,
+ side_effect=lambda *args, **kwargs: invoice_attachments[0],
+ ),
+ ):
+ self.partner_a.execute_followup(options)
+ self.assertEqual(options['attachment_ids'], [invoice_attachments.ids[0]], "The report attachment should be included regardless of join_invoices and email checkboxes.")
+
+ attachaments_domain = [('attachment_ids', '=', attachment.id) for attachment in invoice_attachments]
+ mail = self.env['mail.mail'].search([('recipient_ids', '=', self.partner_a.id)] + attachaments_domain)
+ self.assertTrue(mail, "A payment reminder email should have been sent.")
+
+ def test_followup_report_journal_option_disabled(self):
+ options = {
+ 'partner_id': self.partner_a.id,
+ 'manual_followup': True,
+ }
+
+ self.report.filter_journals = False
+ with patch.object(self.env.registry['account.report'], 'export_to_pdf', autospec=True, side_effect=lambda *args, **kwargs: {'file_name': 'fake_partner_ledger.pdf', 'file_content': b'', 'file_type': 'pdf'}):
+ self.partner_a.execute_followup(options)
+
+ def test_followup_lines_branches(self):
+ branch = self.env['res.company'].create({
+ 'name': 'branch',
+ 'parent_id': self.env.company.id
+ })
+ self.cr.precommit.run() # load the COA
+
+ report = self.env['account.followup.report']
+ options = {
+ 'partner_id': self.partner_a.id,
+ }
+
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'partner_id': self.partner_a.id,
+ 'company_id': branch.id,
+ 'invoice_payment_term_id': self.pay_terms_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 500,
+ 'tax_ids': [],
+ })]
+ })
+ invoice.action_post()
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ report._get_followup_report_lines(options),
+ # Name Date, Due Date, Doc. Total Due
+ [ 0, 1, 2, 3, 5],
+ [
+ ('INV/2016/00001', '01/01/2016', '01/01/2016', '', '$\xa0500.00'),
+ ('', '', '', '', '$\xa0500.00'),
+ ('', '', '', '', '$\xa0500.00'),
+ ],
+ options,
+ )
+
+ def test_followup_report_address_1(self):
+ ''' Test child contact priorities: the company will be used when there is no followup or billing contacts
+ '''
+
+ Partner = self.env['res.partner']
+ self.partner_a.is_company = True
+ options = {
+ 'partner_id': self.partner_a.id,
+ }
+
+ child_partner = Partner.create({
+ 'name': "Child contact",
+ 'type': "contact",
+ 'parent_id': self.partner_a.id,
+ })
+
+ mail = self.env['mail.mail'].search([('recipient_ids', '=', self.partner_a.id)])
+ self.init_invoice('out_invoice', partner=child_partner, invoice_date='2016-01-01', amounts=[500], post=True)
+ self.partner_a._compute_unpaid_invoices()
+ with patch.object(type(self.env['mail.mail']), 'unlink', lambda self: None):
+ with patch.object(self.env.registry['account.report'], 'export_to_pdf', autospec=True, side_effect=lambda *args, **kwargs: {'file_name': 'fake_partner_ledger.pdf', 'file_content': b'', 'file_type': 'pdf'}):
+ self.env['account.followup.report']._send_email(options)
+
+ mail = self.env['mail.mail'].search([('recipient_ids', '=', self.partner_a.id)])
+ self.assertTrue(mail, "The payment reminder email should have been sent to the company.")
+
+ def test_followup_report_address_2(self):
+ ''' Test child contact priorities: the follow up contact will be preferred over the billing contact
+ '''
+
+ Partner = self.env['res.partner']
+ self.partner_a.is_company = True
+ options = {
+ 'partner_id': self.partner_a.id,
+ }
+
+ # Testing followup sent to billing address if used in invoice
+
+ child_partner = Partner.create({
+ 'name': "Child contact",
+ 'type': "contact",
+ 'parent_id': self.partner_a.id,
+ })
+ invoice_partner = Partner.create({
+ 'name' : "Child contact invoice",
+ 'type' : "invoice",
+ 'email' : "test-invoice@example.com",
+ 'parent_id': child_partner.id,
+ })
+
+ self.init_invoice('out_invoice', partner=invoice_partner, invoice_date='2016-01-01', amounts=[500], post=True)
+
+ self.partner_a._compute_unpaid_invoices()
+ with patch.object(type(self.env['mail.mail']), 'unlink', lambda self: None):
+ with patch.object(self.env.registry['account.report'], 'export_to_pdf', autospec=True, side_effect=lambda *args, **kwargs: {'file_name': 'fake_partner_ledger.pdf', 'file_content': b'', 'file_type': 'pdf'}):
+ self.env['account.followup.report']._send_email(options)
+
+ mail = self.env['mail.mail'].search([('recipient_ids', '=', invoice_partner.id)])
+ self.assertTrue(mail, "The payment reminder email should have been sent to the invoice partner.")
+ mail.unlink()
+
+ # Testing followup partner priority
+
+ followup_partner = Partner.create({
+ 'name' : "Child contact followup",
+ 'type' : "followup",
+ 'email' : "test-followup@example.com",
+ 'parent_id': self.partner_a.id,
+ })
+
+ self.partner_a._compute_unpaid_invoices()
+ with patch.object(type(self.env['mail.mail']), 'unlink', lambda self: None):
+ with patch.object(self.env.registry['account.report'], 'export_to_pdf', autospec=True, side_effect=lambda *args, **kwargs: {'file_name': 'fake_partner_ledger.pdf', 'file_content': b'', 'file_type': 'pdf'}):
+ self.env['account.followup.report']._send_email(options)
+
+ mail = self.env['mail.mail'].search([('recipient_ids', '=', followup_partner.id)])
+ self.assertTrue(mail, "The payment reminder email should have been sent to the followup partner.")
+
+ def test_followup_invoice_no_amount(self):
+ # Init options.
+ report = self.env['account.followup.report']
+ options = {
+ 'partner_id': self.partner_a.id,
+ }
+
+ invoice_move = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'date': '2022-01-01',
+ 'invoice_line_ids': [
+ (0, 0, {'quantity': 0, 'price_unit': 30}),
+ ],
+ })
+ invoice_move.action_post()
+
+ lines = report._get_followup_report_lines(options)
+ self.assertEqual(len(lines), 0, "There should be no line displayed")
+
+ def test_negative_followup_report(self):
+ ''' Test negative or null followup reports: if a contact has an overdue invoice but has a negative of null total due, no action is needed.
+ '''
+ followup_line = self.env['account_followup.followup.line'].create({
+ 'company_id': self.env.company.id,
+ 'name': 'First Reminder',
+ 'delay': 15,
+ 'send_email': False,
+ })
+ self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 500,
+ 'tax_ids': [],
+ })]
+ }).action_post()
+
+ self.env['account.move'].create({
+ 'move_type': 'out_refund',
+ 'invoice_date': '2016-01-15',
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 300,
+ 'tax_ids': [],
+ })]
+ }).action_post()
+ self.assertEqual(self.partner_a.total_due, 200)
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', followup_line)
+
+ self.env['account.payment'].create({
+ 'partner_id': self.partner_a.id,
+ 'amount': 400,
+ }).action_post()
+ self.assertEqual(self.partner_a.total_due, -200)
+ self.assertPartnerFollowup(self.partner_a, 'no_action_needed', followup_line)
+
+ def test_followup_report_style(self):
+ """
+ This report is often broken in terms of styling, this test will check the styling of the lines.
+ (This test will not work if we modify the template it self)
+ """
+ report = self.env['account.followup.report']
+ options = {
+ 'partner_id': self.partner_a.id,
+ 'multi_currency': True,
+ }
+
+ # 2016-01-01: First invoice, partially paid.
+
+ invoice_1 = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 500,
+ 'tax_ids': [],
+ })]
+ })
+ invoice_1.action_post()
+
+ payment_1 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ Command.create({
+ 'debit': 0.0,
+ 'credit': 200.0,
+ 'account_id': self.company_data['default_account_receivable'].id
+ }),
+ Command.create({
+ 'debit': 200.0,
+ 'credit': 0.0,
+ 'account_id': self.company_data['default_journal_bank'].default_account_id.id
+ }),
+ ],
+ })
+ payment_1.action_post()
+
+ (payment_1 + invoice_1).line_ids \
+ .filtered(lambda line: line.account_id == self.company_data['default_account_receivable']) \
+ .reconcile()
+
+ lines = report._get_followup_report_lines(options)
+ # The variable lines is composed of 3 lines, the line of the move and two lines of total (due and overdue)
+ # First line
+ line_0_style = [
+ 'white-space:nowrap;text-align:left;',
+ 'white-space:nowrap;text-align:left;color: red;',
+ 'text-align:center; white-space:normal;',
+ 'text-align:left; white-space:normal;',
+ 'text-align:right; white-space:normal;',
+ ]
+ for expected_style, column in zip(line_0_style, lines[0]['columns']):
+ self.assertEqual(expected_style, column['style'])
+
+ # Second line
+ self.assertEqual(lines[1]['columns'][3].get('style'), 'text-align:right; white-space:normal; font-weight: bold;') # Total due title
+ self.assertEqual(lines[1]['columns'][4].get('style'), 'text-align:right; white-space:normal; font-weight: bold;') # Total due value
+
+ # Third line
+ self.assertEqual(lines[2]['columns'][3].get('style'), 'text-align:right; white-space:normal; font-weight: bold;') # Total overdue title
+ self.assertEqual(lines[2]['columns'][4].get('style'), 'text-align:right; white-space:normal; font-weight: bold;') # Total overdue value
+
+ # Check template used
+ for line in lines:
+ for column in line['columns']:
+ self.assertEqual(column['template'], 'account_followup.line_template')
+
+ def test_followup_send_email(self):
+ """ Tests that the email address in the mail.template is used to send the followup email."""
+ self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 500,
+ 'tax_ids': [],
+ })]
+ }).action_post()
+
+ @contextmanager
+ def create_and_send_email(email_from, subject):
+ """ Create a mail.template, open the followup wizard and send the followup email."""
+ mail_template = self.env['mail.template'].create({
+ 'name': "Payment Reminder",
+ 'model_id': self.env.ref('base.model_res_partner').id,
+ 'email_from': email_from,
+ 'partner_to': '{{ object.id }}',
+ 'subject': subject,
+ })
+ wizard = self.env['account_followup.manual_reminder'].with_context(
+ active_model='res.partner',
+ active_ids=self.partner_a.ids,
+ ).create({})
+ wizard.email = True # tick the 'email' checkbox
+ wizard.template_id = mail_template
+ with patch.object(self.env.registry['account.report'], 'export_to_pdf', autospec=True, side_effect=lambda *args, **kwargs: {'file_name': 'fake_partner_ledger.pdf', 'file_content': b'', 'file_type': 'pdf'}):
+ wizard.process_followup()
+ yield
+ self.assertEqual(len(message), 1)
+ self.assertEqual(message.author_id, self.env.user.partner_id)
+
+ # case 1: the email_from is dynamically set
+ with create_and_send_email(
+ email_from="{{ object._get_followup_responsible().email_formatted }}",
+ subject="{{ (object.company_id or object._get_followup_responsible().company_id).name }} Pay me now !",
+ ):
+ message = self.env['mail.message'].search([('subject', 'like', "Pay me now !")])
+ self.assertEqual(message.email_from, self.env.user.partner_id.email_formatted)
+
+ # case 2: the email_from is hardcoded in the template
+ with create_and_send_email(
+ email_from="test@odoo.com",
+ subject="{{ (object.company_id or object._get_followup_responsible().company_id).name }} Pay me noooow !",
+ ):
+ message = self.env['mail.message'].search([('subject', 'like', "Pay me noooow !")])
+ self.assertEqual(message.email_from, "test@odoo.com")
+
+ def test_process_automatic_followup_send_email(self):
+ """ Tests that the email address in the mail.template is used to send the followup email from the cron."""
+ self.env['account_followup.followup.line'].create({
+ 'company_id': self.env.company.id,
+ 'name': 'First Reminder',
+ 'delay': 15,
+ 'send_email': True,
+ })
+ self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 500,
+ 'tax_ids': [],
+ })]
+ }).action_post()
+
+ @contextmanager
+ def create_and_send_email(email_from, subject):
+ """ Create a mail.template, link it with the followup line and execute followups."""
+ mail_template = self.env['mail.template'].create({
+ 'name': "Payment Reminder",
+ 'model_id': self.env.ref('base.model_res_partner').id,
+ 'email_from': email_from,
+ 'partner_to': '{{ object.id }}',
+ 'subject': subject,
+ })
+ self.partner_a.followup_line_id.mail_template_id = mail_template
+ self.partner_a.followup_next_action_date = False
+ with patch.object(self.env.registry['account.report'], 'export_to_pdf', autospec=True, side_effect=lambda *args, **kwargs: {'file_name': 'fake_partner_ledger.pdf', 'file_content': b'', 'file_type': 'pdf'}):
+ self.partner_a._execute_followup_partner(options={'snailmail': False})
+ yield
+ self.assertEqual(len(message), 1)
+ self.assertEqual(message.author_id, self.partner_a._get_followup_responsible().partner_id, "Automatic followups should have the followup responsible as the author.")
+
+ # case 1: the email_from is dynamically set
+ with create_and_send_email(
+ email_from="{{ object._get_followup_responsible().email_formatted }}",
+ subject="{{ (object.company_id or object._get_followup_responsible().company_id).name }} Pay me now !",
+ ):
+ message = self.env['mail.message'].search([('subject', 'like', "Pay me now !")])
+ self.assertEqual(message.email_from, self.env.user.partner_id.email_formatted)
+
+ # we have to create a new overdue invoice to test the second case as the previous invoice
+ # will no longer trigger the followup
+ self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 600,
+ 'tax_ids': [],
+ })]
+ }).action_post()
+
+ # case 2: the email_from is hardcoded in the template
+ with create_and_send_email(
+ email_from="test@odoo.com",
+ subject="{{ (object.company_id or object._get_followup_responsible().company_id).name }} Pay me noooow !",
+ ):
+ message = self.env['mail.message'].search([('subject', 'like', "Pay me noooow !")])
+ self.assertEqual(message.email_from, "test@odoo.com")
+
+ def test_compute_render_model(self):
+ with Form(self.env['account_followup.manual_reminder'].with_context(
+ active_model='res.partner',
+ active_ids=self.partner_a.ids,
+ )) as wizard:
+ self.assertEqual(wizard.render_model, "res.partner")
+
+ def test_followup_report_with_levels_on_main_company(self):
+ cron = self.env.ref('account_followup.ir_cron_auto_post_draft_entry')
+
+ self.env['account_followup.followup.line'].create([{
+ 'company_id': self.company_data['company'].id,
+ 'name': 'First Reminder',
+ 'delay': 15,
+ 'send_email': True,
+ 'auto_execute': True,
+ }, {
+ 'company_id': self.company_data['company'].id,
+ 'name': 'Second Reminder',
+ 'delay': 30,
+ 'send_email': True,
+ 'auto_execute': True,
+ }])
+
+ self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'invoice_date_due': '2016-01-01',
+ 'date': '2016-01-01',
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 500,
+ })]
+ }).action_post()
+
+ with (
+ freeze_time('2022-01-10'),
+ patch.object(self.env.registry['res.partner'], '_send_followup') as patched,
+ patch.object(self.env.registry['ir.actions.report'], '_run_wkhtmltopdf', return_value=b"0")
+ ):
+ cron.method_direct_trigger()
+ # For the same reason we clear the cache in assertPartnerFollowup, to avoid this test change the state of the cache,
+ # which could break other tests.
+ self.env.cr.cache.pop('res_partner_all_followup', None)
+ self.assertEqual(patched.call_count, 1)
+
+ count_mail = self.env['mail.message'].search_count([('record_company_id', '=', self.company_data['company'].id)])
+ # We should have 1 email :
+ # 1 for the main company
+ self.assertEqual(count_mail, 1)
+
+ def test_followup_report_with_levels_on_one_branch(self):
+ cron = self.env.ref('account_followup.ir_cron_auto_post_draft_entry')
+
+ branch_a, branch_b = self.env['res.company'].create([{
+ 'name': 'Branch number 1',
+ 'parent_id': self.company_data['company'].id,
+ }, {
+ 'name': 'Branch number 2',
+ 'parent_id': self.company_data['company'].id,
+ }])
+
+ self.cr.precommit.run() # load the COA
+
+ self.env['account_followup.followup.line'].create([{
+ 'company_id': branch_a.id,
+ 'name': 'First Reminder (A)',
+ 'delay': 15,
+ 'send_email': True,
+ 'auto_execute': True,
+ }, {
+ 'company_id': branch_a.id,
+ 'name': 'Second Reminder (A)',
+ 'delay': 30,
+ 'send_email': True,
+ 'auto_execute': True,
+ }])
+
+ self.env['account.move'].create([{
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'invoice_date_due': '2016-01-01',
+ 'date': '2016-01-01',
+ 'partner_id': self.partner_a.id,
+ 'company_id': branch_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 400,
+ })]
+ }, {
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'invoice_date_due': '2016-01-01',
+ 'date': '2016-01-01',
+ 'partner_id': self.partner_a.id,
+ 'company_id': branch_b.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 800,
+ })]
+ }]).action_post()
+
+ with (
+ freeze_time('2022-01-10'),
+ patch.object(self.env.registry['res.partner'], '_send_followup') as patched,
+ patch.object(self.env.registry['ir.actions.report'], '_run_wkhtmltopdf', return_value=b"0")
+ ):
+ cron.method_direct_trigger()
+ # For the same reason we clear the cache in assertPartnerFollowup, to avoid this test change the state of the cache,
+ # which could break other tests.
+ self.env.cr.cache.pop('res_partner_all_followup', None)
+ self.assertEqual(patched.call_count, 1)
+
+ count_mail = self.env['mail.message'].search_count([('record_company_id', '=', branch_a.id)])
+ # We should have 1 email :
+ # 1 for the Branch number 1
+ self.assertEqual(count_mail, 1)
+
+ def test_followup_report_with_levels_on_branches_and_main_company(self):
+ cron = self.env.ref('account_followup.ir_cron_auto_post_draft_entry')
+
+ branch_a, branch_b = self.env['res.company'].create([{
+ 'name': 'Branch number 1',
+ 'parent_id': self.company_data['company'].id,
+ }, {
+ 'name': 'Branch number 2',
+ 'parent_id': self.company_data['company'].id,
+ }])
+
+ self.cr.precommit.run() # load the COA
+
+ self.env['account_followup.followup.line'].create([{
+ 'company_id': branch_a.id,
+ 'name': 'First Reminder (A)',
+ 'delay': 15,
+ 'send_email': True,
+ 'auto_execute': True,
+ }, {
+ 'company_id': branch_a.id,
+ 'name': 'Second Reminder (A)',
+ 'delay': 30,
+ 'send_email': True,
+ 'auto_execute': True,
+ }, {
+ 'company_id': branch_b.id,
+ 'name': 'First Reminder (B)',
+ 'delay': 10,
+ 'send_email': True,
+ 'auto_execute': True,
+ }, {
+ 'company_id': branch_b.id,
+ 'name': 'Second Reminder (B)',
+ 'delay': 20,
+ 'send_email': True,
+ 'auto_execute': True,
+ }, {
+ 'company_id': self.company_data['company'].id,
+ 'name': 'First Reminder',
+ 'delay': 20,
+ 'send_email': True,
+ 'auto_execute': True,
+ }, {
+ 'company_id': self.company_data['company'].id,
+ 'name': 'Second Reminder',
+ 'delay': 40,
+ 'send_email': True,
+ 'auto_execute': True,
+ }])
+
+ self.env['account.move'].create([{
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'invoice_date_due': '2016-01-01',
+ 'date': '2016-01-01',
+ 'partner_id': self.partner_a.id,
+ 'company_id': branch_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 400,
+ })]
+ }, {
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'invoice_date_due': '2016-01-01',
+ 'date': '2016-01-01',
+ 'partner_id': self.partner_a.id,
+ 'company_id': branch_b.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 800,
+ })]
+ }, {
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'invoice_date_due': '2016-01-01',
+ 'date': '2016-01-01',
+ 'partner_id': self.partner_a.id,
+ 'company_id': self.company_data['company'].id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 200,
+ })]
+ }]).action_post()
+
+ with (
+ freeze_time('2022-01-10'),
+ patch.object(self.env.registry['res.partner'], '_send_followup') as patched,
+ patch.object(self.env.registry['ir.actions.report'], '_run_wkhtmltopdf', return_value=b"0")
+ ):
+ cron.method_direct_trigger()
+ # For the same reason we clear the cache in assertPartnerFollowup, to avoid this test change the state of the cache,
+ # which could break other tests.
+ self.env.cr.cache.pop('res_partner_all_followup', None)
+ self.assertEqual(patched.call_count, 3)
+
+ count_mail = self.env['mail.message'].search_count([('record_company_id', 'in', [self.company_data['company'].id, branch_a.id, branch_b.id])])
+ # We should have 3 emails :
+ # 1 for the main company
+ # 1 for the Branch number 1
+ # 1 for the Branch number 2
+ self.assertEqual(count_mail, 3)
+
+ # Now we check the amounts overdue
+ # Expected : 200 (main_company) + 400 (branch_a) + 800 (branch_b)
+ self.assertEqual(self.partner_a.total_overdue, 1400)
+ # Expected : 400
+ self.assertEqual(self.partner_a.with_company(branch_a).total_overdue, 400)
+ # Expected : 800
+ self.assertEqual(self.partner_a.with_company(branch_b).total_overdue, 800)
+
+ def test_partner_total_due_with_payable(self):
+ """
+ Test that the total due for a partner also reflects payable accounts and is coherent with the customer statement report.
+ """
+ # Init options.
+ report = self.env.ref('odex30_account_reports.customer_statement_report')
+ default_options = {
+ 'partner_id': self.partner_a.id,
+ 'multi_currency': True,
+ 'unfold_all': True,
+ }
+ options = self._generate_options(report, fields.Date.from_string('2016-01-01'), fields.Date.from_string('2016-12-31'), default_options=default_options)
+
+ self.init_invoice('out_invoice', self.partner_a, '2016-01-01', True, amounts=[500])
+
+ self.assertRecordValues(self.partner_a, [{'total_due': 500.0, 'total_all_due': 500.0}])
+
+ with freeze_time('2016-01-01'):
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ report._get_lines(options),
+ # Name Date, Due Date, Amount, Balance
+ [ 0, 1, 2, 3, 5],
+ [
+ ('partner_a', '', '', 500.0, 500.0),
+ ('INV/2016/00001', '01/01/2016', '01/01/2016', 500.0, 500.0),
+ ('Total partner_a', '', '', 500.0, 500.0),
+ ('Total', '', '', 500.0, 500.0),
+ ],
+ options,
+ )
+
+ self.init_invoice('in_invoice', self.partner_a, '2016-01-01', True, amounts=[200])
+
+ self.assertRecordValues(self.partner_a, [{'total_due': 500.0, 'total_all_due': 300.0}])
+
+ with freeze_time('2016-01-01'):
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ report._get_lines(options),
+ # Name Date, Due Date, Amount, Balance
+ [ 0, 1, 2, 3, 5],
+ [
+ ('partner_a', '', '', 300.0, 300.0),
+ ('INV/2016/00001', '01/01/2016', '01/01/2016', 500.0, 500.0),
+ ('BILL/2016/01/0001', '01/01/2016', '01/01/2016', -200.0, 300.0),
+ ('Total partner_a', '', '', 300.0, 300.0),
+ ('Total', '', '', 300.0, 300.0),
+ ],
+ options,
+ )
+
+ def test_automatic_followup_report_attachments(self):
+ followup_line = self.env['account_followup.followup.line'].create({
+ 'company_id': self.env.company.id,
+ 'name': 'First Reminder',
+ 'delay': 15,
+ 'send_email': True,
+ })
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 500,
+ 'tax_ids': [],
+ })]
+ })
+ invoice.action_post()
+
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', followup_line)
+
+ invoice_attachment = self.env['ir.attachment'].create({
+ 'name': 'some_attachment.pdf',
+ 'res_id': invoice.id,
+ 'res_model': 'account.move',
+ 'datas': 'test',
+ 'type': 'binary',
+ })
+ invoice._message_set_main_attachment_id(invoice_attachment)
+
+ self.partner_a._compute_unpaid_invoices()
+ with patch.object(self.env.registry['account.report'], 'export_to_pdf', autospec=True, side_effect=lambda *args, **kwargs: {'file_name': 'fake_partner_ledger.pdf', 'file_content': b'', 'file_type': 'pdf'}):
+ self.partner_a.action_manually_process_automatic_followups()
+
+ sent_attachments = self.env['mail.message'].search([('partner_ids', '=', self.partner_a.id)]).attachment_ids
+ self.assertEqual(sent_attachments.mapped('name'), [f'{self.partner_a.name} - fake_partner_ledger.pdf', 'some_attachment.pdf'])
+
+ def test_manual_followup_report_invoices_removed(self):
+ followup_line = self.env['account_followup.followup.line'].create({
+ 'company_id': self.env.company.id,
+ 'name': 'First Reminder',
+ 'delay': 15,
+ 'send_email': True,
+ })
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 500,
+ 'tax_ids': [],
+ })]
+ })
+ invoice.action_post()
+
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', followup_line)
+
+ invoice_attachment = self.env['ir.attachment'].create({
+ 'name': 'some_attachment.pdf',
+ 'res_id': invoice.id,
+ 'res_model': 'account.move',
+ 'datas': 'test',
+ 'type': 'binary',
+ })
+ invoice._message_set_main_attachment_id(invoice_attachment)
+
+ self.partner_a._compute_unpaid_invoices()
+ with patch.object(self.env.registry['account.report'], 'export_to_pdf', autospec=True, side_effect=lambda *args, **kwargs: {'file_name': 'fake_partner_ledger.pdf', 'file_content': b'', 'file_type': 'pdf'}):
+ self.partner_a._execute_followup_partner(options={
+ 'partner_id': self.partner_a.id,
+ 'manual_followup': True,
+ 'snailmail': False,
+ 'join_invoices': True,
+ 'attachment_ids': [],
+ })
+
+ sent_attachments = self.env['mail.message'].search([('partner_ids', '=', self.partner_a.id)]).attachment_ids
+ self.assertEqual(sent_attachments.mapped('name'), [f'{self.partner_a.name} - fake_partner_ledger.pdf'])
+
+ def test_manual_followup_report_join_invoices(self):
+ followup_line = self.env['account_followup.followup.line'].create({
+ 'company_id': self.env.company.id,
+ 'name': 'First Reminder',
+ 'delay': 15,
+ 'send_email': True,
+ })
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 500,
+ 'tax_ids': [],
+ })]
+ })
+ invoice.action_post()
+
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', followup_line)
+
+ invoice_attachment = self.env['ir.attachment'].create({
+ 'name': 'some_attachment.pdf',
+ 'res_id': invoice.id,
+ 'res_model': 'account.move',
+ 'datas': 'test',
+ 'type': 'binary',
+ })
+ invoice._message_set_main_attachment_id(invoice_attachment)
+
+ self.partner_a._compute_unpaid_invoices()
+ with patch.object(self.env.registry['account.report'], 'export_to_pdf', autospec=True, side_effect=lambda *args, **kwargs: {'file_name': 'fake_partner_ledger.pdf', 'file_content': b'', 'file_type': 'pdf'}):
+ self.partner_a._execute_followup_partner(options={
+ 'partner_id': self.partner_a.id,
+ 'manual_followup': True,
+ 'snailmail': False,
+ 'join_invoices': False,
+ 'attachment_ids': invoice_attachment.ids,
+ })
+
+ sent_attachments = self.env['mail.message'].search([('partner_ids', '=', self.partner_a.id)]).attachment_ids
+ self.assertEqual(sent_attachments.mapped('name'), [f'{self.partner_a.name} - fake_partner_ledger.pdf'])
+
+ def test_followup_report_with_entries(self):
+ """
+ Entries shouldn't have a due date or be added to total_overdue on the followup report and on the partner.
+ """
+ report = self.env['account.followup.report']
+ options = {
+ 'partner_id': self.partner_a.id,
+ }
+ with freeze_time('2016-01-02'):
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'invoice_date_due': '2016-01-01',
+ 'invoice_payment_term_id': False,
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 300,
+ 'tax_ids': [],
+ })]
+ })
+ invoice.action_post()
+
+ entry = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2016-01-02'),
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'line1',
+ 'account_id': self.company_data['default_account_receivable'].id,
+ 'debit': 500.0,
+ 'credit': 0.0,
+ }),
+ Command.create({
+ 'name': 'counterpart line',
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'debit': 0.0,
+ 'credit': 500.0,
+ })
+ ]
+ })
+ entry.action_post()
+
+ with freeze_time('2016-01-15'):
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ report._get_followup_report_lines(options),
+ # Name Date, Due Date, Doc. Total Due
+ [ 0, 1, 2, 3, 5],
+ [
+ ('MISC/2016/01/0001', '01/02/2016', '', '', '$\xa0500.00'),
+ ('INV/2016/00001', '01/01/2016', '01/01/2016', '', '$\xa0300.00'),
+ ('', '', '', '', '$\xa0800.00'),
+ ('', '', '', '', '$\xa0300.00'),
+ ],
+ options,
+ )
+ self.assertEqual(self.partner_a.total_due, 800)
+ self.assertEqual(self.partner_a.total_overdue, 300)
+
+ def test_action_report_followup(self):
+ def _run_wkhtmltopdf(*args, **kwargs):
+ return file_open('base/tests/minimal.pdf', 'rb').read()
+
+ followup_line = self.env['account_followup.followup.line'].create({
+ 'company_id': self.env.company.id,
+ 'name': 'First Reminder',
+ 'delay': 15,
+ 'send_email': True,
+ })
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 500,
+ 'tax_ids': [],
+ })]
+ })
+ invoice.action_post()
+
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', followup_line)
+
+ with patch.object(self.env.registry['ir.actions.report'], '_run_wkhtmltopdf', _run_wkhtmltopdf):
+ followup_letter = self.env['ir.actions.report'].with_context(force_report_rendering=True)._render_qweb_pdf('odex30_account_followup.report_followup_print_all', self.partner_a.id)[0]
+ self.assertTrue(followup_letter)
+
+ def test_automatic_followup_report_attachments_from_template(self):
+ mail_template = self.env['mail.template'].create({
+ 'name': 'reminder',
+ 'model_id': self.env['ir.model']._get_id('res.partner'),
+ 'email_cc': 'john.carmac@example.me',
+ })
+ template_attachment = self.env['ir.attachment'].create({
+ 'name': 'template_attachment.pdf',
+ 'res_id': mail_template.id,
+ 'res_model': 'mail.template',
+ 'datas': 'test',
+ 'type': 'binary',
+ })
+ mail_template.attachment_ids = [template_attachment.id]
+
+ dynamic_report = self.env['ir.actions.report'].create({
+ 'name': 'Test Report Partner',
+ 'model': 'res.partner',
+ 'report_name': 'odex30_account_followup.report_followup_print_all',
+ 'print_report_name': "'followup_dynamic_report'",
+ })
+ mail_template.report_template_ids = [dynamic_report.id]
+
+ followup_line = self.env['account_followup.followup.line'].create({
+ 'company_id': self.env.company.id,
+ 'name': 'First Reminder',
+ 'delay': 15,
+ 'send_email': True,
+ 'mail_template_id': mail_template.id,
+ })
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2016-01-01',
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 500,
+ 'tax_ids': [],
+ })]
+ })
+ invoice.action_post()
+ self.assertPartnerFollowup(self.partner_a, 'in_need_of_action', followup_line)
+
+ invoice_attachment = self.env['ir.attachment'].create({
+ 'name': 'invoice_attachment.pdf',
+ 'res_id': invoice.id,
+ 'res_model': 'account.move',
+ 'datas': 'test',
+ 'type': 'binary',
+ })
+ invoice._message_set_main_attachment_id(invoice_attachment)
+
+ self.partner_a._compute_unpaid_invoices()
+ with patch.object(self.env.registry['account.report'], 'export_to_pdf', autospec=True, side_effect=lambda *args, **kwargs: {'file_name': 'fake_partner_ledger.pdf', 'file_content': b'', 'file_type': 'pdf'}):
+ self.partner_a.action_manually_process_automatic_followups()
+
+ sent_attachments = self.env['mail.message'].search([('partner_ids', '=', self.partner_a.id)]).attachment_ids
+ self.assertEqual(sent_attachments.mapped('name'), ['followup_dynamic_report.html', f'{self.partner_a.name} - fake_partner_ledger.pdf', 'invoice_attachment.pdf', 'template_attachment.pdf'])
diff --git a/dev_odex30_accounting/odex30_account_followup/views/account_followup_line_views.xml b/dev_odex30_accounting/odex30_account_followup/views/account_followup_line_views.xml
new file mode 100644
index 0000000..7c42ac4
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_followup/views/account_followup_line_views.xml
@@ -0,0 +1,104 @@
+
+
+
+ account_followup.followup.line.list
+ account_followup.followup.line
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ account_followup.followup.line.form
+ account_followup.followup.line
+
+
+
+
+
+
+ account.followup.line.select
+ account_followup.followup.line
+
+
+
+
+
+
+
+
+
+
+ Follow-up Levels
+ account_followup.followup.line
+
+ list,kanban,form
+
+
+ Define follow-up levels and their related actions
+
+ For each step, specify the actions to be taken and delay in days. It is
+ possible to use print and e-mail templates to send specific messages to
+ the customer.
+
\n"
+" ملاحظة: هذا البريد الإلكتروني التلقائي مرسل بواسطة محاسبة أودو لتذكيرك قبل انتهاء صلاحية إذن مزامنة البنك.\n"
+"
\n"
+"
\n"
+"
\n"
+"
\n"
+"
\n"
+"
\n"
+" "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__access_token
+msgid "Access Token"
+msgstr "رمز الوصول "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__account_data
+msgid "Account Data"
+msgstr "بيانات الحساب "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__name
+msgid "Account Name"
+msgstr "اسم الحساب"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_account__name
+msgid "Account Name as provided by third party provider"
+msgstr "اسم الحساب حسب مزود الطرف الثالث "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__account_number
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__account_number
+msgid "Account Number"
+msgstr "رقم الحساب"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__account_online_account_ids
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__online_account_id
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_journal__account_online_account_id
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__account_online_account_ids
+msgid "Account Online Account"
+msgstr "حساب عبر الإنترنت "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__account_online_link_id
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line__online_link_id
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_journal__account_online_link_id
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__account_online_link_id
+msgid "Account Online Link"
+msgstr "ربط الحساب عبر الإنترنت "
+
+#. module: odex30_account_online_sync
+#: model:ir.actions.server,name:odex30_account_online_sync.online_sync_cron_waiting_synchronization_ir_actions_server
+msgid "Account: Journal online Waiting Synchronization"
+msgstr "الحساب: دفتر اليومية أونلاين بانتظار المزامنة "
+
+#. module: odex30_account_online_sync
+#: model:ir.actions.server,name:odex30_account_online_sync.online_sync_cron_ir_actions_server
+msgid "Account: Journal online sync"
+msgstr "الحساب: مزامنة دفتر اليومية عبر الإنترنت "
+
+#. module: odex30_account_online_sync
+#: model:ir.actions.server,name:odex30_account_online_sync.online_sync_unused_connection_cron_ir_actions_server
+msgid "Account: Journal online sync cleanup unused connections"
+msgstr "الحساب: مسح الاتصالات غير المستخدمة عند مزامنة دفتر اليومية أونلاين "
+
+#. module: odex30_account_online_sync
+#: model:ir.actions.server,name:odex30_account_online_sync.online_sync_mail_cron_ir_actions_server
+msgid "Account: Journal online sync reminder"
+msgstr "الحساب: تذكير مزامنة دفتر اليومية أونلاين "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_needaction
+msgid "Action Needed"
+msgstr "إجراء مطلوب"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_ids
+msgid "Activities"
+msgstr "الأنشطة"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_exception_decoration
+msgid "Activity Exception Decoration"
+msgstr "زخرفة استثناء النشاط"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_state
+msgid "Activity State"
+msgstr "حالة النشاط"
+
+#. module: odex30_account_online_sync
+#: model:ir.model,name:odex30_account_online_sync.model_mail_activity_type
+msgid "Activity Type"
+msgstr "نوع النشاط"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_type_icon
+msgid "Activity Type Icon"
+msgstr "أيقونة نوع النشاط"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__amount
+msgid "Amount"
+msgstr "مبلغ"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__amount_currency
+msgid "Amount in Currency"
+msgstr "المبلغ بالعملة"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_attachment_count
+msgid "Attachment Count"
+msgstr "عدد المرفقات"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__auto_sync
+msgid "Automatic synchronization"
+msgstr "المزامنة الآلية "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__balance
+msgid "Balance"
+msgstr "الرصيد"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_account__balance
+msgid "Balance of the account sent by the third party provider"
+msgstr "رصيد الحساب الذي أرسله مزود الطرف الثالث "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.portal_renew_consent
+msgid "Bank"
+msgstr "البنك"
+
+#. module: odex30_account_online_sync
+#: model:ir.model,name:odex30_account_online_sync.model_account_online_link
+msgid "Bank Connection"
+msgstr "اتصال البنك"
+
+#. module: odex30_account_online_sync
+#: model:ir.model,name:odex30_account_online_sync.model_account_bank_statement_line
+msgid "Bank Statement Line"
+msgstr "بند كشف الحساب البنكي"
+
+#. module: odex30_account_online_sync
+#: model:mail.activity.type,name:odex30_account_online_sync.bank_sync_activity_update_consent
+msgid "Bank Synchronization: Update consent"
+msgstr "مزامنة البنك: تحديث الإذن "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+msgid "Bank Synchronization: Update your consent"
+msgstr "مزامنة البنك: قم بتحديث الإذن "
+
+#. module: odex30_account_online_sync
+#: model:mail.template,name:odex30_account_online_sync.email_template_sync_reminder
+msgid "Bank connection expiration reminder"
+msgstr "تذكير انتهاء صلاحية اتصال البنك "
+
+#. module: odex30_account_online_sync
+#: model:ir.model,name:odex30_account_online_sync.model_bank_rec_widget
+msgid "Bank reconciliation widget for a single statement line"
+msgstr "أداة التسوية البنكية لبند كشف حساب واحد "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_missing_transaction_wizard_view_form
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.view_account_bank_selection_form_wizard
+msgid "Cancel"
+msgstr "إلغاء"
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+msgid "Check the documentation"
+msgstr "ألقِ نظرة على الوثائق "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_duplicate_transaction_wizard_view_form
+msgid ""
+"Choose a date and a journal from which you want to check the transactions."
+msgstr "اختر التاريخ ودفتر اليومية الذي ترغب في التحقق من المعاملات فيه "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_missing_transaction_wizard_view_form
+msgid "Choose a date and a journal from which you want to fetch transactions"
+msgstr "اختر التاريخ ودفتر اليومية الذي ترغب في جلب المعاملات منه "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__client_id
+msgid "Client"
+msgstr "العميل"
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_online_link_view_form
+msgid "Client id"
+msgstr "معرف العميل "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_journal__renewal_contact_email
+msgid ""
+"Comma separated list of email addresses to send consent renewal "
+"notifications 15, 3 and 1 days before expiry"
+msgstr ""
+"قائمة مفصولة بفواصل بعناوين البريد الإلكترونية لإرسال إشعارات تجديد الإذن "
+"قبل 15، 3، و1 يوم من تاريخ انتهاء الصلاحية "
+
+#. module: odex30_account_online_sync
+#: model:ir.model,name:odex30_account_online_sync.model_res_company
+msgid "Companies"
+msgstr "الشركات"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__company_id
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__company_id
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__company_id
+msgid "Company"
+msgstr "الشركة "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_online_link_view_form
+msgid "Connect"
+msgstr "اتصل"
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.view_account_bank_selection_form_wizard
+msgid "Connect Bank"
+msgstr "توصيل البنك "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_journal_dashboard_inherit_online_sync
+msgid "Connect bank"
+msgstr "توصيل البنك "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.portal_renew_consent
+msgid "Connect my Bank"
+msgstr "توصيل بنكي "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.portal_renew_consent
+msgid "Connect your bank account to Odoo"
+msgstr "قم بتوصيل حسابك البنكي بأودو "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields.selection,name:odex30_account_online_sync.selection__account_online_link__state__connected
+msgid "Connected"
+msgstr "متصل "
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/connected_until_widget/connected_until_widget.xml:0
+msgid "Connected until"
+msgstr "متصل حتى "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_journal__renewal_contact_email
+msgid "Connection Requests"
+msgstr "طلبات الاتصال "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__connection_state_details
+msgid "Connection State Details"
+msgstr "تفاصيل حالة الاتصال "
+
+#. module: odex30_account_online_sync
+#: model:mail.message.subtype,name:odex30_account_online_sync.bank_sync_consent_renewal
+msgid "Consent Renewal"
+msgstr "تجديد الإذن "
+
+#. module: odex30_account_online_sync
+#: model:ir.model,name:odex30_account_online_sync.model_res_partner
+msgid "Contact"
+msgstr "جهة الاتصال"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__create_uid
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__create_uid
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__create_uid
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_missing_transaction_wizard__create_uid
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__create_uid
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__create_uid
+msgid "Created by"
+msgstr "أنشئ بواسطة"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__create_date
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__create_date
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__create_date
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_missing_transaction_wizard__create_date
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__create_date
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__create_date
+msgid "Created on"
+msgstr "أنشئ في"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__currency_id
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__currency_id
+msgid "Currency"
+msgstr "العملة"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__date
+msgid "Date"
+msgstr "التاريخ"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_journal__expiring_synchronization_date
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__expiring_synchronization_date
+msgid "Date when the consent for this connection expires"
+msgstr "تاريخ انتهاء صلاحية إذن هذا الاتصال "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__display_name
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__display_name
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__display_name
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_missing_transaction_wizard__display_name
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__display_name
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__display_name
+msgid "Display Name"
+msgstr "اسم العرض "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields.selection,name:odex30_account_online_sync.selection__account_online_account__fetching_status__done
+msgid "Done"
+msgstr "منتهي "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_bank_statement.py:0
+#: model:ir.model.fields.selection,name:odex30_account_online_sync.selection__account_online_link__state__error
+msgid "Error"
+msgstr "خطأ"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_journal__expiring_synchronization_date
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__expiring_synchronization_date
+msgid "Expiring Synchronization Date"
+msgstr "تاريخ انتهاء صلاحية المزامنة "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_journal__expiring_synchronization_due_day
+msgid "Expiring Synchronization Due Day"
+msgstr "التاريخ الذي ستنتهي فيه صلاحية المزامنة "
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/connected_until_widget/connected_until_widget.xml:0
+msgid "Extend Connection"
+msgstr "تمديد الإذن "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_account__account_data
+msgid "Extra information needed by third party provider"
+msgstr "معلومات إضافية مطلوبة من قِبَل مزود الطرف الثالث "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_missing_transaction_wizard_view_form
+msgid "Fetch"
+msgstr "جلب "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_duplicate_transaction_wizard_view_form
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_missing_transaction_wizard_view_form
+msgid "Fetch Missing Bank Statements Wizard"
+msgstr "معالج جلب كشوفات الحسابات المفقودة "
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_online_link_view_form
+msgid "Fetch Transactions"
+msgstr "جلب المعاملات "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+msgid "Fetched Transactions"
+msgstr "المعاملات التي تم جلبها "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_journal__online_sync_fetching_status
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__fetching_status
+msgid "Fetching Status"
+msgstr "حالة الجلب "
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0
+msgid "Fetching..."
+msgstr "جري جلب... "
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_journal.py:0
+#: code:addons/odex30_account_online_sync/static/src/components/bank_reconciliation/find_duplicate_transactions_cog_menu.xml:0
+msgid "Find Duplicate Transactions"
+msgstr "العثور على المعاملات المكررة "
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_journal.py:0
+#: code:addons/odex30_account_online_sync/static/src/components/bank_reconciliation/fetch_missing_transactions_cog_menu.xml:0
+msgid "Find Missing Transactions"
+msgstr "العثور على المعاملات المفقودة "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__first_ids_in_group
+msgid "First Ids In Group"
+msgstr "المُعرّفات الأولى في المجموعة "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_follower_ids
+msgid "Followers"
+msgstr "المتابعين"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_partner_ids
+msgid "Followers (Partners)"
+msgstr "المتابعين (الشركاء) "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__activity_type_icon
+msgid "Font awesome icon e.g. fa-tasks"
+msgstr "أيقونة من Font awesome مثال: fa-tasks "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__foreign_currency_id
+msgid "Foreign Currency"
+msgstr "عملة أجنبية "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__has_message
+msgid "Has Message"
+msgstr "يحتوي على رسالة "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__has_unlinked_accounts
+msgid "Has Unlinked Accounts"
+msgstr "يحتوي على حسابات غير مرتبطة "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+msgid "Here"
+msgstr "هنا "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__id
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__id
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__id
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_missing_transaction_wizard__id
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__id
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__id
+msgid "ID"
+msgstr "المُعرف"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_exception_icon
+msgid "Icon"
+msgstr "الأيقونة"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__activity_exception_icon
+msgid "Icon to indicate an exception activity."
+msgstr "الأيقونة للإشارة إلى النشاط المستثنى. "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_account__online_identifier
+msgid "Id used to identify account by third party provider"
+msgstr "المعرف المستخدَم لتعرف الحساب من قِبَل مزود الطرف الثالث "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__message_needaction
+msgid "If checked, new messages require your attention."
+msgstr "إذا كان محددًا، فهناك رسائل جديدة عليك رؤيتها. "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__message_has_error
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__message_has_sms_error
+msgid "If checked, some messages have a delivery error."
+msgstr "إذا كان محددًا، فقد حدث خطأ في تسليم بعض الرسائل."
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_account__inverse_balance_sign
+msgid "If checked, the balance sign will be inverted"
+msgstr "إذا كان محدداً، سيتم عكس علامة الرصيد "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_account__inverse_transaction_sign
+msgid "If checked, the transaction sign will be inverted"
+msgstr "إذا كان محدداً، سيتم عكس علامة المعاملة "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__auto_sync
+msgid ""
+"If possible, we will try to automatically fetch new transactions for this record\n"
+" \n"
+"If the automatic sync is disabled. that will be due to security policy on the bank's end. So, they have to launch the sync manually"
+msgstr ""
+"إذا أمكن، سنحاول جلب المعاملات الجديدة تلقائياً لهذا السجل\n"
+" \n"
+"إذا كانت خاصية المزامنة التلقائية معطلة. سيكون ذلك بسبب سياسة الأمن التي يفرضها البنك. لذلك، سيتوجب عليهم تشغيل عملية المزامنة يدوياً "
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml:0
+msgid "Import Transactions"
+msgstr "استيراد المعاملات "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__provider_data
+msgid "Information needed to interact with third party provider"
+msgstr "المعلومات المطلوبة للتفاعل مع مزود الطرف الثالث "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_bank_selection__institution_name
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__name
+msgid "Institution Name"
+msgstr "اسم المنشأة "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+msgid "Internal Error"
+msgstr "خطأ داخلي "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+msgid "Invalid URL"
+msgstr "رابط URL غير صحيح "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+msgid "Invalid value for proxy_mode config parameter."
+msgstr "قيمة غير صالحة لمعيار تهيئة proxy_mode "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__inverse_balance_sign
+msgid "Inverse Balance Sign"
+msgstr "علامة عكس الرصيد "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__inverse_transaction_sign
+msgid "Inverse Transaction Sign"
+msgstr "عكس علامة المعاملة "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_is_follower
+msgid "Is Follower"
+msgstr "متابع"
+
+#. module: odex30_account_online_sync
+#: model:ir.model,name:odex30_account_online_sync.model_account_journal
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__journal_id
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__journal_id
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_missing_transaction_wizard__journal_id
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__journal_ids
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__journal_ids
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.portal_renew_consent
+msgid "Journal"
+msgstr "دفتر اليومية"
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+msgid ""
+"Journal %(journal_name)s has been set up with a different currency and "
+"already has existing entries. You can't link selected bank account in "
+"%(currency_name)s to it"
+msgstr ""
+"تم إعداد دفتر اليومية %(journal_name)s بعملة مختلفة ويحتوي بالفعل على قيود "
+"موجودة. لا يمكنك ربط الحساب البنكي المحدد %(currency_name)s به "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__last_refresh
+msgid "Last Refresh"
+msgstr "التحديث الأخير"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__write_uid
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__write_uid
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__write_uid
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_missing_transaction_wizard__write_uid
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__write_uid
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__write_uid
+msgid "Last Updated by"
+msgstr "آخر تحديث بواسطة"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__write_date
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__write_date
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__write_date
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_missing_transaction_wizard__write_date
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__write_date
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__write_date
+msgid "Last Updated on"
+msgstr "آخر تحديث في"
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_online_link_view_form
+msgid "Last refresh"
+msgstr "آخر تحديث "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__last_sync
+msgid "Last synchronization"
+msgstr "آخر مزامنة"
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.portal_renew_consent
+msgid "Latest Balance"
+msgstr "آخر رصيد "
+
+#. module: odex30_account_online_sync
+#: model:ir.model,name:odex30_account_online_sync.model_account_bank_selection
+msgid "Link a bank account to the selected journal"
+msgstr " قم بربط حساب بنكي واحد بدفتر اليومية المحدد "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/wizard/account_journal_missing_transactions.py:0
+msgid "Manual Bank Statement Lines"
+msgstr "بنود كشف الحساب البنكي اليدوية "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+msgid "Message"
+msgstr "الرسالة"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_has_error
+msgid "Message Delivery error"
+msgstr "خطأ في تسليم الرسائل"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_ids
+msgid "Messages"
+msgstr "الرسائل"
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/wizard/account_journal_missing_transactions.py:0
+msgid "Missing and Pending Transactions"
+msgstr "المعاملات المفقودة وقيد الانتظار "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__my_activity_date_deadline
+msgid "My Activity Deadline"
+msgstr "الموعد النهائي لنشاطاتي "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__institution_name
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__name
+msgid "Name"
+msgstr "الاسم"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_calendar_event_id
+msgid "Next Activity Calendar Event"
+msgstr "الفعالية التالية في تقويم الأنشطة "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_date_deadline
+msgid "Next Activity Deadline"
+msgstr "الموعد النهائي للنشاط التالي"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_summary
+msgid "Next Activity Summary"
+msgstr "ملخص النشاط التالي"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_type_id
+msgid "Next Activity Type"
+msgstr "نوع النشاط التالي"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__next_refresh
+msgid "Next synchronization"
+msgstr "المزامنة التالية"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields.selection,name:odex30_account_online_sync.selection__account_online_link__state__disconnected
+msgid "Not Connected"
+msgstr "غير متصل "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_needaction_counter
+msgid "Number of Actions"
+msgstr "عدد الإجراءات"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_has_error_counter
+msgid "Number of errors"
+msgstr "عدد الأخطاء "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__message_needaction_counter
+msgid "Number of messages requiring action"
+msgstr "عدد الرسائل التي تتطلب اتخاذ إجراء"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__message_has_error_counter
+msgid "Number of messages with delivery error"
+msgstr "عدد الرسائل الحادث بها خطأ في التسليم"
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.portal_renew_consent
+msgid "Odoo"
+msgstr "أودو"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line__online_account_id
+msgid "Online Account"
+msgstr "حساب عبر الإنترنت "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_online_link_view_form
+msgid "Online Accounts"
+msgstr "حسابات عبر الإنترنت "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__online_identifier
+msgid "Online Identifier"
+msgstr "معرف عبر الإنترنت "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_journal__next_link_synchronization
+msgid "Online Link Next synchronization"
+msgstr "ربط المزامنة التالية عبر الإنترنت "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line__online_partner_information
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_res_partner__online_partner_information
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_res_users__online_partner_information
+msgid "Online Partner Information"
+msgstr "معلومات الشريك عبر الإنترنت "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_journal.py:0
+#: model:ir.actions.act_window,name:odex30_account_online_sync.action_account_online_link_form
+#: model:ir.ui.menu,name:odex30_account_online_sync.menu_action_online_link_account
+#: model_terms:ir.actions.act_window,help:odex30_account_online_sync.action_account_online_link_form
+msgid "Online Synchronization"
+msgstr "المزامنة عبر الإنترنت "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line__online_transaction_identifier
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__online_transaction_identifier
+msgid "Online Transaction Identifier"
+msgstr "معرف المعاملات عبر الإنترنت "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_bank_statement.py:0
+msgid "Opening statement: first synchronization"
+msgstr "البيان الافتتاحي: المزامنة الأولى"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__partner_name
+msgid "Partner Name"
+msgstr "اسم الشريك"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__payment_ref
+msgid "Payment Ref"
+msgstr "مرجع الدفع "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields.selection,name:odex30_account_online_sync.selection__account_bank_statement_line_transient__state__pending
+msgid "Pending"
+msgstr "قيد الانتظار "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields.selection,name:odex30_account_online_sync.selection__account_online_account__fetching_status__planned
+msgid "Planned"
+msgstr "المخطط له "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/wizard/account_journal_missing_transactions.py:0
+msgid "Please enter a valid Starting Date to continue."
+msgstr "يرجى إدخال تاريخ بدء صالح للمتابعة. "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+msgid "Please reconnect your online account."
+msgstr "الرجاء إعادة توصيل حسابك عبر الإنترنت. "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/wizard/account_bank_statement_line.py:0
+msgid "Please select first the transactions you wish to import."
+msgstr "يرجى أولاً تحديد المعاملات التي ترغب في استيرادها. "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields.selection,name:odex30_account_online_sync.selection__account_bank_statement_line_transient__state__posted
+msgid "Posted"
+msgstr "مُرحّل "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.missing_bank_statement_line_search
+msgid "Posted Transactions"
+msgstr "المعاملات التي تم ترحيلها "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields.selection,name:odex30_account_online_sync.selection__account_online_account__fetching_status__processing
+msgid "Processing"
+msgstr "معالجة "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__provider_data
+msgid "Provider Data"
+msgstr "بيانات المزود "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__provider_type
+msgid "Provider Type"
+msgstr "نوع Plaid"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__rating_ids
+msgid "Ratings"
+msgstr "التقييمات"
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_online_link_view_form
+msgid "Reconnect"
+msgstr "إعادة توصيل "
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/connected_until_widget/connected_until_widget.xml:0
+#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_journal_dashboard_inherit_online_sync
+msgid "Reconnect Bank"
+msgstr "إعادة توصيل البنك "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+msgid "Redirect"
+msgstr "إعادة توجيه"
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0
+msgid "Refresh"
+msgstr "تحديث "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__refresh_token
+msgid "Refresh Token"
+msgstr "تحديث الرمز"
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_journal.py:0
+msgid "Report Issue"
+msgstr "إبلاغ عن إساءة "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+msgid "Report issue"
+msgstr "إبلاغ عن مشكلة "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__client_id
+msgid "Represent a link for a given user towards a banking institution"
+msgstr "تقديم رابط لمستخدم معين إلى منشأة بنكية "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_online_link_view_form
+msgid "Reset"
+msgstr "إعادة الضبط "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_user_id
+msgid "Responsible User"
+msgstr "المستخدم المسؤول"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_has_sms_error
+msgid "SMS Delivery error"
+msgstr "خطأ في تسليم الرسائل النصية القصيرة "
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.xml:0
+msgid "Search over"
+msgstr "البحث "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.portal_renew_consent
+msgid ""
+"Security Tip: always check the domain name of this page, before clicking on "
+"the button."
+msgstr ""
+"نصيحة للأمان: تحقق دائماً من اسم النطاق لهذه الصفحة، عن طريق الضغط على الزر."
+" "
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0
+msgid "See error"
+msgstr "انظر إلى الخطأ "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.view_account_bank_selection_form_wizard
+msgid "Select a Bank Account"
+msgstr "تحديد حساب بنكي "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.view_account_bank_selection_form_wizard
+msgid "Select the"
+msgstr "قم بتحديد "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__selected_account
+msgid "Selected Account"
+msgstr "الحساب المحدد "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__sequence
+msgid "Sequence"
+msgstr "تسلسل "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_account__account_number
+msgid "Set if third party provider has the full account number"
+msgstr "التعيين إذا كان لدى مزود الطرف الثالث رقم الحساب الكامل "
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.xml:0
+msgid "Setup Bank"
+msgstr "إعداد البنك "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+msgid "Setup Bank Account"
+msgstr "إعداد الحساب البنكي "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__show_sync_actions
+msgid "Show Sync Actions"
+msgstr "عرض إجراءات المزامنة "
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/views/account_online_authorization_kanban_controller.xml:0
+msgid "Some transactions"
+msgstr "بعض المعاملات "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__date
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_missing_transaction_wizard__date
+msgid "Starting Date"
+msgstr "تاريخ البدء"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__state
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_journal__account_online_link_state
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__state
+msgid "State"
+msgstr "الحالة "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__activity_state
+msgid ""
+"Status based on activities\n"
+"Overdue: Due date is already passed\n"
+"Today: Activity date is today\n"
+"Planned: Future activities."
+msgstr ""
+"الأنشطة المعتمدة على الحالة\n"
+"المتأخرة: تاريخ الاستحقاق مر\n"
+"اليوم: تاريخ النشاط هو اليوم\n"
+"المخطط: الأنشطة المستقبلية."
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.portal_renew_consent
+msgid "Thank You!"
+msgstr "شكرا لك! "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+msgid "The consent for the selected account has expired."
+msgstr "انتهت صلاحية الإذن للحساب المحدد. "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+msgid ""
+"The online synchronization service is not available at the moment. Please "
+"try again later."
+msgstr ""
+"خدمة المزامنة عبر الإنترنت غير متوفرة في الوقت الحالي. الرجاء المحاولة "
+"مجدداً لاحقاً "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__provider_type
+msgid "Third Party Provider"
+msgstr "مزود الطرف الثالث "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_duplicate_transaction_wizard_view_form
+msgid ""
+"This action will delete all selected transactions. Are you sure you want to "
+"proceed?"
+msgstr ""
+"سيؤدي هذا الإجراء إلى حذف كافة المعاملات المحددة. هل أنت متأكد أنك ترغب في "
+"المتابعة؟ "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_online_link_view_form
+msgid "This button will reset the fetching status"
+msgstr "سيؤدي هذا الزر إلى إعادة ضبط حالة عملية البحث "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+msgid ""
+"This version of Odoo appears to be outdated and does not support the '%s' "
+"sync mode. Installing the latest update might solve this."
+msgstr ""
+"يبدو أن هذه النسخة من أودو قديمة ولا تدعم وضع المزامنة '%s'. قد يحل تثبيت "
+"آخر تحديث هذه المشكلة. "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.actions.act_window,help:odex30_account_online_sync.action_account_online_link_form
+msgid ""
+"To create a synchronization with your banking institution, \n"
+" please click on Add a Bank Account."
+msgstr ""
+"لإنشاء مزامنة مع المنشأة البنكية، \n"
+" يرجى الضغط على إضافة حساب بنكي. "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__access_token
+msgid "Token used to access API."
+msgstr "الرمز المستخدَم للوصول إلى الواجهة البرمجية للتطبيق. "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__refresh_token
+msgid "Token used to sign API request, Never disclose it"
+msgstr ""
+"الرمز المستخدَم للتوقيع على طلب الواجهة البرمجية للتطبيق، لا تقم بالإفصاح "
+"عنه أبداً "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__transaction_ids
+msgid "Transaction"
+msgstr "معاملة"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__transaction_details
+msgid "Transaction Details"
+msgstr "تفاصيل المعاملة "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_duplicate_transaction_wizard_view_form
+msgid "Transactions"
+msgstr "المعاملات "
+
+#. module: odex30_account_online_sync
+#: model:ir.model,name:odex30_account_online_sync.model_account_bank_statement_line_transient
+msgid "Transient model for bank statement line"
+msgstr "Transient model for bank statement line"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__has_unlinked_accounts
+msgid ""
+"True if that connection still has accounts that are not linked to an Odoo "
+"journal"
+msgstr ""
+"تكون القيمة صحيحة إذا كان الاتصال لا يزال يحتوي على حسابات غير مرتبطة بدفتر "
+"يومية أودو "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__activity_exception_decoration
+msgid "Type of the exception activity on record."
+msgstr "نوع النشاط المستثنى في السجل. "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_online_link_view_form
+msgid "Update Credentials"
+msgstr "تحديث بيانات الاعتماد"
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields.selection,name:odex30_account_online_sync.selection__account_online_account__fetching_status__waiting
+msgid "Waiting"
+msgstr "قيد الانتظار "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__website_message_ids
+msgid "Website Messages"
+msgstr "رسائل الموقع الإلكتروني "
+
+#. module: odex30_account_online_sync
+#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__website_message_ids
+msgid "Website communication history"
+msgstr "سجل تواصل الموقع الإلكتروني "
+
+#. module: odex30_account_online_sync
+#: model:ir.model,name:odex30_account_online_sync.model_account_duplicate_transaction_wizard
+msgid "Wizard for duplicate transactions"
+msgstr "معالج للمعاملات المكررة "
+
+#. module: odex30_account_online_sync
+#: model:ir.model,name:odex30_account_online_sync.model_account_missing_transaction_wizard
+msgid "Wizard for missing transactions"
+msgstr "معالج للمعاملات المفقودة "
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml:0
+msgid ""
+"You are importing transactions before the creation of your online synchronization\n"
+" ("
+msgstr ""
+"أنت تقوم باستيراد المعاملات قبل إنشاء المزامنة عبر الإنترنت\n"
+" ("
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+msgid "You can contact Odoo support"
+msgstr "يمكنك التواصل مع فريق الدعم لدى أودو "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_journal.py:0
+msgid "You can only execute this action for bank-synchronized journals."
+msgstr "لا يمكنك تنفيذ هذا الإجراء إلا لدفاتر اليومية المتزامنة مع البنك. "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_journal.py:0
+#: code:addons/odex30_account_online_sync/wizard/account_journal_missing_transactions.py:0
+msgid ""
+"You can't find missing transactions for a journal that isn't connected."
+msgstr "لا يمكنك العثور على المعاملات المفقودة لدفتر يومية غير متصل. "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/models/account_journal.py:0
+#: code:addons/odex30_account_online_sync/models/account_online.py:0
+msgid "You cannot have two journals associated with the same Online Account."
+msgstr "لا يمكن أن يكون لديك حسابان مرتبطان بنفس الحساب عبر الإنترنت. "
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/wizard/account_bank_statement_line.py:0
+msgid "You cannot import pending transactions."
+msgstr "لا يمكنك استيراد المعاملات المعلقة. "
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml:0
+msgid "You have"
+msgstr "لديك"
+
+#. module: odex30_account_online_sync
+#. odoo-python
+#: code:addons/odex30_account_online_sync/wizard/account_journal_missing_transactions.py:0
+msgid "You have to select one journal to continue."
+msgstr "عليك تحديد دفتر يومية واحد للاستمرار. "
+
+#. module: odex30_account_online_sync
+#: model:mail.template,subject:odex30_account_online_sync.email_template_sync_reminder
+msgid "Your bank connection is expiring soon"
+msgstr "سوف ينتهي اتصال البنك الخاص بك قريباً "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.view_account_bank_selection_form_wizard
+msgid "account to connect:"
+msgstr "الحساب لربطه: "
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.xml:0
+msgid "banks"
+msgstr "البنوك "
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml:0
+msgid "entries"
+msgstr "القيود"
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.xml:0
+msgid "loading..."
+msgstr "جاري التحميل..."
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/views/account_online_authorization_kanban_controller.xml:0
+msgid "may be duplicates."
+msgstr "قد تكون عناك نُسخ مكررة "
+
+#. module: odex30_account_online_sync
+#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.portal_renew_consent
+msgid "on"
+msgstr "في"
+
+#. module: odex30_account_online_sync
+#: model:ir.model,name:odex30_account_online_sync.model_account_online_account
+msgid "representation of an online bank account"
+msgstr "تمثيل لحساب بنكي عبر الإنترنت "
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0
+msgid "transactions fetched"
+msgstr "تم جلب المعاملات "
+
+#. module: odex30_account_online_sync
+#. odoo-javascript
+#: code:addons/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml:0
+msgid ""
+"within this period that were not created using the online synchronization. "
+"This might cause duplicate entries."
+msgstr ""
+"خلال هذه الفترة والتي لم يتم إنشاؤها باستخدام المزامنة عبر الإنترنت. قد "
+"يتسبب هذا في استنساخ القيود. "
diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__init__.py b/dev_odex30_accounting/odex30_account_online_sync/models/__init__.py
new file mode 100644
index 0000000..fa59fc5
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/models/__init__.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+
+from . import account_bank_statement
+from . import account_journal
+from . import account_online
+from . import company
+from . import mail_activity_type
+from . import partner
+from . import bank_rec_widget
diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..807c970
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/__init__.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/account_bank_statement.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/account_bank_statement.cpython-311.pyc
new file mode 100644
index 0000000..161301f
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/account_bank_statement.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/account_journal.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/account_journal.cpython-311.pyc
new file mode 100644
index 0000000..65ca1e2
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/account_journal.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/account_online.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/account_online.cpython-311.pyc
new file mode 100644
index 0000000..eb8eff3
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/account_online.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/bank_rec_widget.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/bank_rec_widget.cpython-311.pyc
new file mode 100644
index 0000000..23f8bc4
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/bank_rec_widget.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/company.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/company.cpython-311.pyc
new file mode 100644
index 0000000..9dba055
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/company.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/mail_activity_type.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/mail_activity_type.cpython-311.pyc
new file mode 100644
index 0000000..2aabcb1
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/mail_activity_type.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/odoofin_auth.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/odoofin_auth.cpython-311.pyc
new file mode 100644
index 0000000..3a6a04c
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/odoofin_auth.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/partner.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/partner.cpython-311.pyc
new file mode 100644
index 0000000..a501c7f
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/partner.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/account_bank_statement.py b/dev_odex30_accounting/odex30_account_online_sync/models/account_bank_statement.py
new file mode 100644
index 0000000..bade26e
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/models/account_bank_statement.py
@@ -0,0 +1,114 @@
+# -*- coding: utf-8 -*-
+import threading
+import time
+import json
+
+from odoo import api, fields, models, SUPERUSER_ID, tools, _
+from odoo.tools import date_utils
+from odoo.exceptions import UserError, ValidationError
+
+STATEMENT_LINE_CREATION_BATCH_SIZE = 500 # When importing transactions, batch the process to commit after importing batch_size
+
+
+class AccountBankStatementLine(models.Model):
+ _inherit = 'account.bank.statement.line'
+
+ online_transaction_identifier = fields.Char("Online Transaction Identifier", readonly=True)
+ online_partner_information = fields.Char(readonly=True)
+ online_account_id = fields.Many2one(comodel_name='account.online.account', readonly=True)
+ online_link_id = fields.Many2one(
+ comodel_name='account.online.link',
+ related='online_account_id.account_online_link_id',
+ store=True,
+ readonly=True,
+ )
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """
+ Some transactions can be marked as "Zero Balancing",
+ which is a transaction used at the end of the day to summarize all the transactions
+ of the day. As we already manage the details of all the transactions, this one is not
+ useful and moreover create duplicates. To deal with that, we cancel the move and so
+ the bank statement line.
+ """
+ # EXTEND account
+ bank_statement_lines = super().create(vals_list)
+ moves_to_cancel = self.env['account.move']
+ for bank_statement_line in bank_statement_lines:
+ transaction_details = json.loads(bank_statement_line.transaction_details) if bank_statement_line.transaction_details else {}
+ if not transaction_details.get('is_zero_balancing'):
+ continue
+ moves_to_cancel |= bank_statement_line.move_id
+ moves_to_cancel.button_cancel()
+
+ return bank_statement_lines
+
+ @api.model
+ def _online_sync_bank_statement(self, transactions, online_account):
+ """
+ build bank statement lines from a list of transaction and post messages is also post in the online_account of the journal.
+ :param transactions: A list of transactions that will be created.
+ The format is : [{
+ 'id': online id, (unique ID for the transaction)
+ 'date': transaction date, (The date of the transaction)
+ 'name': transaction description, (The description)
+ 'amount': transaction amount, (The amount of the transaction. Negative for debit, positive for credit)
+ }, ...]
+ :param online_account: The online account for this statement
+ Return: The number of imported transaction for the journal
+ """
+ start_time = time.time()
+ lines_to_reconcile = self.env['account.bank.statement.line']
+ try:
+ for journal in online_account.journal_ids:
+ # Since the synchronization succeeded, set it as the bank_statements_source of the journal
+ journal.sudo().write({'bank_statements_source': 'online_sync'})
+ if not transactions:
+ continue
+
+ sorted_transactions = sorted(transactions, key=lambda transaction: transaction['date'])
+ total = self.env.context.get('transactions_total') or sum([transaction['amount'] for transaction in transactions])
+
+ # For first synchronization, an opening line is created to fill the missing bank statement data
+ any_st_line = self.search_count([('journal_id', '=', journal.id)], limit=1)
+ journal_currency = journal.currency_id or journal.company_id.currency_id
+ # If there are neither statement and the ending balance != 0, we create an opening bank statement at the day of the oldest transaction.
+ # We set the sequence to >1 to ensure the computed internal_index will force its display before any other statement with the same date.
+ if not any_st_line and not journal_currency.is_zero(online_account.balance - total):
+ opening_st_line = self.with_context(skip_statement_line_cron_trigger=True).create({
+ 'date': sorted_transactions[0]['date'],
+ 'journal_id': journal.id,
+ 'payment_ref': _("Opening statement: first synchronization"),
+ 'amount': online_account.balance - total,
+ 'sequence': 2,
+ })
+ lines_to_reconcile += opening_st_line
+
+ filtered_transactions = online_account._get_filtered_transactions(sorted_transactions)
+
+ do_commit = not (hasattr(threading.current_thread(), 'testing') and threading.current_thread().testing)
+ if filtered_transactions:
+ # split transactions import in batch and commit after each batch except in testing mode
+ for index in range(0, len(filtered_transactions), STATEMENT_LINE_CREATION_BATCH_SIZE):
+ lines_to_reconcile += self.with_user(SUPERUSER_ID).with_company(journal.company_id).with_context(skip_statement_line_cron_trigger=True).create(filtered_transactions[index:index + STATEMENT_LINE_CREATION_BATCH_SIZE])
+ if do_commit:
+ self.env.cr.commit()
+ # Set last sync date as the last transaction date
+ journal.account_online_account_id.sudo().write({'last_sync': filtered_transactions[-1]['date']})
+
+ if lines_to_reconcile:
+ # 'limit_time_real_cron' defaults to -1.
+ # Manual fallback applied for non-POSIX systems where this key is disabled (set to None).
+ cron_limit_time = tools.config['limit_time_real_cron'] or -1
+ limit_time = (cron_limit_time if cron_limit_time > 0 else 180) - (time.time() - start_time)
+ if limit_time > 0:
+ lines_to_reconcile._cron_try_auto_reconcile_statement_lines(limit_time=limit_time)
+ # Catch any configuration error that would prevent creating the entries, reset fetching_status flag and re-raise the error
+ # Otherwise flag is never reset and user is under the impression that we are still fetching transactions
+ except (UserError, ValidationError) as e:
+ self.env.cr.rollback()
+ online_account.account_online_link_id._log_information('error', subject=_("Error"), message=str(e))
+ self.env.cr.commit()
+ raise
+ return lines_to_reconcile
diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/account_journal.py b/dev_odex30_accounting/odex30_account_online_sync/models/account_journal.py
new file mode 100644
index 0000000..afb0777
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/models/account_journal.py
@@ -0,0 +1,380 @@
+# -*- coding: utf-8 -*-
+
+import logging
+import requests
+from dateutil.relativedelta import relativedelta
+from requests.exceptions import RequestException, Timeout
+
+from odoo import api, fields, models, tools, _
+from odoo.exceptions import UserError, ValidationError, RedirectWarning
+from odoo.tools import SQL
+
+_logger = logging.getLogger(__name__)
+
+
+class AccountJournal(models.Model):
+ _inherit = "account.journal"
+
+ def __get_bank_statements_available_sources(self):
+ rslt = super(AccountJournal, self).__get_bank_statements_available_sources()
+ rslt.append(("online_sync", _("Online Synchronization")))
+ return rslt
+
+ next_link_synchronization = fields.Datetime("Online Link Next synchronization", related='account_online_link_id.next_refresh')
+ expiring_synchronization_date = fields.Date(related='account_online_link_id.expiring_synchronization_date')
+ expiring_synchronization_due_day = fields.Integer(compute='_compute_expiring_synchronization_due_day')
+ account_online_account_id = fields.Many2one('account.online.account', copy=False, ondelete='set null')
+ account_online_link_id = fields.Many2one('account.online.link', related='account_online_account_id.account_online_link_id', readonly=True, store=True)
+ account_online_link_state = fields.Selection(related="account_online_link_id.state", readonly=True)
+ renewal_contact_email = fields.Char(
+ string='Connection Requests',
+ help='Comma separated list of email addresses to send consent renewal notifications 15, 3 and 1 days before expiry',
+ default=lambda self: self.env.user.email,
+ )
+ online_sync_fetching_status = fields.Selection(related="account_online_account_id.fetching_status", readonly=True)
+
+ def write(self, vals):
+ # When changing the bank_statement_source, unlink the connection if there is any
+ if 'bank_statements_source' in vals and vals.get('bank_statements_source') != 'online_sync':
+ for journal in self:
+ if journal.bank_statements_source == 'online_sync':
+ # unlink current connection
+ vals['account_online_account_id'] = False
+ journal.account_online_link_id.has_unlinked_accounts = True
+ return super().write(vals)
+
+ @api.depends('expiring_synchronization_date')
+ def _compute_expiring_synchronization_due_day(self):
+ for record in self:
+ if record.expiring_synchronization_date:
+ due_day_delta = record.expiring_synchronization_date - fields.Date.context_today(record)
+ record.expiring_synchronization_due_day = due_day_delta.days
+ else:
+ record.expiring_synchronization_due_day = 0
+
+ def _fill_bank_cash_dashboard_data(self, dashboard_data):
+ super()._fill_bank_cash_dashboard_data(dashboard_data)
+ # Caching data to avoid one call per journal
+ self.browse(list(dashboard_data.keys())).fetch(['type', 'account_online_account_id'])
+ for journal_id, journal_data in dashboard_data.items():
+ journal = self.browse(journal_id)
+ journal_data['display_connect_bank_in_dashboard'] = journal.type in ('bank', 'credit') \
+ and not journal.account_online_account_id \
+ and journal.company_id.id == self.env.company.id
+
+ @api.constrains('account_online_account_id')
+ def _check_account_online_account_id(self):
+ for journal in self:
+ if len(journal.account_online_account_id.journal_ids) > 1:
+ raise ValidationError(_('You cannot have two journals associated with the same Online Account.'))
+
+ def _fetch_online_transactions(self):
+ for journal in self:
+ try:
+ journal.account_online_link_id._pop_connection_state_details(journal=journal)
+ journal.manual_sync()
+ # for cron jobs it is usually recommended committing after each iteration,
+ # so that a later error or job timeout doesn't discard previous work
+ self.env.cr.commit()
+ except (UserError, RedirectWarning):
+ # We need to rollback here otherwise the next iteration will still have the error when trying to commit
+ self.env.cr.rollback()
+
+ def fetch_online_sync_favorite_institutions(self):
+ self.ensure_one()
+ timeout = int(self.env['ir.config_parameter'].sudo().get_param('odex30_account_online_sync.request_timeout')) or 60
+ endpoint_url = self.env['account.online.link']._get_odoofin_url('/proxy/v1/get_dashboard_institutions')
+ params = {'country': self.sudo().company_id.account_fiscal_country_id.code, 'limit': 28}
+ try:
+ resp = requests.post(endpoint_url, json=params, timeout=timeout)
+ resp_dict = resp.json()['result']
+ for institution in resp_dict:
+ if institution['picture'].startswith('/'):
+ institution['picture'] = self.env['account.online.link']._get_odoofin_url(institution['picture'])
+ return resp_dict
+ except (Timeout, ConnectionError, RequestException, ValueError) as e:
+ _logger.warning(e)
+ return []
+
+ @api.model
+ def _cron_fetch_waiting_online_transactions(self):
+ """ This method is only called when the user fetch transactions asynchronously.
+ We only fetch transactions on synchronizations that are in "waiting" status.
+ Once the synchronization is done, the status is changed for "done".
+ We have to that to avoid having too much logic in the same cron function to do
+ 2 different things. This cron should only be used for asynchronous fetchs.
+ """
+
+ # 'limit_time_real_cron' and 'limit_time_real' default respectively to -1 and 120.
+ # Manual fallbacks applied for non-POSIX systems where this key is disabled (set to None).
+ limit_time = tools.config['limit_time_real_cron'] or -1
+ if limit_time <= 0:
+ limit_time = tools.config['limit_time_real'] or 120
+ journals = self.search([
+ '|',
+ ('online_sync_fetching_status', 'in', ('planned', 'waiting')),
+ '&',
+ ('online_sync_fetching_status', '=', 'processing'),
+ ('account_online_link_id.last_refresh', '<', fields.Datetime.now() - relativedelta(seconds=limit_time)),
+ ])
+ journals.with_context(cron=True)._fetch_online_transactions()
+
+ @api.model
+ def _cron_fetch_online_transactions(self):
+ """ This method is called by the cron (by default twice a day) to fetch (for all journals)
+ the new transactions.
+ """
+ journals = self.search([('account_online_account_id', '!=', False)])
+ journals.with_context(cron=True)._fetch_online_transactions()
+
+ @api.model
+ def _cron_send_reminder_email(self):
+ for journal in self.search([('account_online_account_id', '!=', False)]):
+ if journal.expiring_synchronization_due_day in {1, 3, 15}:
+ journal.action_send_reminder()
+
+ def manual_sync(self):
+ self.ensure_one()
+ if self.account_online_link_id:
+ account = self.account_online_account_id
+ return self.account_online_link_id._fetch_transactions(accounts=account)
+
+ def unlink(self):
+ """
+ Override of the unlink method.
+ That's useful to unlink account.online.account too.
+ """
+ if self.account_online_account_id:
+ self.account_online_account_id.unlink()
+ return super(AccountJournal, self).unlink()
+
+ def action_configure_bank_journal(self):
+ """
+ Override the "action_configure_bank_journal" and change the flow for the
+ "Configure" button in dashboard.
+ """
+ self.ensure_one()
+ return self.env['account.online.link'].action_new_synchronization()
+
+ def action_open_account_online_link(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.account_online_link_id.name,
+ 'res_model': 'account.online.link',
+ 'target': 'main',
+ 'view_mode': 'form',
+ 'views': [[False, 'form']],
+ 'res_id': self.account_online_link_id.id,
+ }
+
+ def action_extend_consent(self):
+ """
+ Extend the consent of the user by redirecting him to update his credentials
+ """
+ self.ensure_one()
+ return self.account_online_link_id._open_iframe(
+ mode='updateCredentials',
+ include_param={
+ 'account_online_identifier': self.account_online_account_id.online_identifier,
+ },
+ )
+
+ def action_reconnect_online_account(self):
+ self.ensure_one()
+ return self.account_online_link_id.action_reconnect_account()
+
+ def action_send_reminder(self):
+ self.ensure_one()
+ self._portal_ensure_token()
+ template = self.env.ref('odex30_account_online_sync.email_template_sync_reminder')
+ subtype = self.env.ref('odex30_account_online_sync.bank_sync_consent_renewal')
+ self.message_post_with_source(source_ref=template, subtype_id=subtype.id)
+
+ def action_open_missing_transaction_wizard(self):
+ """ This method allows to open the wizard to fetch the missing
+ transactions and the pending ones.
+ Depending on where the function is called, we'll receive
+ one journal or none of them.
+ If we receive more or less than one journal, we do not set
+ it on the wizard, the user should select it by himself.
+
+ :return: An action opening the wizard.
+ """
+ journal_id = None
+ if len(self) == 1:
+ if not self.account_online_account_id or self.account_online_link_state != 'connected':
+ raise UserError(_("You can't find missing transactions for a journal that isn't connected."))
+
+ journal_id = self.id
+
+ wizard = self.env['account.missing.transaction.wizard'].create({'journal_id': journal_id})
+ return {
+ 'name': _("Find Missing Transactions"),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.missing.transaction.wizard',
+ 'res_id': wizard.id,
+ 'views': [(False, 'form')],
+ 'target': 'new',
+ }
+
+ def action_open_duplicate_transaction_wizard(self, from_date=None):
+ """ This method allows to open the wizard to find duplicate transactions.
+ :param from_date: date from with we must check for duplicates.
+
+ :return: An action opening the wizard.
+ """
+ wizard = self.env['account.duplicate.transaction.wizard'].create({
+ 'journal_id': self.id if len(self) == 1 else None,
+ **({'date': from_date} if from_date else {}),
+ })
+ return wizard._get_records_action(name=_("Find Duplicate Transactions"))
+
+ def _has_duplicate_transactions(self, date_from):
+ """ Has any transaction with
+ - same amount &
+ - same date &
+ - same account number
+ We do not check on online_transaction_identifier because this is called after the fetch
+ where transitions would already have been filtered on existing online_transaction_identifier.
+
+ :param from_date: date from with we must check for duplicates.
+ """
+ self.env.cr.execute(SQL.join(SQL(''), [
+ self._get_duplicate_amount_date_account_transactions_query(date_from),
+ SQL('LIMIT 1'),
+ ]))
+ return bool(self.env.cr.rowcount)
+
+ def _get_duplicate_transactions(self, date_from):
+ """Find all transaction with
+ - same amount &
+ - same date &
+ - same account number
+ or
+ - same transaction id
+
+ :param from_date: date from with we must check for duplicates.
+ """
+ query = SQL.join(SQL(''), [
+ self._get_duplicate_amount_date_account_transactions_query(date_from),
+ SQL('UNION'),
+ self._get_duplicate_online_transaction_identifier_transactions_query(date_from),
+ SQL('ORDER BY ids'),
+ ])
+ return [res[0] for res in self.env.execute_query(query)]
+
+ def _get_duplicate_amount_date_account_transactions_query(self, date_from):
+ self.ensure_one()
+ return SQL('''
+ SELECT ARRAY_AGG(st_line.id ORDER BY st_line.id) AS ids
+ FROM account_bank_statement_line st_line
+ JOIN account_move move ON move.id = st_line.move_id
+ WHERE st_line.journal_id = %(journal_id)s AND move.date >= %(date_from)s
+ GROUP BY st_line.currency_id, st_line.amount, st_line.account_number, move.date
+ HAVING count(st_line.id) > 1
+ ''',
+ journal_id=self.id,
+ date_from=date_from,
+ )
+
+ def _get_duplicate_online_transaction_identifier_transactions_query(self, date_from):
+ return SQL('''
+ SELECT ARRAY_AGG(st_line.id ORDER BY st_line.id) AS ids
+ FROM account_bank_statement_line st_line
+ JOIN account_move move ON move.id = st_line.move_id
+ WHERE st_line.journal_id = %(journal_id)s AND
+ move.date >= %(prior_date)s AND
+ st_line.online_transaction_identifier IS NOT NULL
+ GROUP BY st_line.online_transaction_identifier
+ HAVING count(st_line.id) > 1 AND BOOL_OR(move.date >= %(date_from)s) -- at least one date is > date_from
+ ''',
+ journal_id=self.id,
+ date_from=date_from,
+ prior_date=date_from - relativedelta(months=3), # allow 1 of duplicate statements to be older than "from" date
+ )
+
+ def action_open_dashboard_asynchronous_action(self):
+ """ This method allows to open action asynchronously
+ during the fetching process.
+ When a user clicks on the Fetch Transactions button in
+ the dashboard, we fetch the transactions asynchronously
+ and save connection state details on the synchronization.
+ This action allows the user to open the action saved in
+ the connection state details.
+ """
+ self.ensure_one()
+
+ if not self.account_online_account_id:
+ raise UserError(_("You can only execute this action for bank-synchronized journals."))
+
+ connection_state_details = self.account_online_link_id._pop_connection_state_details(journal=self)
+ if connection_state_details and connection_state_details.get('action'):
+ if connection_state_details.get('error_type') == 'redirect_warning':
+ self.env.cr.commit()
+ raise RedirectWarning(connection_state_details['error_message'], connection_state_details['action'], _('Report Issue'))
+ else:
+ return connection_state_details['action']
+
+ return {'type': 'ir.actions.client', 'tag': 'soft_reload'}
+
+ def _get_journal_dashboard_data_batched(self):
+ dashboard_data = super()._get_journal_dashboard_data_batched()
+ for journal in self.filtered(lambda j: j.type in ('bank', 'credit')):
+ if journal.account_online_account_id:
+ if journal.company_id.id not in self.env.companies.ids:
+ continue
+ connection_state_details = journal.account_online_link_id._get_connection_state_details(journal=journal)
+ if not connection_state_details and journal.account_online_account_id.fetching_status in ('waiting', 'processing'):
+ connection_state_details = {'status': 'fetching'}
+ dashboard_data[journal.id]['connection_state_details'] = connection_state_details
+ dashboard_data[journal.id]['show_sync_actions'] = journal.account_online_link_id.show_sync_actions
+ return dashboard_data
+
+ def get_related_connection_state_details(self):
+ """ This method allows JS widget to get the last connection state details
+ It's useful if the user wasn't on the dashboard when we send the message
+ by websocket that the asynchronous flow is finished.
+ In case we don't have a connection state details and if the fetching
+ status is set on "waiting" or "processing". We're returning that the sync
+ is currently fetching.
+ """
+ self.ensure_one()
+ connection_state_details = self.account_online_link_id._get_connection_state_details(journal=self)
+ if not connection_state_details and self.account_online_account_id.fetching_status in ('waiting', 'processing'):
+ connection_state_details = {'status': 'fetching'}
+ return connection_state_details
+
+ def _consume_connection_state_details(self):
+ self.ensure_one()
+ if self.account_online_link_id and self.env.user.has_group('account.group_account_manager'):
+ # In case we have a bank synchronization connected to the journal
+ # we want to remove the last connection state because it means that we
+ # have "mark as read" this state, and we don't want to display it again to
+ # the user.
+ self.account_online_link_id._pop_connection_state_details(journal=self)
+
+ def open_action(self):
+ # Extends 'account_accountant'
+ if not self._context.get('action_name') and self.type == 'bank' and self.bank_statements_source == 'online_sync':
+ self._consume_connection_state_details()
+ return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
+ default_context={'search_default_journal_id': self.id},
+ )
+ return super().open_action()
+
+ def action_open_reconcile(self):
+ # Extends 'account_accountant'
+ self._consume_connection_state_details()
+ return super().action_open_reconcile()
+
+ def action_open_bank_transactions(self):
+ # Extends 'account_accountant'
+ self._consume_connection_state_details()
+ return super().action_open_bank_transactions()
+
+ @api.model
+ def _toggle_asynchronous_fetching_cron(self):
+ cron = self.env.ref('odex30_account_online_sync.online_sync_cron_waiting_synchronization', raise_if_not_found=False)
+ if cron:
+ cron.sudo().toggle(model=self._name, domain=[('account_online_account_id', '!=', False)])
diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/account_online.py b/dev_odex30_accounting/odex30_account_online_sync/models/account_online.py
new file mode 100644
index 0000000..63554d4
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/models/account_online.py
@@ -0,0 +1,1170 @@
+# -*- coding: utf-8 -*-
+
+import base64
+import datetime
+import requests
+import logging
+import re
+import uuid
+import urllib.parse
+import odoo
+import odoo.release
+from dateutil.relativedelta import relativedelta
+from markupsafe import Markup
+
+from requests.exceptions import RequestException, Timeout, ConnectionError
+from odoo import api, fields, models, modules, tools
+from odoo.exceptions import UserError, CacheMiss, MissingError, ValidationError, RedirectWarning
+from odoo.http import request
+from odoo.addons.odex30_account_online_sync.models.odoofin_auth import OdooFinAuth
+from odoo.tools.misc import format_amount, format_date, get_lang
+from odoo.tools import _, LazyTranslate
+
+_lt = LazyTranslate(__name__)
+_logger = logging.getLogger(__name__)
+pattern = re.compile("^[a-z0-9-_]+$")
+runbot_pattern = re.compile(r"^https:\/\/[a-z0-9-_]+\.[a-z0-9-_]+\.odoo\.com$")
+
+class OdooFinRedirectException(UserError):
+ """ When we need to open the iframe in a given mode. """
+
+ def __init__(self, message=_lt('Redirect'), mode='link'):
+ self.mode = mode
+ super().__init__(message)
+
+class AccountOnlineAccount(models.Model):
+ _name = 'account.online.account'
+ _description = 'representation of an online bank account'
+
+ name = fields.Char(string="Account Name", help="Account Name as provided by third party provider")
+ online_identifier = fields.Char(help='Id used to identify account by third party provider', readonly=True)
+ balance = fields.Float(readonly=True, help='Balance of the account sent by the third party provider')
+ account_number = fields.Char(help='Set if third party provider has the full account number')
+ account_data = fields.Char(help='Extra information needed by third party provider', readonly=True)
+
+ account_online_link_id = fields.Many2one('account.online.link', readonly=True, ondelete='cascade')
+ journal_ids = fields.One2many('account.journal', 'account_online_account_id', string='Journal', domain="[('type', 'in', ('bank', 'credit')), ('company_id', '=', company_id)]")
+ last_sync = fields.Date("Last synchronization")
+ company_id = fields.Many2one('res.company', related='account_online_link_id.company_id')
+ currency_id = fields.Many2one('res.currency')
+ fetching_status = fields.Selection(
+ selection=[
+ ('planned', 'Planned'), # When all the transactions couldn't be imported in one go and is waiting for next batch
+ ('waiting', 'Waiting'), # When waiting for the provider to fetch the transactions
+ ('processing', 'Processing'), # When currently importing in odoo
+ ('done', 'Done'), # When every transaction have been imported in odoo
+ ]
+ )
+
+ inverse_balance_sign = fields.Boolean(
+ string="Inverse Balance Sign",
+ help="If checked, the balance sign will be inverted",
+ )
+ inverse_transaction_sign = fields.Boolean(
+ string="Inverse Transaction Sign",
+ help="If checked, the transaction sign will be inverted",
+ )
+
+ @api.constrains('journal_ids')
+ def _check_journal_ids(self):
+ for online_account in self:
+ if len(online_account.journal_ids) > 1:
+ raise ValidationError(_('You cannot have two journals associated with the same Online Account.'))
+
+ @api.model_create_multi
+ def create(self, vals):
+ result = super().create(vals)
+ if any(data.get('fetching_status') in {'waiting', 'processing', 'planned'} for data in vals):
+ self.env['account.journal']._toggle_asynchronous_fetching_cron()
+ return result
+
+ def write(self, vals):
+ result = super().write(vals)
+ if vals.get('fetching_status') in {'waiting', 'processing', 'planned'}:
+ self.env['account.journal']._toggle_asynchronous_fetching_cron()
+ return result
+
+ def unlink(self):
+ result = super().unlink()
+ self.env['account.journal']._toggle_asynchronous_fetching_cron()
+ return result
+
+ def _assign_journal(self, swift_code=False):
+ """
+ This method allows to link an online account to a journal with the following heuristics
+ Also, Create and assign bank & swift/bic code if odoofin returns one
+ If a journal is present in the context (active_model = account.journal and active_id), we assume that
+ We started the journey from a journal and we assign the online_account to that particular journal.
+ Otherwise we will create a new journal on the fly and assign the online_account to it.
+ If an online_account was previously set on the journal, it will be removed and deleted.
+ This will also set the 'online_sync' source on the journal and create an activity for the consent renewal
+ The date to fetch transaction will also be set and have the following value:
+ date of the latest statement line on the journal
+ or date of the fiscalyear lock date
+ or False (we fetch transactions as far as possible)
+ """
+ currency_id = self.currency_id.id if not self.currency_id.is_current_company_currency else False
+ existing_journal = self.env['account.journal'].search([
+ ('bank_acc_number', '=', self.account_number),
+ ('currency_id', '=', currency_id),
+ ('type', '=', 'bank'),
+ ('account_online_account_id', '=', False),
+ ], limit=1)
+
+ self.ensure_one()
+ if (active_id := self.env.context.get('active_id')) and self.env.context.get('active_model') == 'account.journal':
+ journal = self.env['account.journal'].browse(active_id)
+ # If we already have a linked account on that journal, it means we are in the process of relinking
+ # it is due to an error that occured which require to redo the connection (can't fix it).
+ # Hence we delete the previously linked account.online.link to prevent showing multiple
+ # duplicate existing connections when opening the iframe
+ if journal.account_online_link_id:
+ journal.account_online_link_id.unlink()
+
+ # Ensure the journal's currency matches the bank account's currency.
+ if self.currency_id.id != journal.currency_id.id:
+ # If the journal already has entries in a different currency, raise an error.
+ statement_lines_in_other_currency = self.env['account.bank.statement.line'].search_count([
+ ('journal_id', '=', journal.id),
+ ('currency_id', 'not in', (False, self.currency_id.id)),
+ ], limit=1)
+ if statement_lines_in_other_currency:
+ raise UserError(_("Journal %(journal_name)s has been set up with a different currency and already has existing entries. "
+ "You can't link selected bank account in %(currency_name)s to it",
+ journal_name=journal.name, currency_name=self.currency_id.name))
+ else:
+ # If the journal's default bank account has entries in a differente currency, silently do nothing to avoid an error.
+ move_lines_in_other_currency = self.env['account.move.line'].search_count([
+ ('account_id', '=', journal.default_account_id.id),
+ ('currency_id', '!=', self.currency_id.id),
+ ], limit=1)
+ if not move_lines_in_other_currency:
+ # If not set yet and there are no conflicting entries, set it.
+ journal.sudo().currency_id = self.currency_id.id
+ elif existing_journal:
+ journal = existing_journal
+ else:
+ journal = self.env['account.journal'].create({
+ 'name': self.account_number or self.display_name,
+ 'code': self.env['account.journal'].get_next_bank_cash_default_code('bank', self.env.company),
+ 'type': 'bank',
+ 'company_id': self.env.company.id,
+ 'currency_id': currency_id,
+ })
+
+ self.sudo().journal_ids = journal
+
+ journal_vals = {
+ 'bank_statements_source': 'online_sync',
+ }
+ if self.account_number and not self.journal_ids.bank_acc_number:
+ journal_vals['bank_acc_number'] = self.account_number
+ self.journal_ids.sudo().write(journal_vals)
+ # Update connection status and get consent expiration date and create an activity on related journal
+ self.account_online_link_id._update_connection_status()
+
+ # Set last_sync date (date of latest statement or one day after accounting lock date or False)
+ lock_date = self.env.company._get_user_fiscal_lock_date(journal)
+ last_sync = lock_date + relativedelta(days=1) if lock_date and lock_date > datetime.date.min else None
+ bnk_stmt_line = self.env['account.bank.statement.line'].search([('journal_id', 'in', self.journal_ids.ids)], order="date desc", limit=1)
+ if bnk_stmt_line:
+ last_sync = bnk_stmt_line.date
+ self.last_sync = last_sync
+
+ if swift_code:
+ if self.journal_ids.bank_account_id.bank_id:
+ if not self.journal_ids.bank_account_id.bank_id.bic:
+ self.journal_ids.bank_account_id.bank_id.bic = swift_code
+ else:
+ bank_rec = self.env['res.bank'].search([('bic', '=', swift_code)], limit=1)
+ if not bank_rec:
+ bank_rec = self.env['res.bank'].create({'name': self.account_online_link_id.display_name, 'bic': swift_code})
+ self.journal_ids.bank_account_id.bank_id = bank_rec.id
+
+ def _refresh(self):
+ """
+ This method is called on an online_account in order to check the current refresh status of the
+ account. If we are in manual mode and if the provider allows it, this will also trigger a
+ manual refresh on the provider side. Call to /proxy/v1/refresh will return a boolean
+ telling us if the refresh was successful or not. When not successful, we should avoid
+ trying to fetch transactions. Cases where we can receive an unsuccessful response are as follow
+ (non exhaustive list)
+ - Another refresh was made too early and provider/bank limit the number of refresh allowed
+ - Provider is in the process of importing the transactions so we should wait until he has
+ finished before fetching them in Odoo
+ :return: True if provider has refreshed the account and we can start fetching transactions
+ """
+ data = {'account_id': self.online_identifier}
+ while True:
+ # While this is kind of a bad practice to do, it can happen that provider_data/account_data change between
+ # 2 calls, the reason is that those field contains the encrypted information needed to access the provider
+ # and first call can result in an error due to the encrypted token inside provider_data being expired for example.
+ # In such a case, we renew the token with the provider and send back the newly encrypted token inside provider_data
+ # which result in the information having changed, henceforth why those fields are passed at every loop.
+ data.update({
+ 'provider_data': self.account_online_link_id.provider_data,
+ 'account_data': self.account_data,
+ 'fetching_status': self.fetching_status,
+ })
+ resp_json = self.account_online_link_id._fetch_odoo_fin('/proxy/v1/refresh', data=data)
+ if resp_json.get('account_data'):
+ self.account_data = resp_json['account_data']
+ currently_fetching = resp_json.get('currently_fetching')
+ success = resp_json.get('success', True)
+ if currently_fetching:
+ # Provider has not finished fetching transactions, set status to waiting
+ self.fetching_status = 'waiting'
+ if not resp_json.get('next_data'):
+ break
+ data['next_data'] = resp_json.get('next_data') or {}
+ return {'success': not currently_fetching and success, 'data': resp_json.get('data', {})}
+
+ def _retrieve_transactions(self, date=None, include_pendings=False):
+ last_stmt_line = self.env['account.bank.statement.line'].search([
+ ('date', '<=', self.last_sync or fields.Date().today()),
+ ('online_transaction_identifier', '!=', False),
+ ('journal_id', 'in', self.journal_ids.ids),
+ ('online_account_id', '=', self.id)
+ ], order="date desc", limit=1)
+ transactions = []
+
+ start_date = date or last_stmt_line.date or self.last_sync
+ data = {
+ # If we are in a new sync, we do not give a start date; We will fetch as much as possible. Otherwise, the last sync is the start date.
+ 'start_date': start_date and format_date(self.env, start_date, date_format='yyyy-MM-dd'),
+ 'account_id': self.online_identifier,
+ 'last_transaction_identifier': last_stmt_line.online_transaction_identifier if not include_pendings else None,
+ 'currency_code': self.currency_id.name or self.journal_ids[0].currency_id.name or self.company_id.currency_id.name,
+ 'include_pendings': include_pendings,
+ 'include_foreign_currency': True,
+ }
+ pendings = []
+ while True:
+ # While this is kind of a bad practice to do, it can happen that provider_data/account_data change between
+ # 2 calls, the reason is that those field contains the encrypted information needed to access the provider
+ # and first call can result in an error due to the encrypted token inside provider_data being expired for example.
+ # In such a case, we renew the token with the provider and send back the newly encrypted token inside provider_data
+ # which result in the information having changed, henceforth why those fields are passed at every loop.
+ data.update({
+ 'provider_data': self.account_online_link_id.provider_data,
+ 'account_data': self.account_data,
+ })
+ resp_json = self.account_online_link_id._fetch_odoo_fin('/proxy/v1/transactions', data=data)
+ if resp_json.get('balance'):
+ sign = -1 if self.inverse_balance_sign else 1
+ self.balance = sign * resp_json['balance']
+ if resp_json.get('account_data'):
+ self.account_data = resp_json['account_data']
+ transactions += resp_json.get('transactions', [])
+ pendings += resp_json.get('pendings', [])
+ if not resp_json.get('next_data'):
+ break
+ data['next_data'] = resp_json.get('next_data') or {}
+
+ return {
+ 'transactions': self._format_transactions(transactions),
+ 'pendings': self._format_transactions(pendings),
+ }
+
+ def get_formatted_balances(self):
+ balances = {}
+ for account in self:
+ if account.currency_id:
+ formatted_balance = format_amount(self.env, account.balance, account.currency_id)
+ else:
+ formatted_balance = '%.2f' % account.balance
+ balances[account.id] = [formatted_balance, account.balance]
+ return balances
+
+ ###########
+ # HELPERS #
+ ###########
+
+ def _get_filtered_transactions(self, new_transactions):
+ """ This function will filter transaction to avoid duplicate transactions.
+ To do that, we're comparing the received online_transaction_identifier with
+ those in the database. If there is a match, the new transaction is ignored.
+ """
+ self.ensure_one()
+
+ journal_id = self.journal_ids[0]
+ existing_bank_statement_lines = self.env['account.bank.statement.line'].search_fetch(
+ [
+ ('journal_id', '=', journal_id.id),
+ ('online_transaction_identifier', 'in', [
+ transaction['online_transaction_identifier']
+ for transaction in new_transactions
+ if transaction.get('online_transaction_identifier')
+ ]),
+ ],
+ ['online_transaction_identifier']
+ )
+ existing_online_transaction_identifier = set(existing_bank_statement_lines.mapped('online_transaction_identifier'))
+
+ filtered_transactions = []
+ # Remove transactions already imported in Odoo
+ for transaction in new_transactions:
+ if transaction_identifier := transaction['online_transaction_identifier']:
+ if transaction_identifier in existing_online_transaction_identifier:
+ continue
+ existing_online_transaction_identifier.add(transaction_identifier)
+
+ filtered_transactions.append(transaction)
+ return filtered_transactions
+
+ def _format_transactions(self, new_transactions):
+ """ This function format transactions:
+ It will:
+ - Replace the foreign currency code with the corresponding currency id and activating the currencies that are not active
+ - Change inverse the transaction sign if the setting is activated
+ - Parsing the date
+ - Setting the account online account and the account journal
+ """
+ self.ensure_one()
+ transaction_sign = -1 if self.inverse_transaction_sign else 1
+ currencies = self.env['res.currency'].with_context(active_test=False).search([])
+ currency_code_mapping = {currency.name: currency for currency in currencies}
+
+ formatted_transactions = []
+ for transaction in new_transactions:
+ if transaction.get('foreign_currency_code'):
+ currency = currency_code_mapping.get(transaction.pop('foreign_currency_code'))
+ if currency:
+ transaction.update({'foreign_currency_id': currency.id})
+ if not currency.active:
+ currency.active = True
+
+ formatted_transactions.append({
+ **transaction,
+ 'amount': transaction['amount'] * transaction_sign,
+ 'date': fields.Date.from_string(transaction['date']),
+ 'online_account_id': self.id,
+ 'journal_id': self.journal_ids[0].id,
+ 'company_id': self.company_id.id,
+ })
+ return formatted_transactions
+
+ def action_reset_fetching_status(self):
+ """
+ This action will reset the fetching status to avoid the problem when there is an error during the
+ synchronisation that would block the customer with his connection since we block the fetch due that value.
+ With this he has a button that can reset the fetching status.
+ """
+ self.fetching_status = None
+
+
+class AccountOnlineLink(models.Model):
+ _name = 'account.online.link'
+ _description = 'Bank Connection'
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+
+ def _compute_next_synchronization(self):
+ for rec in self:
+ rec.next_refresh = self.env['ir.cron'].sudo().search([('id', '=', self.env.ref('odex30_account_online_sync.online_sync_cron').id)], limit=1).nextcall
+
+ account_online_account_ids = fields.One2many('account.online.account', 'account_online_link_id')
+ last_refresh = fields.Datetime(readonly=True, default=fields.Datetime.now)
+ next_refresh = fields.Datetime("Next synchronization", compute='_compute_next_synchronization')
+ state = fields.Selection([('connected', 'Connected'), ('error', 'Error'), ('disconnected', 'Not Connected')],
+ default='disconnected', tracking=True, required=True, readonly=True)
+ connection_state_details = fields.Json()
+ auto_sync = fields.Boolean(
+ default=True,
+ string="Automatic synchronization",
+ help="""If possible, we will try to automatically fetch new transactions for this record
+ \nIf the automatic sync is disabled. that will be due to security policy on the bank's end. So, they have to launch the sync manually""",
+ )
+ company_id = fields.Many2one('res.company', required=True, default=lambda self: self.env.company)
+ has_unlinked_accounts = fields.Boolean(default=True, help="True if that connection still has accounts that are not linked to an Odoo journal")
+ show_sync_actions = fields.Boolean(compute='_compute_show_sync_actions')
+
+ # Information received from OdooFin, should not be tampered with
+ name = fields.Char(help="Institution Name", readonly=True)
+ client_id = fields.Char(help="Represent a link for a given user towards a banking institution", readonly=True)
+ refresh_token = fields.Char(help="Token used to sign API request, Never disclose it",
+ readonly=True, groups="base.group_system")
+ access_token = fields.Char(help="Token used to access API.", readonly=True, groups="account.group_account_basic")
+ provider_data = fields.Char(help="Information needed to interact with third party provider", readonly=True)
+ expiring_synchronization_date = fields.Date(help="Date when the consent for this connection expires",
+ readonly=True)
+ journal_ids = fields.One2many('account.journal', compute='_compute_journal_ids')
+ provider_type = fields.Char(help="Third Party Provider", readonly=True)
+
+ ###################
+ # Compute methods #
+ ###################
+
+ @api.depends('account_online_account_ids')
+ def _compute_journal_ids(self):
+ for online_link in self:
+ online_link.journal_ids = online_link.account_online_account_ids.journal_ids
+
+ @api.depends('company_id')
+ @api.depends_context('allowed_company_ids')
+ def _compute_show_sync_actions(self):
+ for online_link in self:
+ online_link.show_sync_actions = online_link.company_id in self.env.companies
+
+ ##########################
+ # Wizard opening actions #
+ ##########################
+ def create_new_bank_account_action(self, data=None):
+ self.ensure_one()
+ # We do return the bank account setup wizard if we don't have minimum info
+ if not data or not data.get('account_number'):
+ ctx = self.env.context
+ # if this was called from kanban box, active_model is in context
+ if ctx.get('active_model') == 'account.journal':
+ ctx = {**ctx, 'default_linked_journal_id': ctx.get('active_id', False), 'dialog_size': 'medium'}
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Setup Bank Account'),
+ 'res_model': 'account.setup.bank.manual.config',
+ 'target': 'new',
+ 'view_mode': 'form',
+ 'context': ctx,
+ 'views': [(False, 'form')],
+ }
+
+ bank = self.env['res.bank']
+ if data.get('name'):
+ bank = self.env['res.bank'].sudo().create({
+ 'name': data['name'],
+ 'bic': data.get('swift_code'),
+ })
+
+ bank_account = self.env['res.partner.bank'].sudo().create({
+ 'acc_number': data.get('account_number'),
+ 'bank_id': bank.id,
+ 'partner_id': self.company_id.partner_id.id,
+ })
+
+ self.env['account.journal'].sudo().create({
+ 'name': data.get('account_number'),
+ 'type': data.get('journal_type') or 'bank',
+ 'bank_account_id': bank_account.id,
+ })
+
+ return {'type': 'ir.actions.client', 'tag': 'soft_reload'}
+
+ def _link_accounts_to_journals_action(self, swift_code):
+ """
+ This method opens a wizard allowing the user to link
+ his bank accounts with new or existing journal.
+ :return: An action openning a wizard to link bank accounts with account journal.
+ """
+ self.ensure_one()
+ account_bank_selection_wizard = self.env['account.bank.selection'].create({
+ 'account_online_link_id': self.id,
+ })
+
+ return {
+ "name": _("Select a Bank Account"),
+ "type": "ir.actions.act_window",
+ "res_model": "account.bank.selection",
+ "views": [[False, "form"]],
+ "target": "new",
+ "res_id": account_bank_selection_wizard.id,
+ 'context': dict(self.env.context, swift_code=swift_code),
+ }
+
+ @api.model
+ def _show_fetched_transactions_action(self, stmt_line_ids, duplicates_from_date):
+ return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
+ extra_domain=[('id', 'in', stmt_line_ids.ids)],
+ name=_('Fetched Transactions'),
+ **({'default_context': {'duplicates_from_date': duplicates_from_date}} if duplicates_from_date else {}),
+ )
+
+ def _get_connection_state_details(self, journal):
+ self.ensure_one()
+ if self.connection_state_details and self.connection_state_details.get(str(journal.id)):
+ # We have to check that we have a key and a right value for this journal
+ # Because if we have an empty dict, the JS part will handle it as a Proxy object.
+ # To avoid that, we checked if we have a key in the dict and if the value is truthy.
+ return self.connection_state_details[str(journal.id)]
+ return None
+
+ def _pop_connection_state_details(self, journal):
+ self.ensure_one()
+ if journal_connection_state_details := self._get_connection_state_details(journal):
+ self._set_connection_state_details(journal, {})
+ return journal_connection_state_details
+ return None
+
+ def _set_connection_state_details(self, journal, connection_state_details):
+ self.ensure_one()
+ existing_connection_state_details = self.connection_state_details or {}
+ self.connection_state_details = {
+ **existing_connection_state_details,
+ str(journal.id): connection_state_details,
+ }
+
+ def _notify_connection_update(self, journal, connection_state_details):
+ """ The aim of this function is saving the last connection state details
+ (like if the status is success or in error) on the account.online.link
+ object. At the same moment, we're sending a websocket message to
+ accounting dashboard where we return the status of the connection.
+ To make sure that we don't return sensitive information, we filtered
+ the connection state details to only send by websocket information
+ like the connection status, how many transactions we fetched, and
+ the error type. In case of an error, the function is calling rollback
+ on the cursor and is committing the save on the account online link.
+ It's also usefull to commit in case of error to send the websocket message.
+ The commit is only called if we aren't in test mode and if the connection is
+ in error.
+
+ :param journal: The journal for which we want to save the connection state details.
+ :param connection_state_details: The information about the status of the connection (like how many transactions fetched, ...)
+ """
+ self.ensure_one()
+
+ connection_state_details_status = connection_state_details['status'] # We're always waiting for a status in the dict.
+ if connection_state_details_status == 'error':
+ # In case the connection status is in error, we roll back everything before saving the status.
+ self.env.cr.rollback()
+ if not (connection_state_details_status == 'success' and connection_state_details.get('nb_fetched_transactions', 0) == 0):
+ self._set_connection_state_details(
+ journal=journal,
+ connection_state_details=connection_state_details,
+ )
+ self.env.ref('account.group_account_user').users._bus_send(
+ 'online_sync',
+ {
+ 'id': journal.id,
+ 'connection_state_details': {
+ key: value
+ for key, value in connection_state_details.items()
+ if key in ('status', 'error_type', 'nb_fetched_transactions')
+ },
+ },
+ )
+ if connection_state_details_status == 'error' and not tools.config['test_enable'] and not modules.module.current_test:
+ # In case the status is in error, and we aren't in test mode, we commit to save the last connection state and to send the websocket message
+ self.env.cr.commit()
+
+ def _handle_odoofin_redirect_exception(self, mode='link'):
+ if mode == 'link':
+ return self.with_context({'redirect_reconnection': True}).action_new_synchronization()
+ return self.with_context({'redirect_reconnection': True})._open_iframe(mode=mode)
+
+ #######################################################
+ # Generic methods to contact server and handle errors #
+ #######################################################
+
+ @api.model
+ def _get_odoofin_url(self, url):
+ proxy_mode = self.env['ir.config_parameter'].sudo().get_param('odex30_account_online_sync.proxy_mode') or 'production'
+ if not pattern.match(proxy_mode) and not runbot_pattern.match(proxy_mode):
+ raise UserError(_('Invalid value for proxy_mode config parameter.'))
+ endpoint_url = 'https://%s.odoofin.com%s' % (proxy_mode, url)
+ if runbot_pattern.match(proxy_mode):
+ endpoint_url = '%s%s' % (proxy_mode, url)
+ return endpoint_url
+
+ def _fetch_odoo_fin(self, url, data=None, ignore_status=False):
+ """
+ Method used to fetch data from the Odoo Fin proxy.
+ :param url: Proxy's URL end point.
+ :param data: HTTP data request.
+ :return: A dict containing all data.
+ """
+ if not data:
+ data = {}
+ if self.state == 'disconnected' and not ignore_status:
+ raise UserError(_('Please reconnect your online account.'))
+ if not url.startswith('/'):
+ raise UserError(_('Invalid URL'))
+
+ timeout = int(self.env['ir.config_parameter'].sudo().get_param('odex30_account_online_sync.request_timeout')) or 60
+ endpoint_url = self._get_odoofin_url(url)
+ cron = self.env.context.get('cron', False)
+ data['utils'] = {
+ 'request_timeout': timeout,
+ 'lang': get_lang(self.env).code,
+ 'server_version': odoo.release.serie,
+ 'db_uuid': self.env['ir.config_parameter'].sudo().get_param('database.uuid'),
+ 'cron': cron,
+ }
+ if request:
+ # many banking institutions require the end-user IP/user_agent for traceability
+ # of client-initiated actions. It won't be stored on odoofin side.
+ data['utils']['psu_info'] = {
+ 'ip': request.httprequest.remote_addr,
+ 'user_agent': request.httprequest.user_agent.string,
+ }
+
+ try:
+ # We have to use sudo to pass record as some fields are protected from read for common users.
+ resp = requests.post(url=endpoint_url, json=data, timeout=timeout, auth=OdooFinAuth(record=self.sudo()))
+ resp_json = resp.json()
+ return self._handle_response(resp_json, url, data, ignore_status)
+ except (Timeout, ConnectionError, RequestException, ValueError):
+ _logger.warning('synchronization error')
+ raise UserError(
+ _("The online synchronization service is not available at the moment. "
+ "Please try again later."))
+
+ def _handle_response(self, resp_json, url, data, ignore_status=False):
+ # Response is a json-rpc response, therefore data is encapsulated inside error in case of error
+ # and inside result in case of success.
+ if not resp_json.get('error'):
+ result = resp_json.get('result')
+ state = result.get('odoofin_state') or False
+ message = result.get('display_message') or False
+ subject = message and _('Message') or False
+ self._log_information(state=state, message=message, subject=subject)
+ if result.get('provider_data'):
+ # Provider_data is extremely important and must be saved as soon as we received it
+ # as it contains encrypted credentials from external provider and if we loose them we
+ # loose access to the bank account, As it is possible that provider_data
+ # are received during a transaction containing multiple calls to the proxy, we ensure
+ # that provider_data is committed in database as soon as we received it.
+ self.provider_data = result.get('provider_data')
+ self.env.cr.commit()
+ return result
+ else:
+ error = resp_json.get('error')
+ # Not considered as error
+ if error.get('code') == 101: # access token expired, not an error
+ self._get_access_token()
+ return self._fetch_odoo_fin(url, data, ignore_status)
+ elif error.get('code') == 102: # refresh token expired, not an error
+ self._get_refresh_token()
+ self._get_access_token()
+ # We need to commit here because if we got a new refresh token, and a new access token
+ # It means that the token is active on the proxy and any further call resulting in an
+ # error would lose the new refresh_token hence blocking the account ad vitam eternam
+ self.env.cr.commit()
+ if self.journal_ids: # We can't do it unless we already have a journal
+ self._update_connection_status()
+ return self._fetch_odoo_fin(url, data, ignore_status)
+ elif error.get('code') == 300: # redirect, not an error
+ raise OdooFinRedirectException(mode=error.get('data', {}).get('mode', 'link'))
+ # If we are in the process of deleting the record ignore code 100 (invalid signature), 104 (account deleted)
+ # 106 (provider_data corrupted) and allow user to delete his record from this side.
+ elif error.get('code') in (100, 104, 106) and self.env.context.get('delete_sync'):
+ return {'delete': True}
+ # Log and raise error
+ error_details = error.get('data')
+ subject = error.get('message')
+ message = error_details.get('message')
+ state = error_details.get('odoofin_state') or 'error'
+ ctx = self.env.context.copy()
+ ctx['error_reference'] = error_details.get('error_reference')
+ ctx['provider_type'] = error_details.get('provider_type')
+ ctx['redirect_warning_url'] = error_details.get('redirect_warning_url')
+
+ self.with_context(ctx)._log_information(state=state, subject=subject, message=message, reset_tx=True)
+
+ def _log_information(self, state, subject=None, message=None, reset_tx=False):
+ # If the reset_tx flag is passed, it means that we have an error, and we want to log it on the record
+ # and then raise the error to the end user. To do that we first roll back the current transaction,
+ # then we write the error on the record, we commit those changes, and finally we raise the error.
+ if reset_tx:
+ self.env.cr.rollback()
+ try:
+ # if state is disconnected, and new state is error: ignore it
+ if state == 'error' and self.state == 'disconnected':
+ state = 'disconnected'
+ if state and self.state != state:
+ self.write({'state': state})
+ if state in ('error', 'disconnected'):
+ self.account_online_account_ids.fetching_status = 'done'
+ if reset_tx:
+ context = self.env.context
+ button_label = url = None
+ if subject and message:
+ message_post = message
+ error_reference = context.get('error_reference')
+ provider = context.get('provider_type')
+ odoo_help_description = f'''ClientID: {self.client_id}\nInstitution: {self.name}\nError Reference: {error_reference}\nError Message: {message_post}\n'''
+ odoo_help_summary = f'Bank sync error ref: {error_reference} - Provider: {provider} - Client ID: {self.client_id}'
+ if context.get('redirect_warning_url'):
+ if context['redirect_warning_url'] == 'odoo_support':
+ url_params = urllib.parse.urlencode({'stage': 'bank_sync', 'summary': odoo_help_summary, 'description': odoo_help_description[:1500]})
+ url = f'https://www.odoo.com/help?{url_params}'
+ message += _("\n\nIf you've already opened a ticket for this issue, don't report it again: a support agent will contact you shortly.")
+ message_post = Markup('%s %s %s') % (message, _("You can contact Odoo support"), url, _("Here"))
+ button_label = _('Report issue')
+ else:
+ url = "https://www.odoo.com/documentation/18.0/applications/finance/accounting/bank/bank_synchronization.html#faq"
+ message_post = Markup('%s %s %s') % (message_post, _("Check the documentation"), url, _("Here"))
+ button_label = _('Check the documentation')
+ self.message_post(body=message_post, subject=subject)
+ # In case of reset_tx, we commit the changes in order to have the message post saved
+ self.env.cr.commit()
+ # and then raise either a redirectWarning error so that customer can easily open an issue with Odoo,
+ # or eventually bring the user to the documentation if there's no need to contact the support.
+ if url:
+ action_id = {
+ "type": "ir.actions.act_url",
+ "url": url,
+ }
+ raise RedirectWarning(message, action_id, button_label) #pylint: disable=E0601
+ # either a userError if there's no need to bother the support, or link to the doc.
+ raise UserError(message)
+ except (CacheMiss, MissingError):
+ # This exception can happen if record was created and rollbacked due to error in same transaction
+ # Therefore it is not possible to log information on it, in this case we just ignore it.
+ pass
+
+ ###############
+ # API methods #
+ ###############
+
+ def _get_access_token(self):
+ for link in self:
+ resp_json = link._fetch_odoo_fin('/proxy/v1/get_access_token', ignore_status=True)
+ link.access_token = resp_json.get('access_token', False)
+
+ def _get_refresh_token(self):
+ # Use sudo as refresh_token field is not accessible to most user
+ for link in self.sudo():
+ resp_json = link._fetch_odoo_fin('/proxy/v1/renew_token', ignore_status=True)
+ link.refresh_token = resp_json.get('refresh_token', False)
+
+ def unlink(self):
+ to_unlink = self.env['account.online.link']
+ for link in self:
+ try:
+ resp_json = link.with_context(delete_sync=True)._fetch_odoo_fin('/proxy/v1/delete_user', data={'provider_data': link.provider_data}, ignore_status=True) # delete proxy user
+ if resp_json.get('delete', True) is True:
+ to_unlink += link
+ except OdooFinRedirectException:
+ # Can happen that this call returns a redirect in mode link, in which case we delete the record
+ to_unlink += link
+ continue
+ except (UserError, RedirectWarning):
+ to_unlink += link
+ continue
+ result = super(AccountOnlineLink, to_unlink).unlink()
+ self.env['account.journal']._toggle_asynchronous_fetching_cron()
+ return result
+
+ def _fetch_accounts(self, online_identifier=False):
+ self.ensure_one()
+ if online_identifier:
+ matching_account = self.account_online_account_ids.filtered(lambda l: l.online_identifier == online_identifier)
+ # Ignore account that is already there and linked to a journal as there is no need to fetch information for that one
+ if matching_account and matching_account.journal_ids:
+ return matching_account
+ # If we have the account locally but didn't link it to a journal yet, delete it first.
+ # This way, we'll get the information back from the proxy with updated balances. Avoiding potential issues.
+ elif matching_account and not matching_account.journal_ids:
+ matching_account.unlink()
+ accounts = {}
+ data = {
+ 'currency_code': self.company_id.currency_id.name,
+ }
+ swift_code = False
+ while True:
+ # While this is kind of a bad practice to do, it can happen that provider_data changes between
+ # 2 calls, the reason is that that field contains the encrypted information needed to access the provider
+ # and first call can result in an error due to the encrypted token inside provider_data being expired for example.
+ # In such a case, we renew the token with the provider and send back the newly encrypted token inside provider_data
+ # which result in the information having changed, henceforth why that field is passed at every loop.
+ data['provider_data'] = self.provider_data
+ # Retrieve information about a specific account
+ if online_identifier:
+ data['online_identifier'] = online_identifier
+
+ resp_json = self._fetch_odoo_fin('/proxy/v1/accounts', data)
+ for acc in resp_json.get('accounts', []):
+ acc['account_online_link_id'] = self.id
+ currency_id = self.env['res.currency'].with_context(active_test=False).search([('name', '=', acc.pop('currency_code', ''))], limit=1)
+ if currency_id:
+ if not currency_id.active:
+ currency_id.sudo().active = True
+ acc['currency_id'] = currency_id.id
+ accounts[str(acc.get('online_identifier'))] = acc
+ swift_code = resp_json.get('swift_code')
+ if not resp_json.get('next_data'):
+ break
+ data['next_data'] = resp_json.get('next_data')
+
+ if accounts:
+ self.has_unlinked_accounts = True
+ return self.env['account.online.account'].create(accounts.values()), swift_code
+ return False, False
+
+ def _pre_check_fetch_transactions(self):
+ self.ensure_one()
+ # 'limit_time_real_cron' and 'limit_time_real' default respectively to -1 and 120.
+ # Manual fallbacks applied for non-POSIX systems where this key is disabled (set to None).
+ limit_time = tools.config['limit_time_real_cron'] or -1
+ if limit_time <= 0:
+ limit_time = tools.config['limit_time_real'] or 120
+ limit_time += 20 # Add 20 seconds to be sure that the process will have been killed
+ # if any account is actually creating entries and last_refresh was made less than cron_limit_time ago, skip fetching
+ if (self.account_online_account_ids.filtered(lambda account: account.fetching_status == 'processing') and
+ self.last_refresh + relativedelta(seconds=limit_time) > fields.Datetime.now()):
+ return False
+ # If not in the process of importing and auto_sync is not set, skip fetching
+ if (self.env.context.get('cron') and
+ not self.auto_sync and
+ not self.account_online_account_ids.filtered(lambda acc: acc.fetching_status in ('planned', 'waiting', 'processing'))):
+ return False
+ return True
+
+ def _fetch_transactions(self, refresh=True, accounts=False, check_duplicates=False):
+ self.ensure_one()
+ # return early if condition to fetch transactions are not met
+ if not self._pre_check_fetch_transactions():
+ return
+
+ is_cron_running = self.env.context.get('cron')
+ acc = (accounts or self.account_online_account_ids).filtered('journal_ids')
+ self.last_refresh = fields.Datetime.now()
+ try:
+ # When manually fetching, refresh must still be done in case a redirect occurs
+ # however since transactions are always fetched inside a cron, in case we are manually
+ # fetching, trigger the cron and redirect customer to accounting dashboard
+ accounts_to_synchronize = acc
+ if not is_cron_running:
+ accounts_not_to_synchronize = self.env['account.online.account']
+ account_to_reauth = False
+ for online_account in acc:
+ # Only get transactions on account linked to a journal
+ if refresh and online_account.fetching_status not in ('planned', 'processing'):
+ refresh_res = online_account._refresh()
+ if not refresh_res['success']:
+ if refresh_res['data'].get('mode') == 'updateCredentials':
+ account_to_reauth = online_account
+ accounts_not_to_synchronize += online_account
+ continue
+ online_account.fetching_status = 'waiting'
+ if account_to_reauth:
+ return self._open_iframe(
+ mode='updateCredentials',
+ include_param={
+ 'account_online_identifier': account_to_reauth.online_identifier,
+ },
+ )
+ accounts_to_synchronize = acc - accounts_not_to_synchronize
+ if not accounts_to_synchronize:
+ return
+
+ def get_duplicates_from_date(statement_lines, journal):
+ if check_duplicates and statement_lines:
+ from_date = fields.Date.to_string(statement_lines.sorted('date')[0].date)
+ if journal._has_duplicate_transactions(from_date):
+ return from_date
+
+ for online_account in accounts_to_synchronize:
+ journal = online_account.journal_ids[0]
+ online_account.fetching_status = 'processing'
+ # Committing here so that multiple thread calling this method won't execute in parallel and import duplicates transaction
+ self.env.cr.commit()
+ try:
+ transactions = online_account._retrieve_transactions().get('transactions', [])
+ except RedirectWarning as redirect_warning:
+ self._notify_connection_update(
+ journal=journal,
+ connection_state_details={
+ 'status': 'error',
+ 'error_type': 'redirect_warning',
+ 'error_message': redirect_warning.args[0],
+ 'action': redirect_warning.args[1],
+ },
+ )
+ raise
+ except OdooFinRedirectException as redirect_exception:
+ self._notify_connection_update(
+ journal=journal,
+ connection_state_details={
+ 'status': 'error',
+ 'error_type': 'odoofin_redirect',
+ 'action': self._handle_odoofin_redirect_exception(mode=redirect_exception.mode),
+ },
+ )
+ raise
+
+ sorted_transactions = sorted(transactions, key=lambda transaction: transaction['date'])
+ if not is_cron_running:
+ # we want to import the first 100 transaction, show them to the user
+ # and import the rest asynchronously with the 'online_sync_cron_waiting_synchronization' cron
+ total = sum([transaction['amount'] for transaction in transactions])
+ statement_lines = self.env['account.bank.statement.line'].with_context(transactions_total=total)._online_sync_bank_statement(sorted_transactions[:100], online_account)
+ online_account.fetching_status = 'planned' if len(transactions) > 100 else 'done'
+ domain = None
+ if statement_lines:
+ domain = [('id', 'in', statement_lines.ids)]
+
+ duplicates_from_date = get_duplicates_from_date(statement_lines, journal)
+ return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
+ extra_domain=domain,
+ name=_('Fetched Transactions'),
+ default_context={**self.env.context, 'default_journal_id': journal.id, 'duplicates_from_date': duplicates_from_date},
+ )
+ else:
+ statement_lines = self.env['account.bank.statement.line']._online_sync_bank_statement(sorted_transactions, online_account)
+ online_account.fetching_status = 'done'
+ duplicates_from_date = get_duplicates_from_date(statement_lines, journal)
+ self._notify_connection_update(
+ journal=journal,
+ connection_state_details={
+ 'status': 'success',
+ 'nb_fetched_transactions': len(statement_lines),
+ 'action': self._show_fetched_transactions_action(statement_lines, duplicates_from_date),
+ },
+ )
+ return
+ except OdooFinRedirectException as e:
+ return self._handle_odoofin_redirect_exception(mode=e.mode)
+
+ def _get_consent_expiring_date(self, data=None):
+ self.ensure_one()
+ if not data: # Small hack to avoid breaking the stable policy
+ data = self._fetch_odoo_fin('/proxy/v1/consent_expiring_date', ignore_status=True)
+
+ if data.get('consent_expiring_date'):
+ expiring_synchronization_date = fields.Date.to_date(data['consent_expiring_date'])
+ if expiring_synchronization_date != self.expiring_synchronization_date:
+ # TDE TODO: master: use generic activity mixin methods instead
+ bank_sync_activity_type_id = self.env.ref('odex30_account_online_sync.bank_sync_activity_update_consent')
+ account_journal_model_id = self.env['ir.model']._get_id('account.journal')
+
+ # Remove old activities
+ self.env['mail.activity'].search([
+ ('res_id', 'in', self.journal_ids.ids),
+ ('res_model_id', '=', account_journal_model_id),
+ ('activity_type_id', '=', bank_sync_activity_type_id.id),
+ ('date_deadline', '<=', self.expiring_synchronization_date),
+ ('user_id', '=', self.env.user.id),
+ ]).unlink()
+
+ # Create a new activity for each journals for this synch
+ self.expiring_synchronization_date = expiring_synchronization_date
+ new_activity_vals = []
+ for journal in self.journal_ids:
+ new_activity_vals.append({
+ 'res_id': journal.id,
+ 'res_model_id': account_journal_model_id,
+ 'date_deadline': self.expiring_synchronization_date,
+ 'summary': _("Bank Synchronization: Update your consent"),
+ 'note': data.get('activity_message') or '',
+ 'activity_type_id': bank_sync_activity_type_id.id,
+ })
+ self.env['mail.activity'].create(new_activity_vals)
+ elif self.expiring_synchronization_date and self.expiring_synchronization_date < fields.Date.context_today(self):
+ # Avoid an infinite "expired synchro" if the provider
+ # doesn't send us a new consent expiring date
+ self.expiring_synchronization_date = None
+
+ def _update_connection_status(self):
+ self.ensure_one()
+ resp_json = self._fetch_odoo_fin('/proxy/v2/connection_status', ignore_status=True)
+
+ self._get_consent_expiring_date(resp_json)
+
+ # Returning what we receive from Odoo Fin to allow function extension
+ return resp_json
+
+ def _authorize_access(self, data_access_token):
+ """
+ This method is used to allow an existing connection to give temporary access
+ to a new connection in order to see the list of available unlinked accounts.
+ We pass as parameter the list of already linked account, so that if there are
+ no more accounts to link, we will receive a response telling us so and we won't
+ call authorize for that connection later on.
+ """
+ self.ensure_one()
+ data = {
+ 'linked_accounts': self.account_online_account_ids.filtered('journal_ids').mapped('online_identifier'),
+ 'record_access_token': data_access_token,
+ }
+ try:
+ resp_json = self._fetch_odoo_fin('/proxy/v1/authorize_access', data)
+ self.has_unlinked_accounts = resp_json.get('has_unlinked_accounts')
+ except UserError:
+ # We don't want to throw an error to the customer so ignore error
+ pass
+
+ @api.model
+ def _cron_delete_unused_connection(self):
+ account_online_links = self.search([
+ ('write_date', '<=', fields.Datetime.now() - relativedelta(months=1)),
+ ])
+ for link in account_online_links:
+ if not link.account_online_account_ids.filtered('journal_ids'):
+ link.unlink()
+
+ @api.returns('mail.message', lambda value: value.id)
+ def message_post(self, **kwargs):
+ """Override to log all message to the linked journal as well."""
+ for journal in self.journal_ids:
+ journal.message_post(**kwargs)
+ return super(AccountOnlineLink, self).message_post(**kwargs)
+
+ ################################
+ # Callback methods from iframe #
+ ################################
+
+ def success(self, mode, data):
+ if data:
+ self.write(data)
+ # Provider_data is extremely important and must be saved as soon as we received it
+ # as it contains encrypted credentials from external provider and if we loose them we
+ # loose access to the bank account, As it is possible that provider_data
+ # are received during a transaction containing multiple calls to the proxy, we ensure
+ # that provider_data is committed in database as soon as we received it.
+ if data.get('provider_data'):
+ self.env.cr.commit()
+
+ self._update_connection_status()
+ # if for some reason we just have to update the record without doing anything else, the mode will be set to 'none'
+ if mode == 'none':
+ return {'type': 'ir.actions.client', 'tag': 'reload'}
+ try:
+ method_name = '_success_%s' % mode
+ method = getattr(self, method_name)
+ except AttributeError:
+ message = _("This version of Odoo appears to be outdated and does not support the '%s' sync mode. "
+ "Installing the latest update might solve this.", mode)
+ _logger.info('Online sync: %s' % (message,))
+ self.env.cr.rollback()
+ self._log_information(state='error', subject=_('Internal Error'), message=message, reset_tx=True)
+ raise UserError(message)
+ action = method()
+ return action or self.env['ir.actions.act_window']._for_xml_id('account.open_account_journal_dashboard_kanban')
+
+ @api.model
+ def connect_existing_account(self, data):
+ # extract client_id and online_identifier from data and retrieve the account detail from the connection.
+ # If we have a journal in context, assign to journal, otherwise create new journal then fetch transaction
+ client_id = data.get('client_id')
+ online_identifier = data.get('online_identifier')
+ if client_id and online_identifier:
+ online_link = self.search([('client_id', '=', client_id)], limit=1)
+ if not online_link:
+ return {'type': 'ir.actions.client', 'tag': 'reload'}
+ new_account, swift_code = online_link._fetch_accounts(online_identifier=online_identifier)
+ if new_account:
+ new_account._assign_journal(swift_code)
+ action = online_link._fetch_transactions(accounts=new_account, check_duplicates=True)
+ return action or self.env['ir.actions.act_window']._for_xml_id('account.open_account_journal_dashboard_kanban')
+ raise UserError(_("The consent for the selected account has expired."))
+ return {'type': 'ir.actions.client', 'tag': 'reload'}
+
+ def exchange_token(self, exchange_token):
+ self.ensure_one()
+ # Exchange token to retrieve client_id and refresh_token from proxy account
+ data = {
+ 'exchange_token': exchange_token,
+ 'company_id': self.env.company.id,
+ 'user_id': self.env.user.id
+ }
+ resp_json = self._fetch_odoo_fin('/proxy/v1/exchange_token', data=data, ignore_status=True)
+ # Write in sudo mode as those fields are protected from users
+ self.sudo().write({
+ 'client_id': resp_json.get('client_id'),
+ 'refresh_token': resp_json.get('refresh_token'),
+ 'access_token': resp_json.get('access_token')
+ })
+ return True
+
+ def _success_link(self):
+ self.ensure_one()
+ self._log_information(state='connected')
+ account_online_accounts, swift_code = self._fetch_accounts()
+ if account_online_accounts and len(account_online_accounts) == 1:
+ account_online_accounts._assign_journal(swift_code)
+ return self._fetch_transactions(accounts=account_online_accounts, check_duplicates=True)
+ return self._link_accounts_to_journals_action(swift_code)
+
+ def _success_updateCredentials(self):
+ self.ensure_one()
+ return self._fetch_transactions(refresh=False)
+
+ def _success_refreshAccounts(self):
+ self.ensure_one()
+ return self._fetch_transactions(refresh=False)
+
+ def _success_reconnect(self):
+ self.ensure_one()
+ self._log_information(state='connected')
+ return self._fetch_transactions(check_duplicates=True)
+
+ ##################
+ # action buttons #
+ ##################
+
+ def action_new_synchronization(self, preferred_inst=None, journal_id=False):
+ # Search for an existing link that was not fully connected
+ online_link = self
+ if not online_link or online_link.provider_data:
+ online_link = self.search([('account_online_account_ids', '=', False)], limit=1)
+ # If not found, create a new one
+ if not online_link or online_link.provider_data:
+ online_link = self.create({})
+ return online_link._open_iframe('link', preferred_institution=preferred_inst, journal_id=journal_id)
+
+ def action_update_credentials(self):
+ return self._open_iframe('updateCredentials')
+
+ def action_fetch_transactions(self):
+ self.account_online_account_ids.fetching_status = None
+ action = self._fetch_transactions()
+ return action or self.env['ir.actions.act_window']._for_xml_id('account.open_account_journal_dashboard_kanban')
+
+ def action_reconnect_account(self):
+ return self._open_iframe('reconnect')
+
+ def _open_iframe(self, mode='link', include_param=None, preferred_institution=False, journal_id=False):
+ self.ensure_one()
+ if self.client_id and self.sudo().refresh_token:
+ try:
+ self._get_access_token()
+ except OdooFinRedirectException:
+ # Delete record and open iframe in a new one
+ self.unlink()
+ return self.create({})._open_iframe('link')
+
+ proxy_mode = self.env['ir.config_parameter'].sudo().get_param('odex30_account_online_sync.proxy_mode') or 'production'
+ country = self.env['account.journal'].browse(journal_id).company_id.account_fiscal_country_id or self.env.company.country_id
+ action = {
+ 'type': 'ir.actions.client',
+ 'tag': 'odoo_fin_connector',
+ 'id': self.id,
+ 'params': {
+ 'proxyMode': proxy_mode,
+ 'clientId': self.client_id,
+ 'accessToken': self.access_token,
+ 'mode': mode,
+ 'includeParam': {
+ 'lang': get_lang(self.env).code,
+ 'countryCode': country.code,
+ 'countryName': country.display_name,
+ 'redirect_reconnection': self.env.context.get('redirect_reconnection'),
+ 'serverVersion': odoo.release.serie,
+ 'mfa_type': self.env.user._mfa_type(),
+ }
+ },
+ 'context': {
+ 'dialog_size': 'medium',
+ },
+ }
+ if self.provider_data:
+ action['params']['providerData'] = self.provider_data
+ if preferred_institution:
+ action['params']['includeParam']['clickedInstitution'] = preferred_institution
+ if journal_id:
+ action['context']['active_model'] = 'account.journal'
+ action['context']['active_id'] = journal_id
+
+ if mode == 'link':
+ user_email = self.env.user.email or self.env.ref('base.user_admin').email or '' # Necessary for some providers onboarding
+ action['params']['includeParam']['dbUuid'] = self.env['ir.config_parameter'].sudo().get_param('database.uuid')
+ action['params']['includeParam']['userEmail'] = user_email
+ # Compute a hash of a random string for each connection in success
+ existing_link = self.search([('state', '!=', 'disconnected'), ('has_unlinked_accounts', '=', True)])
+ if existing_link:
+ record_access_token = base64.b64encode(uuid.uuid4().bytes).decode('utf-8')
+ for link in existing_link:
+ link._authorize_access(record_access_token)
+ action['params']['includeParam']['recordAccessToken'] = record_access_token
+
+ if include_param:
+ action['params']['includeParam'].update(include_param)
+ return action
diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/bank_rec_widget.py b/dev_odex30_accounting/odex30_account_online_sync/models/bank_rec_widget.py
new file mode 100644
index 0000000..273c43f
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/models/bank_rec_widget.py
@@ -0,0 +1,16 @@
+from odoo import models
+
+
+class BankRecWidget(models.Model):
+ _inherit = 'bank.rec.widget'
+
+ def _action_validate(self):
+ # EXTENDS account_accountant
+ super()._action_validate()
+ line = self.st_line_id
+ if line.partner_id and line.online_partner_information:
+ # write value for account and merchant on partner only if partner has no value,
+ # in case value are different write False
+ value_merchant = line.partner_id.online_partner_information or line.online_partner_information
+ value_merchant = value_merchant if value_merchant == line.online_partner_information else False
+ line.partner_id.online_partner_information = value_merchant
diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/company.py b/dev_odex30_accounting/odex30_account_online_sync/models/company.py
new file mode 100644
index 0000000..84feee2
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/models/company.py
@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, models
+
+
+class ResCompany(models.Model):
+ _inherit = "res.company"
+
+ @api.model
+ def setting_init_bank_account_action(self):
+ """
+ Override the "setting_init_bank_account_action" in accounting menu
+ and change the flow for the "Add a bank account" menu item in dashboard.
+ """
+ return self.env['account.online.link'].action_new_synchronization()
diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/mail_activity_type.py b/dev_odex30_accounting/odex30_account_online_sync/models/mail_activity_type.py
new file mode 100644
index 0000000..93e61bf
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/models/mail_activity_type.py
@@ -0,0 +1,14 @@
+from odoo import api, models
+
+
+class MailActivityType(models.Model):
+ _inherit = "mail.activity.type"
+
+ @api.model
+ def _get_model_info_by_xmlid(self):
+ info = super()._get_model_info_by_xmlid()
+ info['odex30_account_online_sync.bank_sync_activity_update_consent'] = {
+ 'res_model': 'account.journal',
+ 'unlink': False,
+ }
+ return info
diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/odoofin_auth.py b/dev_odex30_accounting/odex30_account_online_sync/models/odoofin_auth.py
new file mode 100644
index 0000000..19288f6
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/models/odoofin_auth.py
@@ -0,0 +1,46 @@
+import base64
+import hashlib
+import hmac
+import json
+import requests
+import time
+import werkzeug.urls
+
+
+class OdooFinAuth(requests.auth.AuthBase):
+
+ def __init__(self, record=None):
+ self.access_token = record and record.access_token or False
+ self.refresh_token = record and record.refresh_token or False
+ self.client_id = record and record.client_id or False
+
+ def __call__(self, request):
+ # We don't sign request that still don't have a client_id/refresh_token
+ if not self.client_id or not self.refresh_token:
+ return request
+ # craft the message (timestamp|url path|client_id|access_token|query params|body content)
+ msg_timestamp = int(time.time())
+ parsed_url = werkzeug.urls.url_parse(request.path_url)
+
+ body = request.body
+ if isinstance(body, bytes):
+ body = body.decode('utf-8')
+ body = json.loads(body)
+
+ message = '%s|%s|%s|%s|%s|%s' % (
+ msg_timestamp, # timestamp
+ parsed_url.path, # url path
+ self.client_id,
+ self.access_token,
+ json.dumps(werkzeug.urls.url_decode(parsed_url.query), sort_keys=True), # url query params sorted by key
+ json.dumps(body, sort_keys=True)) # http request body
+
+ h = hmac.new(base64.b64decode(self.refresh_token), message.encode('utf-8'), digestmod=hashlib.sha256)
+
+ request.headers.update({
+ 'odoofin-client-id': self.client_id,
+ 'odoofin-access-token': self.access_token,
+ 'odoofin-signature': base64.b64encode(h.digest()),
+ 'odoofin-timestamp': msg_timestamp,
+ })
+ return request
diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/partner.py b/dev_odex30_accounting/odex30_account_online_sync/models/partner.py
new file mode 100644
index 0000000..7cb6bc9
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/models/partner.py
@@ -0,0 +1,7 @@
+from odoo import models, fields
+
+
+class ResPartner(models.Model):
+ _inherit = 'res.partner'
+
+ online_partner_information = fields.Char(readonly=True)
diff --git a/dev_odex30_accounting/odex30_account_online_sync/security/account_online_sync_security.xml b/dev_odex30_accounting/odex30_account_online_sync/security/account_online_sync_security.xml
new file mode 100644
index 0000000..9a4c704
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/security/account_online_sync_security.xml
@@ -0,0 +1,15 @@
+
+
+
+ Account online link company rule
+
+
+ [('company_id', 'parent_of', company_ids)]
+
+
+ Online account company rule
+
+
+ [('account_online_link_id.company_id','parent_of', company_ids)]
+
+
diff --git a/dev_odex30_accounting/odex30_account_online_sync/security/ir.model.access.csv b/dev_odex30_accounting/odex30_account_online_sync/security/ir.model.access.csv
new file mode 100644
index 0000000..adca78d
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/security/ir.model.access.csv
@@ -0,0 +1,12 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_account_online_link_id,access_account_online_link_id,model_account_online_link,account.group_account_basic,1,1,1,0
+access_account_online_link_id_readonly,access_account_online_link_id_readonly,model_account_online_link,account.group_account_readonly,1,0,0,0
+access_account_online_link_id_manager,access_account_online_link_id manager,model_account_online_link,account.group_account_manager,1,1,1,1
+access_account_online_account_id,access_account_online_account_id,model_account_online_account,account.group_account_basic,1,1,1,0
+access_account_online_account_id_readonly,access_account_online_account_id_readonly,model_account_online_account,account.group_account_readonly,1,0,0,0
+access_account_online_account_id_manager,access_account_online_account_id manager,model_account_online_account,account.group_account_manager,1,1,1,1
+access_account_bank_selection_manager,access.account.bank.selection manager,model_account_bank_selection,account.group_account_manager,1,1,1,1
+access_account_bank_selection,access.account.bank.selection basic,model_account_bank_selection,account.group_account_basic,1,1,1,0
+access_account_bank_statement_line_transient,access_account_bank_statement_line_transient,model_account_bank_statement_line_transient,account.group_account_manager,1,1,1,1
+access_account_missing_transaction_wizard,access_account_missing_transaction_wizard,model_account_missing_transaction_wizard,account.group_account_manager,1,1,1,1
+access_account_duplicate_transaction_wizard,access_account_duplicate_transaction_wizard,model_account_duplicate_transaction_wizard,account.group_account_user,1,1,1,0
diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transaction_form.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transaction_form.js
new file mode 100644
index 0000000..403bbe4
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transaction_form.js
@@ -0,0 +1,37 @@
+import { formView } from "@web/views/form/form_view";
+import { FormController } from "@web/views/form/form_controller";
+import { registry } from "@web/core/registry";
+import { useCheckDuplicateService } from "./account_duplicate_transaction_hook";
+
+export class AccountDuplicateTransactionsFormController extends FormController {
+ setup() {
+ super.setup();
+ this.duplicateCheckService = useCheckDuplicateService();
+ }
+
+ async beforeExecuteActionButton(clickParams) {
+ if (clickParams.name === "delete_selected_transactions") {
+ const selected = this.duplicateCheckService.selectedLines;
+ if (selected.size) {
+ await this.orm.call(
+ "account.bank.statement.line",
+ "unlink",
+ [Array.from(selected)],
+ );
+ this.env.services.action.doAction({type: 'ir.actions.client', tag: 'reload'});
+ }
+ return false;
+ }
+ return super.beforeExecuteActionButton(...arguments);
+ }
+
+ get cogMenuProps() {
+ const props = super.cogMenuProps;
+ props.items.action = [];
+ return props;
+ }
+}
+
+export const form = { ...formView, Controller: AccountDuplicateTransactionsFormController };
+
+registry.category("views").add("account_duplicate_transactions_form", form);
diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transaction_hook.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transaction_hook.js
new file mode 100644
index 0000000..64b1604
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transaction_hook.js
@@ -0,0 +1,6 @@
+import { useService } from "@web/core/utils/hooks";
+import { useState } from "@odoo/owl";
+
+export function useCheckDuplicateService() {
+ return useState(useService("odex30_account_online_sync.duplicate_check_service"));
+}
diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transaction_service.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transaction_service.js
new file mode 100644
index 0000000..b5571d2
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transaction_service.js
@@ -0,0 +1,21 @@
+import { registry } from "@web/core/registry";
+
+class AccountDuplicateTransactionsServiceModel {
+ constructor() {
+ this.selectedLines = new Set();
+ }
+
+ updateLIne(selected, id) {
+ this.selectedLines[selected ? "add" : "delete"](id);
+ }
+}
+
+const duplicateCheckService = {
+ start(env, services) {
+ return new AccountDuplicateTransactionsServiceModel();
+ },
+};
+
+registry
+ .category("services")
+ .add("odex30_account_online_sync.duplicate_check_service", duplicateCheckService);
diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transactions_x2many.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transactions_x2many.js
new file mode 100644
index 0000000..d1d2a8e
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transactions_x2many.js
@@ -0,0 +1,50 @@
+import { onMounted } from "@odoo/owl";
+import { registry } from "@web/core/registry";
+import { ListRenderer } from "@web/views/list/list_renderer";
+import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
+import { useCheckDuplicateService } from "./account_duplicate_transaction_hook";
+
+export class AccountDuplicateTransactionsListRenderer extends ListRenderer {
+ static template = "odex30_account_online_sync.AccountDuplicateTransactionsListRenderer";
+ static recordRowTemplate = "odex30_account_online_sync.AccountDuplicateTransactionsRecordRow";
+
+ setup() {
+ super.setup();
+ this.duplicateCheckService = useCheckDuplicateService();
+
+ onMounted(() => {
+ this.deleteButton = document.querySelector('button[name="delete_selected_transactions"]');
+ this.deleteButton.disabled = true;
+ });
+ }
+
+ toggleRecordSelection(selected, record) {
+ this.duplicateCheckService.updateLIne(selected, record.data.id);
+ this.deleteButton.disabled = this.duplicateCheckService.selectedLines.size === 0;
+ }
+
+ get hasSelectors() {
+ return true;
+ }
+
+ getRowClass(record) {
+ let classes = super.getRowClass(record);
+ const firstIdsInGroup = this.env.model.root.data.first_ids_in_group;
+ if (firstIdsInGroup instanceof Array && firstIdsInGroup.includes(record.data.id)) {
+ classes += " account_duplicate_transactions_lines_list_x2many_group_line";
+ }
+ return classes;
+ }
+}
+
+export class AccountDuplicateTransactionsLinesListX2ManyField extends X2ManyField {
+ static components = {
+ ...X2ManyField.components,
+ ListRenderer: AccountDuplicateTransactionsListRenderer,
+ };
+}
+
+registry.category("fields").add("account_duplicate_transactions_lines_list_x2many", {
+ ...x2ManyField,
+ component: AccountDuplicateTransactionsLinesListX2ManyField,
+});
diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transactions_x2many.scss b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transactions_x2many.scss
new file mode 100644
index 0000000..4b147a5
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transactions_x2many.scss
@@ -0,0 +1,3 @@
+.account_duplicate_transactions_lines_list_x2many_group_line {
+ border-top-width: thick;
+}
diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transactions_x2many.xml b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transactions_x2many.xml
new file mode 100644
index 0000000..52f66ff
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transactions_x2many.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/js/odoo_fin_connector.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/js/odoo_fin_connector.js
new file mode 100644
index 0000000..b8d18ca
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/js/odoo_fin_connector.js
@@ -0,0 +1,86 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+import { loadJS } from "@web/core/assets";
+import { cookie } from "@web/core/browser/cookie";
+import { markup } from "@odoo/owl";
+const actionRegistry = registry.category('actions');
+/* global OdooFin */
+
+function OdooFinConnector(parent, action) {
+ const orm = parent.services.orm;
+ const actionService = parent.services.action;
+ const notificationService = parent.services.notification;
+ const debugMode = parent.debug;
+
+ const id = action.id;
+ action.params.colorScheme = cookie.get("color_scheme");
+ let mode = action.params.mode || 'link';
+ // Ensure that the proxyMode is valid
+ const modeRegexp = /^[a-z0-9-_]+$/;
+ const runbotRegexp = /^https:\/\/[a-z0-9-_]+\.[a-z0-9-_]+\.odoo\.com$/;
+ if (!modeRegexp.test(action.params.proxyMode) && !runbotRegexp.test(action.params.proxyMode)) {
+ return;
+ }
+ let url = 'https://' + action.params.proxyMode + '.odoofin.com/proxy/v1/odoofin_link';
+ if (runbotRegexp.test(action.params.proxyMode)) {
+ url = action.params.proxyMode + '/proxy/v1/odoofin_link';
+ }
+ let actionResult = false;
+
+ loadJS(url)
+ .then(function () {
+ // Create and open the iframe
+ const params = {
+ data: action.params,
+ proxyMode: action.params.proxyMode,
+ onEvent: async function (event, data) {
+ switch (event) {
+ case 'close':
+ return;
+ case 'reload':
+ return actionService.doAction({type: 'ir.actions.client', tag: 'reload'});
+ case 'notification':
+ notificationService.add(data.message, data);
+ break;
+ case 'exchange_token':
+ await orm.call('account.online.link', 'exchange_token',
+ [[id], data], {context: action.context});
+ break;
+ case 'success':
+ mode = data.mode || mode;
+ actionResult = await orm.call('account.online.link', 'success', [[id], mode, data], {context: action.context});
+ actionResult.help = markup(actionResult.help)
+ return actionService.doAction(actionResult);
+ case 'connect_existing_account':
+ actionResult = await orm.call('account.online.link', 'connect_existing_account', [data], {context: action.context});
+ actionResult.help = markup(actionResult.help)
+ return actionService.doAction(actionResult);
+ default:
+ return;
+ }
+ },
+ onAddBank: async function (data) {
+ // If the user doesn't find his bank
+ actionResult = await orm.call(
+ "account.online.link",
+ "create_new_bank_account_action",
+ [[id], data],
+ { context: action.context }
+ );
+ return actionService.doAction(actionResult);
+ }
+ };
+ // propagate parent debug mode to iframe
+ if (typeof debugMode !== "undefined" && debugMode) {
+ params.data["debug"] = debugMode;
+ }
+ OdooFin.create(params);
+ OdooFin.open();
+ });
+ return;
+}
+
+actionRegistry.add('odoo_fin_connector', OdooFinConnector);
+
+export default OdooFinConnector;
diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/js/online_sync_portal.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/js/online_sync_portal.js
new file mode 100644
index 0000000..8dde787
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/js/online_sync_portal.js
@@ -0,0 +1,57 @@
+/** @odoo-module **/
+
+ import publicWidget from "@web/legacy/js/public/public_widget";
+ import { loadJS } from "@web/core/assets";
+ /* global OdooFin */
+
+ publicWidget.registry.OnlineSyncPortal = publicWidget.Widget.extend({
+ selector: '.oe_online_sync',
+ events: Object.assign({}, {
+ 'click #renew_consent_button': '_onRenewConsent',
+ }),
+
+ OdooFinConnector: function (parent, action) {
+ // Ensure that the proxyMode is valid
+ const modeRegexp = /^[a-z0-9-_]+$/i;
+ if (!modeRegexp.test(action.params.proxyMode)) {
+ return;
+ }
+ const url = 'https://' + action.params.proxyMode + '.odoofin.com/proxy/v1/odoofin_link';
+
+ loadJS(url)
+ .then(() => {
+ // Create and open the iframe
+ const params = {
+ data: action.params,
+ proxyMode: action.params.proxyMode,
+ onEvent: function (event, data) {
+ switch (event) {
+ case 'success':
+ const processUrl = window.location.pathname + '/complete' + window.location.search;
+ $('.js_reconnect').toggleClass('d-none');
+ $.post(processUrl, {csrf_token: odoo.csrf_token});
+ default:
+ return;
+ }
+ },
+ };
+ OdooFin.create(params);
+ OdooFin.open();
+ });
+ return;
+ },
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onRenewConsent: async function (ev) {
+ ev.preventDefault();
+ const action = JSON.parse($(ev.currentTarget).attr('iframe-params'));
+ return this.OdooFinConnector(this, action);
+ },
+ });
+
+ export default {
+ OnlineSyncPortal: publicWidget.registry.OnlineSyncPortal,
+ };
diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/tests/helpers/model_definitions_setup.js b/dev_odex30_accounting/odex30_account_online_sync/static/tests/helpers/model_definitions_setup.js
new file mode 100644
index 0000000..6434c51
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/static/tests/helpers/model_definitions_setup.js
@@ -0,0 +1,5 @@
+/** @odoo-module **/
+
+import { addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers';
+
+addModelNamesToFetch(["account.online.link", "account.online.account", "account.bank.selection"]);
diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/tests/online_account_radio_test.js b/dev_odex30_accounting/odex30_account_online_sync/static/tests/online_account_radio_test.js
new file mode 100644
index 0000000..3be8ad5
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/static/tests/online_account_radio_test.js
@@ -0,0 +1,83 @@
+/* @odoo-module */
+
+import { startServer } from "@bus/../tests/helpers/mock_python_environment";
+
+import { openFormView, start } from "@mail/../tests/helpers/test_utils";
+
+import { click, contains } from "@web/../tests/utils";
+
+QUnit.module("Views", {}, function () {
+ QUnit.module("AccountOnlineSynchronizationAccountRadio");
+
+ QUnit.test("can be rendered", async () => {
+ const pyEnv = await startServer();
+ const onlineLink = pyEnv["account.online.link"].create([
+ {
+ state: "connected",
+ name: "Fake Bank",
+ },
+ ]);
+ pyEnv["account.online.account"].create([
+ {
+ name: "account_1",
+ online_identifier: "abcd",
+ balance: 10.0,
+ account_number: "account_number_1",
+ account_online_link_id: onlineLink,
+ },
+ {
+ name: "account_2",
+ online_identifier: "efgh",
+ balance: 20.0,
+ account_number: "account_number_2",
+ account_online_link_id: onlineLink,
+ },
+ ]);
+ const bankSelection = pyEnv["account.bank.selection"].create([
+ {
+ account_online_link_id: onlineLink,
+ },
+ ]);
+
+ const views = {
+ "account.bank.selection,false,form": ``,
+ };
+ await start({
+ serverData: { views },
+ mockRPC: function (route, args) {
+ if (
+ route === "/web/dataset/call_kw/account.online.account/get_formatted_balances"
+ ) {
+ return {
+ 1: ["$ 10.0", 10.0],
+ 2: ["$ 20.0", 20.0],
+ };
+ }
+ },
+ });
+ await openFormView("account.bank.selection", bankSelection);
+ await contains(".o_radio_item", { count: 2 });
+ await contains(":nth-child(1 of .o_radio_item)", {
+ contains: [
+ ["p", { text: "$ 10.0" }],
+ ["label", { text: "account_1" }],
+ [".o_radio_input:checked"],
+ ],
+ });
+ await contains(":nth-child(2 of .o_radio_item)", {
+ contains: [
+ ["p", { text: "$ 20.0" }],
+ ["label", { text: "account_2" }],
+ [".o_radio_input:not(:checked)"],
+ ],
+ });
+ await click(":nth-child(2 of .o_radio_item) .o_radio_input");
+ await contains(":nth-child(1 of .o_radio_item) .o_radio_input:not(:checked)");
+ await contains(":nth-child(2 of .o_radio_item) .o_radio_input:checked");
+ });
+});
diff --git a/dev_odex30_accounting/odex30_account_online_sync/tests/__init__.py b/dev_odex30_accounting/odex30_account_online_sync/tests/__init__.py
new file mode 100644
index 0000000..eacefb9
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/tests/__init__.py
@@ -0,0 +1,7 @@
+# -*- encoding: utf-8 -*-
+
+from . import common
+from . import test_account_online_account
+from . import test_online_sync_creation_statement
+from . import test_account_missing_transactions_wizard
+from . import test_online_sync_branch_companies
diff --git a/dev_odex30_accounting/odex30_account_online_sync/tests/common.py b/dev_odex30_accounting/odex30_account_online_sync/tests/common.py
new file mode 100644
index 0000000..ffcbc53
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/tests/common.py
@@ -0,0 +1,110 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import Command, fields
+from odoo.addons.account.tests.common import AccountTestInvoicingCommon
+from odoo.tests import tagged
+from unittest.mock import MagicMock
+
+
+@tagged('post_install', '-at_install')
+class AccountOnlineSynchronizationCommon(AccountTestInvoicingCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.other_currency = cls.setup_other_currency('EUR')
+
+ cls.euro_bank_journal = cls.env['account.journal'].create({
+ 'name': 'Euro Bank Journal',
+ 'type': 'bank',
+ 'code': 'EURB',
+ 'currency_id': cls.other_currency.id,
+ })
+ cls.account_online_link = cls.env['account.online.link'].create({
+ 'name': 'Test Bank',
+ 'client_id': 'client_id_1',
+ 'refresh_token': 'refresh_token',
+ 'access_token': 'access_token',
+ })
+ cls.account_online_account = cls.env['account.online.account'].create({
+ 'name': 'MyBankAccount',
+ 'account_online_link_id': cls.account_online_link.id,
+ 'journal_ids': [Command.set(cls.euro_bank_journal.id)]
+ })
+ cls.BankStatementLine = cls.env['account.bank.statement.line']
+
+ def setUp(self):
+ super().setUp()
+ self.transaction_id = 1
+ self.account_online_account.balance = 0.0
+
+ def _create_one_online_transaction(self, transaction_identifier=None, date=None, payment_ref=None, amount=10.0, partner_name=None, foreign_currency_code=None, amount_currency=8.0):
+ """ This method allows to create an online transaction granularly
+
+ :param transaction_identifier: Online identifier of the transaction, by default transaction_id from the
+ setUp. If used, transaction_id is not incremented.
+ :param date: Date of the transaction, by default the date of today
+ :param payment_ref: Label of the transaction
+ :param amount: Amount of the transaction, by default equals 10.0
+ :param foreign_currency_code: Code of transaction's foreign currency
+ :param amount_currency: Amount of transaction in foreign currency, update transaction only if foreign_currency_code is given, by default equals 8.0
+ :return: A dictionnary representing an online transaction (not formatted)
+ """
+ transaction_identifier = transaction_identifier if transaction_identifier is not None else self.transaction_id
+ if date:
+ date = date if isinstance(date, str) else fields.Date.to_string(date)
+ else:
+ date = fields.Date.to_string(fields.Date.today())
+
+ payment_ref = payment_ref or f'transaction_{transaction_identifier}'
+ transaction = {
+ 'online_transaction_identifier': transaction_identifier,
+ 'date': date,
+ 'payment_ref': payment_ref,
+ 'amount': amount,
+ 'partner_name': partner_name,
+ }
+ if foreign_currency_code:
+ transaction.update({
+ 'foreign_currency_code': foreign_currency_code,
+ 'amount_currency': amount_currency
+ })
+ return transaction
+
+ def _create_online_transactions(self, dates):
+ """ This method returns a list of transactions with the
+ given dates.
+ All amounts equals 10.0
+
+ :param dates: A list of dates, one transaction is created for each given date.
+ :return: A formatted list of transactions
+ """
+ transactions = []
+ for date in dates:
+ transactions.append(self._create_one_online_transaction(date=date))
+ self.transaction_id += 1
+ return self.account_online_account._format_transactions(transactions)
+
+ def _mock_odoofin_response(self, data=None):
+ if not data:
+ data = {}
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ 'result': data,
+ }
+ return mock_response
+
+ def _mock_odoofin_error_response(self, code=200, message='Default', data=None):
+ if not data:
+ data = {}
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ 'error': {
+ 'code': code,
+ 'message': message,
+ 'data': data,
+ },
+ }
+ return mock_response
diff --git a/dev_odex30_accounting/odex30_account_online_sync/tests/test_account_missing_transactions_wizard.py b/dev_odex30_accounting/odex30_account_online_sync/tests/test_account_missing_transactions_wizard.py
new file mode 100644
index 0000000..1d9989f
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/tests/test_account_missing_transactions_wizard.py
@@ -0,0 +1,45 @@
+from odoo import fields
+from odoo.addons.odex30_account_online_sync.tests.common import AccountOnlineSynchronizationCommon
+from odoo.tests import tagged
+from unittest.mock import patch
+
+
+@tagged('post_install', '-at_install')
+class TestAccountMissingTransactionsWizard(AccountOnlineSynchronizationCommon):
+ """ Tests the account journal missing transactions wizard. """
+
+ @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineLink._fetch_odoo_fin')
+ def test_fetch_missing_transaction(self, patched_fetch_odoofin):
+ self.account_online_link.state = 'connected'
+ patched_fetch_odoofin.side_effect = [{
+ 'transactions': [
+ self._create_one_online_transaction(transaction_identifier='ABCD01', date='2023-07-06', foreign_currency_code='EGP', amount_currency=8.0),
+ ],
+ 'pendings': [
+ self._create_one_online_transaction(transaction_identifier='ABCD02_pending', date='2023-07-25', foreign_currency_code='GBP', amount_currency=8.0),
+ ]
+ }]
+ start_date = fields.Date.from_string('2023-07-01')
+ wizard = self.env['account.missing.transaction.wizard'].new({
+ 'date': start_date,
+ 'journal_id': self.euro_bank_journal.id,
+ })
+
+ action = wizard.action_fetch_missing_transaction()
+ transient_transactions = self.env['account.bank.statement.line.transient'].search(domain=action['domain'])
+ egp_currency = self.env['res.currency'].search([('name', '=', 'EGP')])
+ gbp_currency = self.env['res.currency'].search([('name', '=', 'GBP')])
+
+ self.assertEqual(2, len(transient_transactions))
+ # Posted Transaction
+ self.assertEqual(transient_transactions[0]['online_transaction_identifier'], 'ABCD01')
+ self.assertEqual(transient_transactions[0]['date'], fields.Date.from_string('2023-07-06'))
+ self.assertEqual(transient_transactions[0]['state'], 'posted')
+ self.assertEqual(transient_transactions[0]['foreign_currency_id'], egp_currency)
+ self.assertEqual(transient_transactions[0]['amount_currency'], 8.0)
+ # Pending Transaction
+ self.assertEqual(transient_transactions[1]['online_transaction_identifier'], 'ABCD02_pending')
+ self.assertEqual(transient_transactions[1]['date'], fields.Date.from_string('2023-07-25'))
+ self.assertEqual(transient_transactions[1]['state'], 'pending')
+ self.assertEqual(transient_transactions[1]['foreign_currency_id'], gbp_currency)
+ self.assertEqual(transient_transactions[1]['amount_currency'], 8.0)
diff --git a/dev_odex30_accounting/odex30_account_online_sync/tests/test_account_online_account.py b/dev_odex30_accounting/odex30_account_online_sync/tests/test_account_online_account.py
new file mode 100644
index 0000000..35c5bcd
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/tests/test_account_online_account.py
@@ -0,0 +1,491 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+from datetime import datetime, timedelta
+from freezegun import freeze_time
+from unittest.mock import patch
+
+from odoo import Command, fields, tools
+from odoo.addons.odex30_account_online_sync.tests.common import AccountOnlineSynchronizationCommon
+from odoo.tests import tagged
+
+_logger = logging.getLogger(__name__)
+
+@tagged('post_install', '-at_install')
+class TestAccountOnlineAccount(AccountOnlineSynchronizationCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.bank_account_id = cls.env['account.account'].create({
+ 'name': 'Bank Account',
+ 'account_type': 'asset_cash',
+ 'code': cls.env['account.account']._search_new_account_code('BNK100'),
+ })
+ cls.bank_journal = cls.env['account.journal'].create({
+ 'name': 'A bank journal',
+ 'default_account_id': cls.bank_account_id.id,
+ 'type': 'bank',
+ 'code': cls.env['account.journal'].get_next_bank_cash_default_code('bank', cls.company_data['company']),
+ })
+
+ @freeze_time('2023-08-01')
+ def test_get_filtered_transactions(self):
+ """ This test verifies that duplicate transactions are filtered """
+ self.BankStatementLine.with_context(skip_statement_line_cron_trigger=True).create({
+ 'date': '2023-08-01',
+ 'journal_id': self.euro_bank_journal.id,
+ 'online_transaction_identifier': 'ABCD01',
+ 'payment_ref': 'transaction_ABCD01',
+ 'amount': 10.0,
+ })
+
+ transactions_to_filtered = [
+ self._create_one_online_transaction(transaction_identifier='ABCD01'),
+ self._create_one_online_transaction(transaction_identifier='ABCD02'),
+ ]
+
+ filtered_transactions = self.account_online_account._get_filtered_transactions(transactions_to_filtered)
+
+ self.assertEqual(
+ filtered_transactions,
+ [
+ {
+ 'payment_ref': 'transaction_ABCD02',
+ 'date': '2023-08-01',
+ 'online_transaction_identifier': 'ABCD02',
+ 'amount': 10.0,
+ 'partner_name': None,
+ }
+ ]
+ )
+
+ @freeze_time('2023-08-01')
+ def test_get_filtered_transactions_with_empty_transaction_identifier(self):
+ """ This test verifies that transactions without a transaction identifier
+ are not filtered due to their empty transaction identifier.
+ """
+ self.BankStatementLine.with_context(skip_statement_line_cron_trigger=True).create({
+ 'date': '2023-08-01',
+ 'journal_id': self.euro_bank_journal.id,
+ 'online_transaction_identifier': '',
+ 'payment_ref': 'transaction_ABCD01',
+ 'amount': 10.0,
+ })
+
+ transactions_to_filtered = [
+ self._create_one_online_transaction(transaction_identifier=''),
+ self._create_one_online_transaction(transaction_identifier=''),
+ ]
+
+ filtered_transactions = self.account_online_account._get_filtered_transactions(transactions_to_filtered)
+
+ self.assertEqual(
+ filtered_transactions,
+ [
+ {
+ 'payment_ref': 'transaction_',
+ 'date': '2023-08-01',
+ 'online_transaction_identifier': '',
+ 'amount': 10.0,
+ 'partner_name': None,
+ },
+ {
+ 'payment_ref': 'transaction_',
+ 'date': '2023-08-01',
+ 'online_transaction_identifier': '',
+ 'amount': 10.0,
+ 'partner_name': None,
+ },
+ ]
+ )
+
+ @freeze_time('2023-08-01')
+ def test_format_transactions(self):
+ transactions_to_format = [
+ self._create_one_online_transaction(transaction_identifier='ABCD01'),
+ self._create_one_online_transaction(transaction_identifier='ABCD02'),
+ ]
+ formatted_transactions = self.account_online_account._format_transactions(transactions_to_format)
+ self.assertEqual(
+ formatted_transactions,
+ [
+ {
+ 'payment_ref': 'transaction_ABCD01',
+ 'date': fields.Date.from_string('2023-08-01'),
+ 'online_transaction_identifier': 'ABCD01',
+ 'amount': 10.0,
+ 'online_account_id': self.account_online_account.id,
+ 'journal_id': self.euro_bank_journal.id,
+ 'company_id': self.euro_bank_journal.company_id.id,
+ 'partner_name': None,
+ },
+ {
+ 'payment_ref': 'transaction_ABCD02',
+ 'date': fields.Date.from_string('2023-08-01'),
+ 'online_transaction_identifier': 'ABCD02',
+ 'amount': 10.0,
+ 'online_account_id': self.account_online_account.id,
+ 'journal_id': self.euro_bank_journal.id,
+ 'company_id': self.euro_bank_journal.company_id.id,
+ 'partner_name': None,
+ },
+ ]
+ )
+
+ @freeze_time('2023-08-01')
+ def test_format_transactions_invert_sign(self):
+ transactions_to_format = [
+ self._create_one_online_transaction(transaction_identifier='ABCD01', amount=25.0),
+ ]
+ self.account_online_account.inverse_transaction_sign = True
+ formatted_transactions = self.account_online_account._format_transactions(transactions_to_format)
+ self.assertEqual(
+ formatted_transactions,
+ [
+ {
+ 'payment_ref': 'transaction_ABCD01',
+ 'date': fields.Date.from_string('2023-08-01'),
+ 'online_transaction_identifier': 'ABCD01',
+ 'amount': -25.0,
+ 'online_account_id': self.account_online_account.id,
+ 'journal_id': self.euro_bank_journal.id,
+ 'company_id': self.euro_bank_journal.company_id.id,
+ 'partner_name': None,
+ },
+ ]
+ )
+
+ @freeze_time('2023-08-01')
+ def test_format_transactions_foreign_currency_code_to_id_with_activation(self):
+ """ This test ensures conversion of foreign currency code to foreign currency id and activates foreign currency if not already activate """
+ gbp_currency = self.env['res.currency'].with_context(active_test=False).search([('name', '=', 'GBP')])
+ egp_currency = self.env['res.currency'].with_context(active_test=False).search([('name', '=', 'EGP')])
+
+ transactions_to_format = [
+ self._create_one_online_transaction(transaction_identifier='ABCD01', foreign_currency_code='GBP'),
+ self._create_one_online_transaction(transaction_identifier='ABCD02', foreign_currency_code='EGP', amount_currency=500.0),
+ ]
+ formatted_transactions = self.account_online_account._format_transactions(transactions_to_format)
+
+ self.assertTrue(gbp_currency.active)
+ self.assertTrue(egp_currency.active)
+
+ self.assertEqual(
+ formatted_transactions,
+ [
+ {
+ 'payment_ref': 'transaction_ABCD01',
+ 'date': fields.Date.from_string('2023-08-01'),
+ 'online_transaction_identifier': 'ABCD01',
+ 'amount': 10.0,
+ 'online_account_id': self.account_online_account.id,
+ 'journal_id': self.euro_bank_journal.id,
+ 'company_id': self.euro_bank_journal.company_id.id,
+ 'partner_name': None,
+ 'foreign_currency_id': gbp_currency.id,
+ 'amount_currency': 8.0,
+ },
+ {
+ 'payment_ref': 'transaction_ABCD02',
+ 'date': fields.Date.from_string('2023-08-01'),
+ 'online_transaction_identifier': 'ABCD02',
+ 'amount': 10.0,
+ 'online_account_id': self.account_online_account.id,
+ 'journal_id': self.euro_bank_journal.id,
+ 'company_id': self.euro_bank_journal.company_id.id,
+ 'partner_name': None,
+ 'foreign_currency_id': egp_currency.id,
+ 'amount_currency': 500.0,
+ },
+ ]
+ )
+
+ @freeze_time('2023-07-25')
+ @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineLink._fetch_odoo_fin')
+ def test_retrieve_pending_transactions(self, patched_fetch_odoofin):
+ self.account_online_link.state = 'connected'
+ patched_fetch_odoofin.side_effect = [{
+ 'transactions': [
+ self._create_one_online_transaction(transaction_identifier='ABCD01', date='2023-07-06'),
+ self._create_one_online_transaction(transaction_identifier='ABCD02', date='2023-07-22'),
+ ],
+ 'pendings': [
+ self._create_one_online_transaction(transaction_identifier='ABCD03_pending', date='2023-07-25'),
+ self._create_one_online_transaction(transaction_identifier='ABCD04_pending', date='2023-07-25'),
+ ]
+ }]
+
+ start_date = fields.Date.from_string('2023-07-01')
+ result = self.account_online_account._retrieve_transactions(date=start_date, include_pendings=True)
+ self.assertEqual(
+ result,
+ {
+ 'transactions': [
+ {
+ 'payment_ref': 'transaction_ABCD01',
+ 'date': fields.Date.from_string('2023-07-06'),
+ 'online_transaction_identifier': 'ABCD01',
+ 'amount': 10.0,
+ 'partner_name': None,
+ 'online_account_id': self.account_online_account.id,
+ 'journal_id': self.euro_bank_journal.id,
+ 'company_id': self.euro_bank_journal.company_id.id,
+ },
+ {
+ 'payment_ref': 'transaction_ABCD02',
+ 'date': fields.Date.from_string('2023-07-22'),
+ 'online_transaction_identifier': 'ABCD02',
+ 'amount': 10.0,
+ 'partner_name': None,
+ 'online_account_id': self.account_online_account.id,
+ 'journal_id': self.euro_bank_journal.id,
+ 'company_id': self.euro_bank_journal.company_id.id,
+ }
+ ],
+ 'pendings': [
+ {
+ 'payment_ref': 'transaction_ABCD03_pending',
+ 'date': fields.Date.from_string('2023-07-25'),
+ 'online_transaction_identifier': 'ABCD03_pending',
+ 'amount': 10.0,
+ 'partner_name': None,
+ 'online_account_id': self.account_online_account.id,
+ 'journal_id': self.euro_bank_journal.id,
+ 'company_id': self.euro_bank_journal.company_id.id,
+ },
+ {
+ 'payment_ref': 'transaction_ABCD04_pending',
+ 'date': fields.Date.from_string('2023-07-25'),
+ 'online_transaction_identifier': 'ABCD04_pending',
+ 'amount': 10.0,
+ 'partner_name': None,
+ 'online_account_id': self.account_online_account.id,
+ 'journal_id': self.euro_bank_journal.id,
+ 'company_id': self.euro_bank_journal.company_id.id,
+ }
+ ]
+ }
+ )
+
+ @freeze_time('2023-01-01 01:10:15')
+ @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineAccount._retrieve_transactions', return_value={})
+ @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineAccount._refresh', return_value={'success': True, 'data': {}})
+ def test_basic_flow_manual_fetching_transactions(self, patched_refresh, patched_transactions):
+ self.addCleanup(self.env.registry.leave_test_mode)
+ # flush and clear everything for the new "transaction"
+ self.env.invalidate_all()
+
+ self.env.registry.enter_test_mode(self.cr)
+ with self.env.registry.cursor() as test_cr:
+ test_env = self.env(cr=test_cr)
+ test_link_account = self.account_online_link.with_env(test_env)
+ test_link_account.state = 'connected'
+ # Call fetch_transaction in manual mode and check that a call was made to refresh and to transaction
+ test_link_account._fetch_transactions()
+ patched_refresh.assert_called_once()
+ patched_transactions.assert_called_once()
+ self.assertEqual(test_link_account.account_online_account_ids[0].fetching_status, 'done')
+
+ @freeze_time('2023-01-01 01:10:15')
+ @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineAccount._retrieve_transactions', return_value={})
+ @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineLink._fetch_odoo_fin')
+ def test_refresh_incomplete_fetching_transactions(self, patched_refresh, patched_transactions):
+ patched_refresh.return_value = {'success': False}
+ # Call fetch_transaction and if call result is false, don't call transaction
+ self.account_online_link._fetch_transactions()
+ patched_transactions.assert_not_called()
+
+ patched_refresh.return_value = {'success': False, 'currently_fetching': True}
+ # Call fetch_transaction and if call result is false but in the process of fetching, don't call transaction
+ # and wait for the async cron to try again
+ self.account_online_link._fetch_transactions()
+ patched_transactions.assert_not_called()
+ self.assertEqual(self.account_online_account.fetching_status, 'waiting')
+
+ @freeze_time('2023-01-01 01:10:15')
+ @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineAccount._retrieve_transactions', return_value={})
+ @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineAccount._refresh', return_value={'success': True, 'data': {}})
+ def test_currently_processing_fetching_transactions(self, patched_refresh, patched_transactions):
+ self.account_online_account.fetching_status = 'processing' # simulate the fact that we are currently creating entries in odoo
+ limit_time = tools.config['limit_time_real_cron'] if tools.config['limit_time_real_cron'] > 0 else tools.config['limit_time_real']
+ self.account_online_link.last_refresh = datetime.now()
+ with freeze_time(datetime.now() + timedelta(seconds=(limit_time - 10))):
+ # Call to fetch_transaction should be skipped, and the cron should not try to fetch either
+ self.account_online_link._fetch_transactions()
+ self.euro_bank_journal._cron_fetch_waiting_online_transactions()
+ patched_refresh.assert_not_called()
+ patched_transactions.assert_not_called()
+
+ self.addCleanup(self.env.registry.leave_test_mode)
+ # flush and clear everything for the new "transaction"
+ self.env.invalidate_all()
+
+ self.env.registry.enter_test_mode(self.cr)
+ with self.env.registry.cursor() as test_cr:
+ test_env = self.env(cr=test_cr)
+ with freeze_time(datetime.now() + timedelta(seconds=(limit_time + 100))):
+ # Call to fetch_transaction should be started by the cron when the time limit is exceeded and still in processing
+ self.euro_bank_journal.with_env(test_env)._cron_fetch_waiting_online_transactions()
+ patched_refresh.assert_not_called()
+ patched_transactions.assert_called_once()
+
+ @patch('odoo.addons.odex30_account_online_sync.models.account_online.requests')
+ def test_delete_with_redirect_error(self, patched_request):
+ # Use case being tested: call delete on a record, first call returns token expired exception
+ # Which trigger a call to get a new token, which result in a 104 user_deleted_error, since version 17,
+ # such error are returned as a OdooFinRedirectException with mode link to reopen the iframe and link with a new
+ # bank. In our case we don't want that and want to be able to delete the record instead.
+ # Such use case happen when db_uuid has changed as the check for db_uuid is done after the check for token_validity
+ account_online_link = self.env['account.online.link'].create({
+ 'name': 'Test Delete',
+ 'client_id': 'client_id_test',
+ 'refresh_token': 'refresh_token',
+ 'access_token': 'access_token',
+ })
+ first_call = self._mock_odoofin_error_response(code=102)
+ second_call = self._mock_odoofin_error_response(code=300, data={'mode': 'link'})
+ patched_request.post.side_effect = [first_call, second_call]
+ nb_connections = len(self.env['account.online.link'].search([]))
+ # Try to delete record
+ account_online_link.unlink()
+ # Record should be deleted
+ self.assertEqual(len(self.env['account.online.link'].search([])), nb_connections - 1)
+
+ @patch('odoo.addons.odex30_account_online_sync.models.account_online.requests')
+ def test_redirect_mode_link(self, patched_request):
+ # Use case being tested: Call to open the iframe which result in a OdoofinRedirectException in link mode
+ # This should not trigger a traceback but delete the current online.link and reopen the iframe
+ account_online_link = self.env['account.online.link'].create({
+ 'name': 'Test Delete',
+ 'client_id': 'client_id_test',
+ 'refresh_token': 'refresh_token',
+ 'access_token': 'access_token',
+ })
+ link_id = account_online_link.id
+ first_call = self._mock_odoofin_error_response(code=300, data={'mode': 'link'})
+ second_call = self._mock_odoofin_response(data={'delete': True})
+ patched_request.post.side_effect = [first_call, second_call]
+ # Try to open iframe with broken connection
+ action = account_online_link.action_new_synchronization()
+ # Iframe should open in mode link and with a different record (old one should have been deleted)
+ self.assertEqual(action['params']['mode'], 'link')
+ self.assertNotEqual(action['id'], link_id)
+ self.assertEqual(len(self.env['account.online.link'].search([('id', '=', link_id)])), 0)
+
+ @patch("odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineLink._update_connection_status", return_value={})
+ def test_assign_journal_with_currency_on_account_online_account(self, patched_update_connection_status):
+ self.env['account.move'].create([
+ {
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2025-06-25'),
+ 'journal_id': self.bank_journal.id,
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'a line',
+ 'account_id': self.bank_account_id.id,
+ 'debit': 100,
+ 'currency_id': self.company_data['currency'].id,
+ }),
+ Command.create({
+ 'name': 'another line',
+ 'account_id': self.company_data['default_account_expense'].id,
+ 'credit': 100,
+ 'currency_id': self.company_data['currency'].id,
+ }),
+ ],
+ },
+ {
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2025-06-26'),
+ 'journal_id': self.bank_journal.id,
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'a line',
+ 'account_id': self.bank_account_id.id,
+ 'debit': 220,
+ 'currency_id': self.company_data['currency'].id,
+ }),
+ Command.create({
+ 'name': 'another line',
+ 'account_id': self.company_data['default_account_expense'].id,
+ 'credit': 220,
+ 'currency_id': self.company_data['currency'].id,
+ }),
+ ],
+ },
+ ])
+
+ self.account_online_account.currency_id = self.company_data['currency'].id
+ self.account_online_account.with_context(active_id=self.bank_journal.id, active_model='account.journal')._assign_journal()
+ self.assertEqual(
+ self.bank_journal.currency_id.id,
+ self.company_data['currency'].id,
+ )
+ self.assertEqual(
+ self.bank_journal.default_account_id.currency_id.id,
+ self.company_data['currency'].id,
+ )
+
+ @patch("odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineLink._update_connection_status", return_value={})
+ def test_set_currency_on_journal_when_existing_currencies_on_move_lines(self, patched_update_connection_status):
+ bank_account_id = self.env['account.account'].create({
+ 'name': 'Bank Account',
+ 'account_type': 'asset_cash',
+ 'code': self.env['account.account']._search_new_account_code('BNK100'),
+ })
+ bank_journal = self.env['account.journal'].create({
+ 'name': 'A bank journal',
+ 'default_account_id': bank_account_id.id,
+ 'type': 'bank',
+ 'code': self.env['account.journal'].get_next_bank_cash_default_code('bank', self.company_data['company']),
+ })
+
+ self.env['account.move'].create([
+ {
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2025-06-25'),
+ 'journal_id': bank_journal.id,
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'a line',
+ 'account_id': bank_account_id.id,
+ 'debit': 100,
+ 'currency_id': self.other_currency.id,
+ }),
+ Command.create({
+ 'name': 'another line',
+ 'account_id': self.company_data['default_account_expense'].id,
+ 'credit': 100,
+ 'currency_id': self.other_currency.id,
+ }),
+ ],
+ },
+ {
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2025-06-26'),
+ 'journal_id': bank_journal.id,
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'a line',
+ 'account_id': bank_account_id.id,
+ 'debit': 220,
+ 'currency_id': self.company_data['currency'].id,
+ }),
+ Command.create({
+ 'name': 'another line',
+ 'account_id': self.company_data['default_account_expense'].id,
+ 'credit': 220,
+ 'currency_id': self.company_data['currency'].id,
+ }),
+ ],
+ },
+ ])
+
+ self.account_online_account.currency_id = self.company_data['currency'].id
+ self.account_online_account.with_context(active_id=bank_journal.id, active_model='account.journal')._assign_journal()
+
+ # Silently ignore the error and don't set currency on the journal and on the account
+ self.assertEqual(bank_journal.currency_id.id, False)
+ self.assertEqual(bank_journal.default_account_id.currency_id.id, False)
diff --git a/dev_odex30_accounting/odex30_account_online_sync/tests/test_online_sync_branch_companies.py b/dev_odex30_accounting/odex30_account_online_sync/tests/test_online_sync_branch_companies.py
new file mode 100644
index 0000000..16cb0ef
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/tests/test_online_sync_branch_companies.py
@@ -0,0 +1,86 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.odex30_account_online_sync.tests.common import AccountOnlineSynchronizationCommon
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestSynchInBranches(AccountOnlineSynchronizationCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.mother_company = cls.env['res.company'].create({'name': 'Mother company 2000'})
+ cls.branch_company = cls.env['res.company'].create({'name': 'Branch company', 'parent_id': cls.mother_company.id})
+
+ cls.mother_bank_journal = cls.env['account.journal'].create({
+ 'name': 'Mother Bank Journal',
+ 'type': 'bank',
+ 'code': 'MBJ',
+ 'company_id': cls.mother_company.id,
+ })
+ cls.mother_account_online_link = cls.env['account.online.link'].create({
+ 'name': 'Test Bank',
+ 'client_id': 'client_id_1',
+ 'refresh_token': 'refresh_token',
+ 'access_token': 'access_token',
+ 'company_id': cls.mother_company.id,
+ })
+
+ def test_show_sync_actions(self):
+ """We test if the sync actions are correctly displayed based on the selected and enabled companies.
+
+ Let's have company A with an online link, and a branch of that company: company B.
+
+ - If we only have company A enabled and selected, the sync actions should be shown.
+ - If company A and B are enabled, no matter which company is selected, the sync actions should be shown.
+ - If we only have company B enabled and selected, the sync actions should be hidden.
+ """
+ self.assertTrue(
+ self.mother_account_online_link
+ .with_context(allowed_company_ids=(self.mother_company)._ids)
+ .with_company(self.mother_company)
+ .show_sync_actions
+ )
+
+ self.assertTrue(
+ self.mother_account_online_link
+ .with_context(allowed_company_ids=(self.branch_company + self.mother_company)._ids)
+ .with_company(self.mother_company)
+ .show_sync_actions
+ )
+
+ self.assertTrue(
+ self.mother_account_online_link
+ .with_context(allowed_company_ids=(self.branch_company + self.mother_company)._ids)
+ .with_company(self.branch_company)
+ .show_sync_actions
+ )
+
+ self.assertFalse(
+ self.mother_account_online_link
+ .with_context(allowed_company_ids=(self.branch_company)._ids)
+ .with_company(self.branch_company)
+ .show_sync_actions
+ )
+
+ def test_show_bank_connect(self):
+ """We test if the 'connect' bank button appears on the journal on the dashboard given the selected company.
+
+ Let's have company A with an bank journal, and a branch of that company: company B.
+
+ - On the dashboard of company A, the connect bank button should appear on the journal.
+ - On the dashboard of company B, the connect bank button should not appear on the journal, even with company A enabled.
+ """
+ dashboard_data = self.mother_bank_journal\
+ .with_context(allowed_company_ids=(self.mother_company)._ids)\
+ .with_company(self.mother_company)\
+ ._get_journal_dashboard_data_batched()
+ self.assertTrue(dashboard_data[self.mother_bank_journal.id].get('display_connect_bank_in_dashboard'))
+
+ dashboard_data = self.mother_bank_journal\
+ .with_context(allowed_company_ids=(self.branch_company + self.mother_company)._ids)\
+ .with_company(self.branch_company)\
+ ._get_journal_dashboard_data_batched()
+ self.assertFalse(dashboard_data[self.mother_bank_journal.id].get('display_connect_bank_in_dashboard'))
diff --git a/dev_odex30_accounting/odex30_account_online_sync/tests/test_online_sync_creation_statement.py b/dev_odex30_accounting/odex30_account_online_sync/tests/test_online_sync_creation_statement.py
new file mode 100644
index 0000000..a68e1da
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_online_sync/tests/test_online_sync_creation_statement.py
@@ -0,0 +1,374 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from unittest.mock import MagicMock, patch
+
+from odoo.addons.base.models.res_bank import sanitize_account_number
+from odoo.addons.odex30_account_online_sync.tests.common import AccountOnlineSynchronizationCommon
+from odoo.exceptions import RedirectWarning
+from odoo.tests import tagged
+from odoo import fields, Command
+
+
+@tagged('post_install', '-at_install')
+class TestSynchStatementCreation(AccountOnlineSynchronizationCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.account = cls.env['account.account'].create({
+ 'name': 'Fixed Asset Account',
+ 'code': 'AA',
+ 'account_type': 'asset_fixed',
+ })
+
+ def reconcile_st_lines(self, st_lines):
+ for line in st_lines:
+ wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=line.id).new({})
+ line = wizard.line_ids.filtered(lambda x: x.flag == 'auto_balance')
+ wizard._js_action_mount_line_in_edit(line.index)
+ line.name = "toto"
+ wizard._line_value_changed_name(line)
+ line.account_id = self.account
+ wizard._line_value_changed_account_id(line)
+ wizard._action_validate()
+
+ # Tests
+ def test_creation_initial_sync_statement(self):
+ transactions = self._create_online_transactions(['2016-01-01', '2016-01-03'])
+ self.account_online_account.balance = 1000
+ self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account)
+ # Since ending balance is 1000$ and we only have 20$ of transactions and that it is the first statement
+ # it should create a statement before this one with the initial statement line
+ created_st_lines = self.BankStatementLine.search([('journal_id', '=', self.euro_bank_journal.id)], order='internal_index asc')
+ self.assertEqual(len(created_st_lines), 3, 'Should have created an initial bank statement line and two for the synchronization')
+ transactions = self._create_online_transactions(['2016-01-05'])
+ self.account_online_account.balance = 2000
+ self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account)
+ created_st_lines = self.BankStatementLine.search([('journal_id', '=', self.euro_bank_journal.id)], order='internal_index asc')
+ self.assertRecordValues(
+ created_st_lines,
+ [
+ {'date': fields.Date.from_string('2016-01-01'), 'amount': 980.0},
+ {'date': fields.Date.from_string('2016-01-01'), 'amount': 10.0},
+ {'date': fields.Date.from_string('2016-01-03'), 'amount': 10.0},
+ {'date': fields.Date.from_string('2016-01-05'), 'amount': 10.0},
+ ]
+ )
+
+ def test_creation_initial_sync_statement_bis(self):
+ transactions = self._create_online_transactions(['2016-01-01', '2016-01-03'])
+ self.account_online_account.balance = 20
+ self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account)
+ # Since ending balance is 20$ and we only have 20$ of transactions and that it is the first statement
+ # it should NOT create a initial statement before this one
+ created_st_lines = self.BankStatementLine.search([('journal_id', '=', self.euro_bank_journal.id)], order='internal_index asc')
+ self.assertRecordValues(
+ created_st_lines,
+ [
+ {'date': fields.Date.from_string('2016-01-01'), 'amount': 10.0},
+ {'date': fields.Date.from_string('2016-01-03'), 'amount': 10.0},
+ ]
+ )
+
+ def test_creation_initial_sync_statement_invert_sign(self):
+ self.account_online_account.balance = -20
+ self.account_online_account.inverse_transaction_sign = True
+ self.account_online_account.inverse_balance_sign = True
+ transactions = self._create_online_transactions(['2016-01-01', '2016-01-03'])
+ self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account)
+ # Since ending balance is 1000$ and we only have 20$ of transactions and that it is the first statement
+ # it should create a statement before this one with the initial statement line
+ created_st_lines = self.BankStatementLine.search([('journal_id', '=', self.euro_bank_journal.id)], order='internal_index asc')
+ self.assertEqual(len(created_st_lines), 2, 'Should have created two bank statement lines for the synchronization')
+ transactions = self._create_online_transactions(['2016-01-05'])
+ self.account_online_account.balance = -30
+ self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account)
+ created_st_lines = self.BankStatementLine.search([('journal_id', '=', self.euro_bank_journal.id)], order='internal_index asc')
+ self.assertRecordValues(
+ created_st_lines,
+ [
+ {'date': fields.Date.from_string('2016-01-01'), 'amount': -10.0},
+ {'date': fields.Date.from_string('2016-01-03'), 'amount': -10.0},
+ {'date': fields.Date.from_string('2016-01-05'), 'amount': -10.0},
+ ]
+ )
+
+ @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineLink._fetch_transactions')
+ @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineLink._update_connection_status')
+ def test_automatic_journal_assignment(self, patched_update_connection_status, patched_fetch_transactions):
+ def create_online_account(name, link_id, iban, currency_id):
+ return self.env['account.online.account'].create({
+ 'name': name,
+ 'account_online_link_id': link_id,
+ 'account_number': iban,
+ 'currency_id' : currency_id,
+ })
+
+ def create_bank_account(account_number, partner_id):
+ return self.env['res.partner.bank'].create({
+ 'acc_number': account_number,
+ 'partner_id': partner_id,
+ })
+
+ def create_journal(name, journal_type, code, currency_id=False, bank_account_id=False):
+ return self.env['account.journal'].create({
+ 'name': name,
+ 'type': journal_type,
+ 'code': code,
+ 'currency_id': currency_id,
+ 'bank_account_id': bank_account_id,
+ })
+
+ bank_account_1 = create_bank_account('BE48485444456727', self.company_data['company'].partner_id.id)
+ bank_account_2 = create_bank_account('BE23798242487491', self.company_data['company'].partner_id.id)
+
+ bank_journal_with_account_gol = create_journal('Bank with account', 'bank', 'BJWA1', self.other_currency.id)
+ bank_journal_with_account_usd = create_journal('Bank with account USD', 'bank', 'BJWA3', self.env.ref('base.USD').id, bank_account_2.id)
+
+ online_account_1 = create_online_account('OnlineAccount1', self.account_online_link.id, 'BE48485444456727', self.other_currency.id)
+ online_account_2 = create_online_account('OnlineAccount2', self.account_online_link.id, 'BE61954856342317', self.other_currency.id)
+ online_account_3 = create_online_account('OnlineAccount3', self.account_online_link.id, 'BE23798242487495', self.other_currency.id)
+
+ patched_fetch_transactions.return_value = True
+ patched_update_connection_status.return_value = {
+ 'consent_expiring_date': None,
+ 'is_payment_enabled': False,
+ 'is_payment_activated': False,
+ }
+
+ account_link_journal_wizard = self.env['account.bank.selection'].create({'account_online_link_id': self.account_online_link.id})
+ account_link_journal_wizard.with_context(active_model='account.journal', active_id=bank_journal_with_account_gol.id).sync_now()
+ self.assertEqual(
+ online_account_1.id, bank_journal_with_account_gol.account_online_account_id.id,
+ "The wizard should have linked the online account to the journal with the same account."
+ )
+ self.assertEqual(bank_journal_with_account_gol.bank_account_id, bank_account_1, "Account should be set on the journal")
+
+ # Test with no context present, should create a new journal
+ previous_number = self.env['account.journal'].search_count([])
+ account_link_journal_wizard.selected_account = online_account_2
+ account_link_journal_wizard.sync_now()
+ actual_number = self.env['account.journal'].search_count([])
+ self.assertEqual(actual_number, previous_number+1, "should have created a new journal")
+ self.assertEqual(online_account_2.journal_ids.currency_id, self.other_currency)
+ self.assertEqual(online_account_2.journal_ids.bank_account_id.sanitized_acc_number, sanitize_account_number('BE61954856342317'))
+
+ # Test assigning to a journal in another currency
+ account_link_journal_wizard.selected_account = online_account_3
+ account_link_journal_wizard.with_context(active_model='account.journal', active_id=bank_journal_with_account_usd.id).sync_now()
+ self.assertEqual(online_account_3.id, bank_journal_with_account_usd.account_online_account_id.id)
+ self.assertEqual(bank_journal_with_account_usd.bank_account_id, bank_account_2, "Bank Account should not have changed")
+ self.assertEqual(bank_journal_with_account_usd.currency_id, self.other_currency, "Currency should have changed")
+
+ @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineLink._fetch_odoo_fin')
+ def test_fetch_transaction_date_start(self, patched_fetch):
+ """ This test verifies that the start_date params used when fetching transaction is correct """
+ patched_fetch.return_value = {'transactions': []}
+ # Since no transactions exists in db, we should fetch transactions without a starting_date
+ self.account_online_account._retrieve_transactions()
+ data = {
+ 'start_date': False,
+ 'account_id': False,
+ 'last_transaction_identifier': False,
+ 'currency_code': 'EUR',
+ 'provider_data': False,
+ 'account_data': False,
+ 'include_pendings': False,
+ 'include_foreign_currency': True,
+ }
+ patched_fetch.assert_called_with('/proxy/v1/transactions', data=data)
+
+ # No transaction exists in db but we have a value for last_sync on the online_account, we should use that date
+ self.account_online_account.last_sync = '2020-03-04'
+ data['start_date'] = '2020-03-04'
+ self.account_online_account._retrieve_transactions()
+ patched_fetch.assert_called_with('/proxy/v1/transactions', data=data)
+
+ # We have transactions, we should use the date of the latest one instead of the last_sync date
+ transactions = self._create_online_transactions(['2016-01-01', '2016-01-03'])
+ self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account)
+ self.account_online_account.last_sync = '2020-03-04'
+ data['start_date'] = '2016-01-03'
+ data['last_transaction_identifier'] = '2'
+ self.account_online_account._retrieve_transactions()
+ patched_fetch.assert_called_with('/proxy/v1/transactions', data=data)
+
+ def test_multiple_transaction_identifier_fetched(self):
+ # Ensure that if we receive twice the same transaction within the same call, it won't be created twice
+ transactions = self._create_online_transactions(['2016-01-01', '2016-01-03'])
+ # Add first transactions to the list again
+ transactions.append(transactions[0])
+ self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account)
+ bnk_stmt_lines = self.BankStatementLine.search([('online_transaction_identifier', '!=', False), ('journal_id', '=', self.euro_bank_journal.id)])
+ self.assertEqual(len(bnk_stmt_lines), 2, 'Should only have created two lines')
+
+ @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineLink._fetch_odoo_fin')
+ def test_fetch_transactions_reauth(self, patched_refresh):
+ patched_refresh.side_effect = [
+ {
+ 'success': False,
+ 'code': 300,
+ 'data': {'mode': 'updateCredentials'},
+ },
+ {
+ 'access_token': 'open_sesame',
+ },
+ ]
+ self.account_online_account.account_online_link_id.state = 'connected'
+ res = self.account_online_account.account_online_link_id._fetch_transactions()
+ self.assertTrue('account_online_identifier' in res.get('params', {}).get('includeParam', {}))
+
+ def test_duplicate_transaction_date_amount_account(self):
+ """ This test verifies that the duplicate transaction wizard is detects transactions with
+ same date, amount, account_number and currency
+ """
+ # Create 2 groups of respectively 2 and 3 duplicate transactions. We create one transaction the day before so the opening statement does not interfere with the test.
+ transactions = self._create_online_transactions([
+ '2024-01-01',
+ '2024-01-02', '2024-01-02',
+ '2024-01-03', '2024-01-03', '2024-01-03',
+ ])
+ bsls = self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account)
+ self.env.flush_all() # _get_duplicate_transactions make sql request, must write to db
+ duplicate_transactions = self.euro_bank_journal._get_duplicate_transactions(
+ fields.Date.to_date('2000-01-01')
+ )
+ group_1 = bsls.filtered(lambda bsl: bsl.date == fields.Date.from_string('2024-01-02')).ids
+ group_2 = bsls.filtered(lambda bsl: bsl.date == fields.Date.from_string('2024-01-03')).ids
+
+ self.assertEqual(duplicate_transactions, [group_1, group_2])
+
+ # check has_duplicate_transactions
+ has_duplicate_transactions = self.euro_bank_journal._has_duplicate_transactions(
+ fields.Date.to_date('2000-01-01')
+ )
+ self.assertTrue(has_duplicate_transactions is True) # explicit check on bool type
+
+ def test_duplicate_transaction_online_transaction_identifier(self):
+ """ This test verifies that the duplicate transaction wizard is detects transactions with
+ same online_transaction_identifier
+ """
+ # Create transactions
+ transactions = self._create_online_transactions([
+ '2024-01-01', '2024-01-02', '2024-01-03', '2024-01-04', '2024-01-05'
+ ])
+ bsls = self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account)
+
+ group_1, group_2 = [], []
+ for bsl in bsls:
+ # have to update the online_transaction_identifier after to force duplicates
+ if bsl.payment_ref in ('transaction_1', 'transaction_2'):
+ group_1.append(bsl.id)
+ bsl.online_transaction_identifier = 'same_oti_1'
+ if bsl.payment_ref in ('transaction_3, transaction_4, transaction_5'):
+ group_2.append(bsl.id)
+ bsl.online_transaction_identifier = 'same_oti_2'
+
+ self.env.flush_all() # _get_duplicate_transactions make sql request, must write to db
+ duplicate_transactions = self.euro_bank_journal._get_duplicate_transactions(
+ fields.Date.to_date('2000-01-01')
+ )
+ self.assertEqual(duplicate_transactions, [group_1, group_2])
+
+ @patch('odoo.addons.odex30_account_online_sync.models.account_online.requests')
+ def test_fetch_receive_error_message(self, patched_request):
+ # We want to test that when we receive an error, a redirectWarning with the correct parameter is thrown
+ # However the method _log_information that we need to test for that is performing a rollback as it needs
+ # to save the message error on the record as well (so it rollback, save message, commit, raise error).
+ # So in order to test the method, we need to use a "test cursor".
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ 'error': {
+ 'code': 400,
+ 'message': 'Shit Happened',
+ 'data': {
+ 'exception_type': 'random',
+ 'message': 'This kind of things can happen.',
+ 'error_reference': 'abc123',
+ 'provider_type': 'theonlyone',
+ 'redirect_warning_url': 'odoo_support',
+ },
+ },
+ }
+ patched_request.post.return_value = mock_response
+
+ generated_url = 'https://www.odoo.com/help?stage=bank_sync&summary=Bank+sync+error+ref%3A+abc123+-+Provider%3A+theonlyone+-+Client+ID%3A+client_id_1&description=ClientID%3A+client_id_1%0AInstitution%3A+Test+Bank%0AError+Reference%3A+abc123%0AError+Message%3A+This+kind+of+things+can+happen.%0A'
+ return_act_url = {
+ 'type': 'ir.actions.act_url',
+ 'url': generated_url
+ }
+ body_generated_url = generated_url.replace('&', '&') #in post_message, & has been escaped to &
+ message_body = f"""
This kind of things can happen.
+
+If you've already opened a ticket for this issue, don't report it again: a support agent will contact you shortly. You can contact Odoo support Here
"""
+
+ # flush and clear everything for the new "transaction"
+ self.env.invalidate_all()
+ try:
+ self.env.registry.enter_test_mode(self.cr)
+ with self.env.registry.cursor() as test_cr:
+ test_env = self.env(cr=test_cr)
+ test_link_account = self.account_online_link.with_env(test_env)
+ test_link_account.state = 'connected'
+
+ # this hand-written self.assertRaises() does not roll back self.cr,
+ # which is necessary below to inspect the message being posted
+ try:
+ test_link_account._fetch_odoo_fin('/testthisurl')
+ except RedirectWarning as exception:
+ self.assertEqual(exception.args[0], "This kind of things can happen.\n\nIf you've already opened a ticket for this issue, don't report it again: a support agent will contact you shortly.")
+ self.assertEqual(exception.args[1], return_act_url)
+ self.assertEqual(exception.args[2], 'Report issue')
+ else:
+ self.fail("Expected RedirectWarning not raised")
+ self.assertEqual(test_link_account.message_ids[0].body, message_body)
+ finally:
+ self.env.registry.leave_test_mode()
+
+ def test_account_online_link_having_journal_ids(self):
+ """ This test verifies that the account online link object
+ has all the journal in the field journal_ids.
+ It's important to handle these journals because we need
+ them to add the consent expiring date.
+ """
+ # Create a bank sync connection having 2 online accounts (with one journal connected for each account)
+ online_link = self.env['account.online.link'].create({
+ 'name': 'My New Bank connection',
+ })
+ online_accounts = self.env['account.online.account'].create([
+ {
+ 'name': 'Account 1',
+ 'account_online_link_id': online_link.id,
+ 'journal_ids': [Command.create({
+ 'name': 'Account 1',
+ 'code': 'BK1',
+ 'type': 'bank',
+ })],
+ },
+ {
+ 'name': 'Account 2',
+ 'account_online_link_id': online_link.id,
+ 'journal_ids': [Command.create({
+ 'name': 'Account 2',
+ 'code': 'BK2',
+ 'type': 'bank',
+ })],
+ },
+ ])
+ self.assertEqual(online_link.account_online_account_ids, online_accounts)
+ self.assertEqual(len(online_link.journal_ids), 2) # Our online link connections should have 2 journals.
+
+ def test_transaction_details_json_compatibility_from_html(self):
+ """ This test checks that, after being imported from the transient model
+ the records of account.bank.statement.line will have the
+ 'transaction_details' field able to be decoded to a JSON,
+ i.e. it is not encapsulated in
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_reports/data/mail_activity_type_data.xml b/dev_odex30_accounting/odex30_account_reports/data/mail_activity_type_data.xml
new file mode 100644
index 0000000..4638127
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/data/mail_activity_type_data.xml
@@ -0,0 +1,42 @@
+
+
+
+
+ Tax Report
+ Tax Report
+ tax_report
+ account.journal
+ suggest
+
+
+
+ Pay Tax
+ Tax is ready to be paid
+ tax_report
+ 0
+ days
+ previous_activity
+ account.move
+ suggest
+
+
+
+ Tax Report Ready
+ Tax report is ready to be sent to the administration
+ tax_report
+ 0
+ days
+ current_date
+ account.move
+ suggest
+
+
+
+ Tax Report - Error
+ Error sending Tax Report
+ tax_report
+ account.move
+ warning
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_reports/data/mail_templates.xml b/dev_odex30_accounting/odex30_account_reports/data/mail_templates.xml
new file mode 100644
index 0000000..b4af9e0
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/data/mail_templates.xml
@@ -0,0 +1,26 @@
+
+
+ Customer Statement
+
+ {{ object._get_followup_responsible().email_formatted }}
+ {{ (object.company_id or object._get_followup_responsible().company_id).name }} Statement - {{ object.commercial_company_name }}
+
+
+
+ Dear (),
+ Dear ,
+
+ Please find enclosed the statement of your account.
+
+ Do not hesitate to contact us if you have any questions.
+
+ Sincerely,
+
+
+
\n"
+" Dear (),\n"
+" Dear ,\n"
+" \n"
+" Please find enclosed the statement of your account.\n"
+" \n"
+" Do not hesitate to contact us if you have any questions.\n"
+" \n"
+" Sincerely,\n"
+" \n"
+"\t \n"
+"
\n"
+" Dear (),\n"
+" Dear ,"
+"t>\n"
+" \n"
+" Please find enclosed the statement of your account.\n"
+" \n"
+" Do not hesitate to contact us if you have any "
+"questions.\n"
+" \n"
+" Sincerely,\n"
+" \n"
+"\t \n"
+"
\n"
+"
\n"
+" "
+msgstr ""
+"
\n"
+"
\n"
+" عزيزنا "
+"()،\n"
+" عزيزنا ،"
+"t>\n"
+" \n"
+" يُرجى الاطلاع على كشف حسابك في المرفقات.\n"
+" \n"
+" لا تتردد في التواصل معنا إذا كانت لديك أي أسئلة أو "
+"استفسارات.\n"
+" \n"
+" مع وافر الشكر والتقدير،\n"
+" \n"
+"\t \n"
+"
\n"
+"
\n"
+" "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_send_form
+msgid ""
+""
+msgstr ""
+""
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_file_download_error_wizard_form
+msgid ""
+"Errors marked with are critical and prevent "
+"the file generation."
+msgstr ""
+"تعتبر الأخطاء التي تم وضع علامة عليها خطيرة "
+"وتمنع إنشاء الملف. "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_reports_journal_dashboard_kanban_view
+msgid "Reconciliation"
+msgstr "التسوية"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_file_download_error_wizard_form
+msgid "One or more error(s) occurred during file generation:"
+msgstr "وقع خطأ واحد أو أكثر أثناء عملية إنشاء الملف: "
+
+#. module: odex30_account_reports
+#: model:ir.model.constraint,message:odex30_account_reports.constraint_account_report_horizontal_group_name_uniq
+msgid "A horizontal group with the same name already exists."
+msgstr "توجد مجموعة أفقية بنفس الاسم بالفعل. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/widgets/account_report_x2many/account_report_x2many.js:0
+msgid "A line with a 'Group By' value cannot have children."
+msgstr "البند الذي به قيمة 'التجميع حسب' لا يمكن أن يكون له توابع. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_tax.py:0
+msgid ""
+"A tax unit can only be created between companies sharing the same main "
+"currency."
+msgstr "يمكن إنشاء الوحدة الضريبية فقط بين الشركات التي تتشارك نفس العملة. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_tax.py:0
+msgid ""
+"A tax unit must contain a minimum of two companies. You might want to delete "
+"the unit."
+msgstr "يجب أن تحتوي وحدة الضريبة على شركتين كحد أدنى. قد ترغب في حذف الوحدة. "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_total_assets0
+msgid "ASSETS"
+msgstr "الأصول"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/deferred_reports/groupby.xml:0
+#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0
+#: model:account.report.column,name:odex30_account_reports.aged_payable_report_account_name
+#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_account_name
+#: model:account.report.column,name:odex30_account_reports.partner_ledger_report_account_code
+#: model:ir.model,name:odex30_account_reports.model_account_account
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__account_id
+msgid "Account"
+msgstr "الحساب "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_chart_template
+msgid "Account Chart Template"
+msgstr "نموذج مخطط الحساب "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+msgid "Account Code"
+msgstr "كود الحساب "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Account Code / Tag"
+msgstr "علامة تصنيف / كود الحساب "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__account_display_representative_field
+msgid "Account Display Representative Field"
+msgstr "حقل ممثل لعرض الحساب "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+msgid "Account Label"
+msgstr "عنوان الحساب "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Account Name"
+msgstr "اسم الحساب"
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_report_annotation
+msgid "Account Report Annotation"
+msgstr "شرح تقرير الحساب "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_report_custom_handler
+msgid "Account Report Custom Handler"
+msgstr "المعالج المخصص لتقارير الحساب "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_tax_report_handler
+msgid "Account Report Handler for Tax Reports"
+msgstr "معالج تقرير الحساب للتقارير الضريبية "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_report_send
+msgid "Account Report Send"
+msgstr "إرسال التقرير المحاسبي "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_res_config_settings__account_reports_show_per_company_setting
+msgid "Account Reports Show Per Company Setting"
+msgstr "عرض تقارير الحساب حسب إعدادات الشركة "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_res_partner__account_represented_company_ids
+#: model:ir.model.fields,field_description:odex30_account_reports.field_res_users__account_represented_company_ids
+msgid "Account Represented Company"
+msgstr "شركة ممثَّلة في الحساب "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__account_revaluation_journal_id
+msgid "Account Revaluation Journal"
+msgstr "يومية إعادة تقييم الحساب "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_account_type.xml:0
+msgid "Account:"
+msgstr "الحساب: "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__account_representative_id
+msgid "Accounting Firm"
+msgstr "مؤسسة محاسبية "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_report
+msgid "Accounting Report"
+msgstr "تقرير المحاسبة "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_report_budget
+msgid "Accounting Report Budget"
+msgstr "ميزانية التقرير المحاسبي "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_report_budget_item
+msgid "Accounting Report Budget Item"
+msgstr "عنصر ميزانية التقرير المحاسبي "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_report_expression
+msgid "Accounting Report Expression"
+msgstr "تعبير التقرير المحاسبي "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_report_external_value
+msgid "Accounting Report External Value"
+msgstr "القيمة الخارجية للتقرير المحاسبي"
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_report_line
+msgid "Accounting Report Line"
+msgstr "بند التقرير المحاسبي "
+
+#. module: odex30_account_reports
+#: model:ir.actions.act_window,name:odex30_account_reports.action_account_report_tree
+#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_tree
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_search
+msgid "Accounting Reports"
+msgstr "التقارير المحاسبية"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_analytic.xml:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_analytic_groupby.xml:0
+msgid "Accounts"
+msgstr "الحسابات"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form
+msgid "Accounts Coverage Report"
+msgstr "تقرير تغطية الحسابات "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.multicurrency_revaluation_to_adjust
+msgid "Accounts To Adjust"
+msgstr "الحسابات بانتظار التعديل "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Accounts coverage"
+msgstr "تغطية الحسابات "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_mail_activity_type__category
+msgid "Action"
+msgstr "إجراء"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_file_download_error_wizard__actionable_errors
+msgid "Actionable Errors"
+msgstr "أخطاء قابلة للتنفيذ "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,help:odex30_account_reports.field_mail_activity_type__category
+msgid ""
+"Actions may trigger specific behavior like opening calendar view or "
+"automatically mark as done when a document is uploaded"
+msgstr ""
+"قد تؤدي الإجراءات إلى سلوك معين مثل فتح طريقة عرض التقويم أو وضع علامة "
+"\"تم\" تلقائياً عند تحميل مستند "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_mail_activity
+msgid "Activity"
+msgstr "النشاط"
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_mail_activity_type
+msgid "Activity Type"
+msgstr "نوع النشاط"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/line_name/popover/annotations_popover.xml:0
+#: code:addons/odex30_account_reports/static/src/widgets/account_report_x2many/account_report_x2many.xml:0
+msgid "Add a line"
+msgstr "إضافة بند"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_send_form
+msgid "Add contacts to notify..."
+msgstr "إضافة جهات اتصال لإشعارهم..."
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__totals_below_sections
+#: model:ir.model.fields,field_description:odex30_account_reports.field_res_config_settings__totals_below_sections
+msgid "Add totals below sections"
+msgstr "إضافة إجمالي تحت الأقسام"
+
+#. module: odex30_account_reports
+#: model:account.report.column,name:odex30_account_reports.multicurrency_revaluation_report_adjustment
+msgid "Adjustment"
+msgstr "التعديلات"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_multicurrency_revaluation_report.py:0
+msgid "Adjustment Entry"
+msgstr "تعديل القيد"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0
+msgid "Advance Payments received from customers"
+msgstr "المدفوعات المدفوعة مقدماً من قِبَل العملاء "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0
+msgid "Advance payments made to suppliers"
+msgstr "المدفوعات المدفوعة مقدمًا للموردين"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form
+msgid "Advanced"
+msgstr "متقدم"
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_aged_partner_balance_report_handler
+msgid "Aged Partner Balance Custom Handler"
+msgstr "المعالج المخصص لأعمار ديون الشريك "
+
+#. module: odex30_account_reports
+#: model:account.report,name:odex30_account_reports.aged_payable_report
+#: model:account.report.line,name:odex30_account_reports.aged_payable_line
+#: model:ir.actions.client,name:odex30_account_reports.action_account_report_ap
+#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_aged_payable
+msgid "Aged Payable"
+msgstr "حسابات دائنة مستحقة متأخرة "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_aged_payable_report_handler
+msgid "Aged Payable Custom Handler"
+msgstr "المعالج المخصص للحسابات الدائنة المستحقة "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_reports_journal_dashboard_kanban_view
+msgid "Aged Payables"
+msgstr "حساب دائن مستحق متأخر "
+
+#. module: odex30_account_reports
+#: model:account.report,name:odex30_account_reports.aged_receivable_report
+#: model:account.report.line,name:odex30_account_reports.aged_receivable_line
+#: model:ir.actions.client,name:odex30_account_reports.action_account_report_ar
+#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_aged_receivable
+msgid "Aged Receivable"
+msgstr "المتأخر المدين"
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_aged_receivable_report_handler
+msgid "Aged Receivable Custom Handler"
+msgstr "المعالج المخصص للحسابات المدينة المستحقة "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_reports_journal_dashboard_kanban_view
+msgid "Aged Receivables"
+msgstr "المتأخرات المدينة"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_fiscal_position.xml:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+#: code:addons/odex30_account_reports/static/src/components/sales_report/filters/filters.js:0
+msgid "All"
+msgstr "الكل"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "All Journals"
+msgstr "كافة اليوميات "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "All Payable"
+msgstr "جميع الذمم مستحقة الدفع "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "All Receivable"
+msgstr "جميع الذمم المدينة"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "All Report Variants"
+msgstr "كافة متغيرات التقرير "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/tax_report/warnings.xml:0
+msgid ""
+"All selected companies or branches do not share the same Tax ID. Please "
+"check the Tax ID of the selected companies."
+msgstr ""
+"لا تتشارك كل الشركات أو الفروع المحددة في نفس المُعرِّف الضريبي. يُرجى التحقق من "
+"المُعرِّف الضريبي للشركات المحددة. "
+
+#. module: odex30_account_reports
+#: model:account.report.column,name:odex30_account_reports.account_financial_report_ec_sales_amount
+#: model:account.report.column,name:odex30_account_reports.bank_reconciliation_report_amount
+#: model:account.report.column,name:odex30_account_reports.customer_statement_amount
+#: model:account.report.column,name:odex30_account_reports.followup_report_amount
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__amount
+msgid "Amount"
+msgstr "مبلغ"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+#: model:account.report.column,name:odex30_account_reports.aged_payable_report_amount_currency
+#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_amount_currency
+#: model:account.report.column,name:odex30_account_reports.bank_reconciliation_report_amount_currency
+#: model:account.report.column,name:odex30_account_reports.customer_statement_report_amount_currency
+#: model:account.report.column,name:odex30_account_reports.followup_report_report_amount_currency
+#: model:account.report.column,name:odex30_account_reports.partner_ledger_report_amount_currency
+msgid "Amount Currency"
+msgstr "عملة المبلغ"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+msgid "Amount in currency: %s"
+msgstr "المبلغ بالعملة: %s "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Amounts in Lakhs"
+msgstr "المبلغ باللاكس "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Amounts in Millions"
+msgstr "المبالغ بالملايين "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Amounts in Thousands"
+msgstr "المبالغ بالآلاف "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_analytic.xml:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_analytic_groupby.xml:0
+msgid "Analytic"
+msgstr "تحليلي "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report__filter_analytic_groupby
+msgid "Analytic Group By"
+msgstr "التجميع التحليلي حسب "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0
+msgid "Analytic Simulations"
+msgstr "عمليات المحاكاة التحليلية "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/line_name/line_name.xml:0
+msgid "Annotate"
+msgstr "تعليق"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/line_name/popover/annotations_popover.xml:0
+msgid "Annotation"
+msgstr "الشرح "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report__annotations_ids
+msgid "Annotations"
+msgstr "الشرح "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "As of %s"
+msgstr "اعتبارًا من %s"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "Ascending"
+msgstr "تصاعدي "
+
+#. module: odex30_account_reports
+#: model:account.report.column,name:odex30_account_reports.aged_payable_report_period0
+#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_period0
+msgid "At Date"
+msgstr "بتاريخ"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_send_form
+msgid "Attach a file"
+msgstr "إرفاق ملف"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0
+#: code:addons/odex30_account_reports/static/src/components/journal_report/line_name.xml:0
+msgid "Audit"
+msgstr "تدقيق"
+
+#. module: odex30_account_reports
+#: model:ir.ui.menu,name:odex30_account_reports.account_reports_audit_reports_menu
+msgid "Audit Reports"
+msgstr "تقارير التدقيق"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_avgcre0
+msgid "Average creditors days"
+msgstr "متوسط أيام الدائنين"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_avdebt0
+msgid "Average debtors days"
+msgstr "متوسط أيام المدينين"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+msgid "B: %s"
+msgstr "B: %s"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+#: model:account.report.column,name:odex30_account_reports.balance_sheet_balance
+#: model:account.report.column,name:odex30_account_reports.cash_flow_report_balance
+#: model:account.report.column,name:odex30_account_reports.customer_statement_report_balance
+#: model:account.report.column,name:odex30_account_reports.executive_summary_column
+#: model:account.report.column,name:odex30_account_reports.followup_report_report_balance
+#: model:account.report.column,name:odex30_account_reports.general_ledger_report_balance
+#: model:account.report.column,name:odex30_account_reports.journal_report_balance
+#: model:account.report.column,name:odex30_account_reports.partner_ledger_report_balance
+#: model:account.report.column,name:odex30_account_reports.profit_and_loss_column
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_reports_journal_dashboard_kanban_view
+msgid "Balance"
+msgstr "الرصيد"
+
+#. module: odex30_account_reports
+#: model:account.report,name:odex30_account_reports.balance_sheet
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_balancesheet0
+#: model:ir.actions.client,name:odex30_account_reports.action_account_report_bs
+#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_balance_sheet
+msgid "Balance Sheet"
+msgstr "الميزانية العمومية"
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_balance_sheet_report_handler
+msgid "Balance Sheet Custom Handler"
+msgstr "المعالج المخصص للميزانية العمومية "
+
+#. module: odex30_account_reports
+#: model:account.report.column,name:odex30_account_reports.multicurrency_revaluation_report_balance_current
+msgid "Balance at Current Rate"
+msgstr "الرصيد بسعر الصرف الحالي "
+
+#. module: odex30_account_reports
+#: model:account.report.column,name:odex30_account_reports.multicurrency_revaluation_report_balance_operation
+msgid "Balance at Operation Rate"
+msgstr "الرصيد بسعر العملية "
+
+#. module: odex30_account_reports
+#: model:account.report.column,name:odex30_account_reports.multicurrency_revaluation_report_balance_currency
+msgid "Balance in Foreign Currency"
+msgstr "الرصيد بالعملة الأجنبية "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/bank_reconciliation_report.py:0
+msgid "Balance of '%s'"
+msgstr "رصيد '%s' "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.balance_bank
+msgid "Balance of Bank"
+msgstr "رصيد البنك "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0
+msgid "Balance tax advance payment account"
+msgstr "حساب رصيد الضريبة مسبقة الدفع "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0
+msgid "Balance tax current account (payable)"
+msgstr "حساب رصيد الضريبة مسبقة الدفع (الدائن)"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0
+msgid "Balance tax current account (receivable)"
+msgstr "حساب رصيد الضريبة مسبقة الدفع (المدين)"
+
+#. module: odex30_account_reports
+#: model:ir.actions.client,name:odex30_account_reports.action_account_report_bank_reconciliation
+msgid "Bank Reconciliation"
+msgstr "التسوية البنكية"
+
+#. module: odex30_account_reports
+#: model:account.report,name:odex30_account_reports.bank_reconciliation_report
+msgid "Bank Reconciliation Report"
+msgstr "تقرير التسوية البنكية"
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_bank_reconciliation_report_handler
+msgid "Bank Reconciliation Report Custom Handler"
+msgstr "المعالج المخصص لتقارير تسوية البنك "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_bank_view0
+msgid "Bank and Cash Accounts"
+msgstr "الحسابات البنكية والنقدية "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.bank_information_customer_report
+msgid "Bank:"
+msgstr ""
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_general_ledger.py:0
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary
+msgid "Base Amount"
+msgstr "المبلغ الأساسي"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filter_aging.xml:0
+msgid "Based on"
+msgstr "بناءً على"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0
+msgid "Before"
+msgstr "قبل"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_budgets.xml:0
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__budget_id
+msgid "Budget"
+msgstr "الميزانية "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_account__budget_item_ids
+msgid "Budget Item"
+msgstr "عنصر الميزانية "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_budget_form
+msgid "Budget Items"
+msgstr "عناصر الميزانية "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_budgets.xml:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_budget_form
+msgid "Budget Name"
+msgstr "اسم الميزانية "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Budget items can only be edited from account lines."
+msgstr "يمكن تحرير عناصر الميزانية فقط من بنود الحساب. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/redirectAction/redirectAction.xml:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_send_form
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_multicurrency_revaluation_wizard
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_report_export_wizard
+msgid "Cancel"
+msgstr "إلغاء"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0
+msgid "Cannot audit tax from another model than account.tax."
+msgstr "لا يمكن تدقيق الضريبة من نموذج آخر غير account.tax. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Cannot generate carryover values for all fiscal positions at once!"
+msgstr "لا يمكن إنشاء قيم الترحيل لكافة الأوضاع المالية في آن واحد! "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/carryover_popover.xml:0
+msgid "Carryover"
+msgstr "الترحيل"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Carryover adjustment for tax unit"
+msgstr "تعديل الترحيل لوحدة الضريبة "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Carryover can only be generated for a single column group."
+msgstr "يمكن إنشاء الترحيل فقط لمجموعة عمود واحدة. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Carryover from %(date_from)s to %(date_to)s"
+msgstr "ترحيل من %(date_from)s إلى %(date_to)s "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Carryover lines for: %s"
+msgstr "ترحيل القيود لـ: %s "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_cash0
+msgid "Cash"
+msgstr "نقدي"
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_cash_flow_report_handler
+msgid "Cash Flow Report Custom Handler"
+msgstr "المعالج المخصص لتقارير التدفق النقدي "
+
+#. module: odex30_account_reports
+#: model:account.report,name:odex30_account_reports.cash_flow_report
+#: model:ir.actions.client,name:odex30_account_reports.action_account_report_cs
+#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_cash_flow
+msgid "Cash Flow Statement"
+msgstr "كشف التدفقات النقدية"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0
+msgid "Cash and cash equivalents, beginning of period"
+msgstr "النقد ومعادِلات النقد، بداية الفترة "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0
+msgid "Cash and cash equivalents, closing balance"
+msgstr "النقد وومعادِلات النقد، رصيد الإقفال "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0
+msgid "Cash flows from financing activities"
+msgstr "التدفقات النقدية من الأنشطة المالية "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0
+msgid "Cash flows from investing & extraordinary activities"
+msgstr "التدفقات النقدية من الأنشطة الاستثمارية وغير العادية"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0
+msgid "Cash flows from operating activities"
+msgstr "التدفقات النقدية من الأنشطة التشغيلية"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0
+msgid "Cash flows from unclassified activities"
+msgstr "التدفقات النقدية من الأنشطة غير المصنفة"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0
+msgid "Cash in"
+msgstr "إيرادات"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0
+msgid "Cash out"
+msgstr "نفقات "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0
+msgid "Cash paid for operating activities"
+msgstr "نفقات الأنشطة التشغيلية "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_cash_received0
+msgid "Cash received"
+msgstr "الأرباح النقدية "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0
+msgid "Cash received from operating activities"
+msgstr "أرباح الأنشطة التشغيلية"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_cash_spent0
+msgid "Cash spent"
+msgstr "المبلغ المُنفَق "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_cash_surplus0
+msgid "Cash surplus"
+msgstr "الفائض النقدي"
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_change_lock_date
+msgid "Change Lock Date"
+msgstr "تغيير تاريخ الإقفال"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/wizard/account_report_send.py:0
+msgid "Check Partner(s) Email(s)"
+msgstr "تحقق من عناوين البريد الإلكتروني للشركاء "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/tax_report/warnings.xml:0
+msgid "Check them"
+msgstr "تفقدهم "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_file_download_error_wizard_form
+msgid "Close"
+msgstr "إغلاق"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0
+msgid "Closing Entry"
+msgstr "قيد الإقفال "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_closing_bank_balance0
+msgid "Closing bank balance"
+msgstr "رصيد الإقفال البنكي "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+#: model:account.report.column,name:odex30_account_reports.journal_report_code
+msgid "Code"
+msgstr "رمز "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/sales_report/filters/filter_code.xml:0
+msgid "Codes:"
+msgstr "الأكواد: "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form
+msgid "Columns"
+msgstr "الأعمدة"
+
+#. module: odex30_account_reports
+#: model:account.report.column,name:odex30_account_reports.general_ledger_report_communication
+msgid "Communication"
+msgstr "التواصل "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+#: model:ir.model,name:odex30_account_reports.model_res_company
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__company_ids
+msgid "Companies"
+msgstr "الشركات"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__company_id
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__company_id
+msgid "Company"
+msgstr "الشركة "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_tax.py:0
+msgid ""
+"Company %(company)s already belongs to a tax unit in %(country)s. A company "
+"can at most be part of one tax unit per country."
+msgstr ""
+"الشركة %(company)s تنتمي بالفعل إلى وحدة ضريبية في %(country)s. يمكن أن تكون "
+"الشركة الواحدة جزءاً من وحدة ضريبية واحدة كحد أقصى لكل دولة. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Company Currency"
+msgstr "عملة الشركة "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_tax_unit.xml:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "Company Only"
+msgstr "الشركة فقط "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Company Settings"
+msgstr "إعدادات الشركة "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0
+msgid "Comparison"
+msgstr "مقارنة"
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_res_config_settings
+msgid "Config Settings"
+msgstr "تهيئة الإعدادات "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form
+msgid "Configure start dates"
+msgstr "تهيئة تواريخ البدء "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0
+msgid "Configure your TAX accounts - %s"
+msgstr "قم بتهيئة حساباتك الضريبية - %s "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/res_config_settings.py:0
+msgid "Configure your start dates"
+msgstr "قم بتهيئة تواريخ البدء "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form
+msgid "Configure your tax accounts"
+msgstr "قم بتهيئة حساباتك الضريبية "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_res_partner
+msgid "Contact"
+msgstr "جهة الاتصال"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.duplicated_vat_partner_tree_view
+msgid "Contacts"
+msgstr "جهات الاتصال"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__mail_body
+msgid "Contents"
+msgstr "المحتويات"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_direct_costs0
+msgid "Cost of Revenue"
+msgstr "تكاليف الإيرادات "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"Could not expand term %(term)s while evaluating formula %"
+"(unexpanded_formula)s"
+msgstr "hello "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Could not parse account_code formula from token '%s'"
+msgstr "تعذر تحليل صيغة account_code من الرمز '%s' "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__country_id
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_search
+msgid "Country"
+msgstr "الدولة"
+
+#. module: odex30_account_reports
+#: model:account.report.column,name:odex30_account_reports.account_financial_report_ec_sales_country
+msgid "Country Code"
+msgstr "رمز الدولة"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_budgets.xml:0
+msgid "Create"
+msgstr "إنشاء "
+
+#. module: odex30_account_reports
+#: model:ir.actions.server,name:odex30_account_reports.action_create_composite_report_list
+msgid "Create Composite Report"
+msgstr "إنشاء تقرير مُركّب "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_multicurrency_revaluation_wizard
+msgid "Create Entry"
+msgstr "إنشاء قيد "
+
+#. module: odex30_account_reports
+#: model:ir.actions.server,name:odex30_account_reports.action_create_report_menu
+msgid "Create Menu Item"
+msgstr "إنشاء عنصر قائمة "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__create_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__create_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__create_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__create_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_file_download_error_wizard__create_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group__create_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__create_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__create_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard__create_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__create_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__create_uid
+msgid "Created by"
+msgstr "أنشئ بواسطة"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__create_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__create_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__create_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__create_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_file_download_error_wizard__create_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group__create_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__create_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__create_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard__create_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__create_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__create_date
+msgid "Created on"
+msgstr "أنشئ في"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0
+#: model:account.report.column,name:odex30_account_reports.general_ledger_report_credit
+#: model:account.report.column,name:odex30_account_reports.journal_report_credit
+#: model:account.report.column,name:odex30_account_reports.partner_ledger_report_credit
+#: model:account.report.column,name:odex30_account_reports.trial_balance_report_credit
+msgid "Credit"
+msgstr "الدائن"
+
+#. module: odex30_account_reports
+#: model:account.report.column,name:odex30_account_reports.aged_payable_report_currency
+#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_currency
+#: model:account.report.column,name:odex30_account_reports.bank_reconciliation_report_currency
+#: model:account.report.column,name:odex30_account_reports.general_ledger_report_amount_currency
+msgid "Currency"
+msgstr "العملة"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Currency Code"
+msgstr "كود العملة "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_multicurrency_revaluation_report.py:0
+msgid "Currency Rates (%s)"
+msgstr "أسعار صرف العملة (%s)"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filters
+msgid "Currency:"
+msgstr "العملة: "
+
+#. module: odex30_account_reports
+#: model:account.report.column,name:odex30_account_reports.deferred_expense_current
+#: model:account.report.column,name:odex30_account_reports.deferred_revenue_current
+msgid "Current"
+msgstr "الحالي "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_current_assets0
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_current_assets_view0
+msgid "Current Assets"
+msgstr "الأصول المتداولة"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_current_liabilities0
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_current_liabilities1
+msgid "Current Liabilities"
+msgstr "الالتزامات الجارية "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_retained_earnings_line_1
+msgid "Current Year Retained Earnings"
+msgstr "الأرباح المحتجزة للسنة الجارية "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_current_year_earnings0
+msgid "Current Year Unallocated Earnings"
+msgstr "الأرباح غير المخصصة للسنة الجارية "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_ca_to_l0
+msgid "Current assets to liabilities"
+msgstr "نسبة الأصول المتداولة إلى الالتزامات"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_date.xml:0
+msgid "Custom Dates"
+msgstr "التواريخ المخصصة "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report__custom_handler_model_id
+msgid "Custom Handler Model"
+msgstr "نموذج للمعالج المخصص "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report__custom_handler_model_name
+msgid "Custom Handler Model Name"
+msgstr "اسم نموذج المعالج المخصص "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/bank_reconciliation_report.py:0
+msgid ""
+"Custom engine _report_custom_engine_last_statement_balance_amount does not "
+"support groupby"
+msgstr ""
+"المحرك المخصص _report_custom_engine_last_statement_balance_amount لا يدعم "
+"خاصية groupby "
+
+#. module: odex30_account_reports
+#: model:account.report,name:odex30_account_reports.customer_statement_report
+#: model:ir.actions.client,name:odex30_account_reports.action_account_report_customer_statement
+#: model:mail.template,name:odex30_account_reports.email_template_customer_statement
+msgid "Customer Statement"
+msgstr "كشف حساب العميل "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_customer_statement_report_handler
+msgid "Customer Statement Custom Handler"
+msgstr "المعالج المخصص لكشف حساب العميل "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/line_name/popover/annotations_popover.xml:0
+#: model:account.report.column,name:odex30_account_reports.bank_reconciliation_report_date
+#: model:account.report.column,name:odex30_account_reports.general_ledger_report_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__date
+msgid "Date"
+msgstr "التاريخ"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "Date cannot be empty"
+msgstr "لا يمكن ترك خانة التاريخ فارغة"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,help:odex30_account_reports.field_account_report_annotation__date
+msgid "Date considered as annotated by the annotation."
+msgstr "يُعتَبَر التاريخ مشروحاً بواسطة الشرح. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filter_aging.xml:0
+msgid "Days"
+msgstr "أيام "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0
+#: model:account.report.column,name:odex30_account_reports.general_ledger_report_debit
+#: model:account.report.column,name:odex30_account_reports.journal_report_debit
+#: model:account.report.column,name:odex30_account_reports.partner_ledger_report_debit
+#: model:account.report.column,name:odex30_account_reports.trial_balance_report_debit
+msgid "Debit"
+msgstr "المدين"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary
+msgid "Deductible"
+msgstr "قابل للاستقطاع "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/deferred_reports/warnings.xml:0
+msgid "Deferrals have not yet been completely generated for this period."
+msgstr "لم يتم بعد إنشاء التأجيلات بالكامل لهذه الفترة. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/deferred_reports/warnings.xml:0
+msgid "Deferrals have not yet been generated for this period."
+msgstr "لم يتم بعد إنشاء التأجيلات لهذه الفترة. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Deferred Entries"
+msgstr "القيم المؤجلة "
+
+#. module: odex30_account_reports
+#: model:ir.actions.client,name:odex30_account_reports.action_account_report_deferred_expense
+#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_deferred_expense
+msgid "Deferred Expense"
+msgstr "النفقات المؤجلة "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_deferred_expense_report_handler
+msgid "Deferred Expense Custom Handler"
+msgstr "أداة مخصصة لمعالجة النفقات المؤجلة "
+
+#. module: odex30_account_reports
+#: model:account.report,name:odex30_account_reports.deferred_expense_report
+msgid "Deferred Expense Report"
+msgstr "تقرير النفقات المؤجلة "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_deferred_report_handler
+msgid "Deferred Expense Report Custom Handler"
+msgstr "أداة مخصصة لمعالجة تقرير النفقات المؤجلة "
+
+#. module: odex30_account_reports
+#: model:ir.actions.client,name:odex30_account_reports.action_account_report_deferred_revenue
+#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_deferred_revenue
+msgid "Deferred Revenue"
+msgstr "الإيرادات المؤجلة "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_deferred_revenue_report_handler
+msgid "Deferred Revenue Custom Handler"
+msgstr "أداة مخصصة لمعالجة الإيرادات المؤجلة "
+
+#. module: odex30_account_reports
+#: model:account.report,name:odex30_account_reports.deferred_revenue_report
+msgid "Deferred Revenue Report"
+msgstr "تقرير الإيرادات المؤجلة "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_expression_form
+msgid "Definition"
+msgstr "تعريف"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__account_tax_periodicity
+msgid "Delay units"
+msgstr "وحدات التأخير"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_move.py:0
+msgid "Depending moves"
+msgstr "الحركات المعتمدة على غيرها "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "Descending"
+msgstr "تنازلي "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0
+msgid "Difference from rounding taxes"
+msgstr "الفرق من تقريب الضرائب "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_line__display_custom_groupby_warning
+msgid "Display Custom Groupby Warning"
+msgstr "عرض تحذير \"التجميع حسب\" المخصص "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__display_mail_composer
+msgid "Display Mail Composer"
+msgstr "عرض أداة إنشاء البريد الإلكتروني "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__display_name
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__display_name
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__display_name
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__display_name
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_file_download_error_wizard__display_name
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group__display_name
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__display_name
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__display_name
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard__display_name
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__display_name
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__display_name
+msgid "Display Name"
+msgstr "اسم العرض "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+msgid "Document"
+msgstr "المستند "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard__doc_name
+msgid "Documents Name"
+msgstr "اسم المستندات "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__domain
+msgid "Domain"
+msgstr "النطاق"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_fiscal_position.xml:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "Domestic"
+msgstr "محلي"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__checkbox_download
+msgid "Download"
+msgstr "تنزيل "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_file_download_error_wizard_form
+msgid "Download Anyway"
+msgstr "التنزيل بأي حال "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form
+msgid "Download the Data Inalterability Check Report"
+msgstr "تحميل تقرير التحقق من بيانات عدم قابلية التغيير "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0
+msgid "Draft Entries"
+msgstr "القيود في حالة المسودة "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_general_ledger.py:0
+msgid "Draft Entry"
+msgstr "مسودة قيد "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_followup_report.py:0
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary
+msgid "Due"
+msgstr "مستحق"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filter_aging.xml:0
+#: model:account.report.column,name:odex30_account_reports.customer_statement_report_date_maturity
+#: model:account.report.column,name:odex30_account_reports.followup_report_date_maturity
+#: model:account.report.column,name:odex30_account_reports.partner_ledger_report_date_maturity
+msgid "Due Date"
+msgstr "موعد إجراء المكالمة "
+
+#. module: odex30_account_reports
+#: model:ir.actions.client,name:odex30_account_reports.action_account_report_sales
+#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_sales
+msgid "EC Sales List"
+msgstr "قائمة مبيعات EC"
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_ec_sales_report_handler
+msgid "EC Sales Report Custom Handler"
+msgstr "المعالج المخصص لتقارير مبيعات العمولة الأوروبية "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_sales_report.py:0
+msgid "EC tax on non EC countries"
+msgstr "ضريبة الاتحاد الأوروبي للدول خارج الاتحاد الأوروبي "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_sales_report.py:0
+msgid "EC tax on same country"
+msgstr "ضريبة الاتحاد الأوروبي في نفس الدولة "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_equity0
+msgid "EQUITY"
+msgstr "رأس المال "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"Editing a manual report line is not allowed in multivat setup when "
+"displaying data from all fiscal positions."
+msgstr ""
+"لا يُسمح بتحرير تقرير يدوي في بيئة متعددة الضرائب عند عرض البيانات من كافة "
+"الأوضاع المالية. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"Editing a manual report line is not allowed when multiple companies are "
+"selected."
+msgstr "لا يُسمح بتحرير تقرير يدوي عندما يتم تحديد عدة شركات. "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__checkbox_send_mail
+msgid "Email"
+msgstr "البريد الإلكتروني"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__mail_template_id
+msgid "Email template"
+msgstr "قالب البريد الإلكتروني "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__enable_download
+msgid "Enable Download"
+msgstr "تمكين التنزيل "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Enable Sections"
+msgstr "تمكين الأجزاء "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__enable_send_mail
+msgid "Enable Send Mail"
+msgstr "تمكين إرسال البريد الإلكتروني "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_variant.xml:0
+msgid "Enable more ..."
+msgstr "تمكين المزيد... "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_trial_balance_report.py:0
+msgid "End Balance"
+msgstr "الرصيد النهائي "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "End of Month"
+msgstr "نهاية الشهر "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "End of Quarter"
+msgstr "نهاية ربع السنة "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "End of Year"
+msgstr "نهاية العام "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/line/popover/debug_popover.xml:0
+msgid "Engine"
+msgstr "المحرك "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_sales_report.py:0
+msgid "Entries with partners with no VAT"
+msgstr "القيود التي بها شركاء بلا ضريبة القيمة المضافة "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Error message"
+msgstr "رسالة خطأ"
+
+#. module: odex30_account_reports
+#: model:mail.activity.type,summary:odex30_account_reports.mail_activity_type_tax_report_error
+msgid "Error sending Tax Report"
+msgstr ""
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/filters/filter_exchange_rate.xml:0
+msgid "Exchange Rates"
+msgstr "أسعار الصرف "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_move_line__exclude_bank_lines
+msgid "Exclude Bank Lines"
+msgstr "استثناء بنود البنك "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_journal_report_audit_move_line_search
+msgid "Exclude Bank lines"
+msgstr "استثناء بنود البنك "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_account__exclude_provision_currency_ids
+msgid "Exclude Provision Currency"
+msgstr "استثناء عملة الحكم "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.multicurrency_revaluation_excluded
+msgid "Excluded Accounts"
+msgstr "الحسابات المستثناة "
+
+#. module: odex30_account_reports
+#: model:account.report,name:odex30_account_reports.executive_summary
+#: model:ir.actions.client,name:odex30_account_reports.action_account_report_exec_summary
+#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_exec_summary
+msgid "Executive Summary"
+msgstr "الملخص التنفيذي"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__expense_provision_account_id
+msgid "Expense Account"
+msgstr "حساب النفقات "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__account_revaluation_expense_provision_account_id
+msgid "Expense Provision Account"
+msgstr "حساب أحكام النفقات "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0
+msgid "Expense Provision for %s"
+msgstr "حكم النفقات لـ %s"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_expenses0
+msgid "Expenses"
+msgstr "النفقات "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_report_export_wizard
+msgid "Export"
+msgstr "تصدير"
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_reports_export_wizard_format
+msgid "Export format for accounting's reports"
+msgstr "صيغة التصدير للتقارير المحاسبية "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard__export_format_ids
+msgid "Export to"
+msgstr "التصدير إلى "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_reports_export_wizard
+msgid "Export wizard for accounting's reports"
+msgstr "مُعالِج التصدير للتقارير المحاسبية "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_expression_form
+msgid "Expression"
+msgstr "تعبير "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"Expression labelled '%(label)s' of line '%(line)s' is being overwritten when "
+"computing the current report. Make sure the cross-report aggregations of "
+"this report only reference terms belonging to other reports."
+msgstr ""
+"تتم الكتابة فوق التعبير الذي عنوانه \"%(label)s\" من البند \"%(line)s\" عند "
+"احتساب التقرير الحالي. تأكد من أن مجموعات التقارير التبادلية لهذا التقرير "
+"تشير فقط إلى الشروط التي تنتمي إلى تقارير أخرى. "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__field_name
+msgid "Field"
+msgstr "حقل"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"Field %s does not exist on account.move.line, and is not supported by this "
+"report's custom handler."
+msgstr ""
+"الحقل %s غير موجود في account.move.line، وهو غير مدعوم في أداة معالجة "
+"التقارير هذه. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Field %s does not exist on account.move.line."
+msgstr "الحقل %s غير موجود في account.move.line. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Field %s of account.move.line cannot be used in a groupby expression."
+msgstr "لا يمكن استخدام حقل%s account.move.line في تعبير groupby. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"Field %s of account.move.line is not searchable and can therefore not be "
+"used in a groupby expression."
+msgstr ""
+"لا يمكن البحث في حقل %s لـ account.move.line، وبالتالي لا يمكن استخدامه في "
+"تعبير groupby. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"Field 'Custom Handler Model' can only reference records inheriting from [%s]."
+msgstr ""
+"الحقل 'نموذج المعالج المخصص' يمكنه فقط الإشارة إلى السجلات التي ترث من [%s]. "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_file_download_error_wizard__file_content
+msgid "File Content"
+msgstr "محتوى الملف "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_file_download_error_wizard_form
+msgid "File Download Errors"
+msgstr "أخطاء تنزيل الملف "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_file_download_error_wizard__file_name
+msgid "File Name"
+msgstr "اسم الملف"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form
+msgid "Filters"
+msgstr "عوامل التصفية "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_aml_ir_filters.xml:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filters
+msgid "Filters:"
+msgstr "عوامل التصفية: "
+
+#. module: odex30_account_reports
+#: model:ir.actions.act_window,name:odex30_account_reports.action_account_report_budget_tree
+#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_budget_tree
+msgid "Financial Budgets"
+msgstr "الميزانيات المالية "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_fiscal_position
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__fiscal_position_id
+msgid "Fiscal Position"
+msgstr "الوضع المالي "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_fiscal_position.xml:0
+msgid "Fiscal Position:"
+msgstr "الوضع المالي: "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__fpos_synced
+msgid "Fiscal Positions Synchronised"
+msgstr "الأوضاع المالية المتزامنة "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_tax_unit_form
+msgid ""
+"Fiscal Positions should apply to all companies of the tax unit. You may want "
+"to"
+msgstr ""
+"يجب أن تنطبق الأوضاع المالية على كافة الشركات لوحدة الضريبة. ربما عليك "
+
+#. module: odex30_account_reports
+#: model:account.report,name:odex30_account_reports.followup_report
+msgid "Follow-Up Report"
+msgstr "تقرير المتابعة "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_followup_report_handler
+msgid "Follow-Up Report Custom Handler"
+msgstr "المعالج المخصص لتقرير المتابعة "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0
+msgid "Foreign currencies adjustment entry as of %s"
+msgstr "قيد تعديل العملات الأجنبية اعتباراً من %s"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/line/popover/debug_popover.xml:0
+msgid "Formula"
+msgstr "الصيغة"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"From %(date_from)s\n"
+"to %(date_to)s"
+msgstr ""
+"من %(date_from)s\n"
+"إلى %(date_to)s "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__fun_param
+msgid "Function Parameter"
+msgstr "معايير الوظيفة "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__fun_to_call
+msgid "Function to Call"
+msgstr "الوظيفة لاستدعائها "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+#: code:addons/odex30_account_reports/models/account_trial_balance_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0
+#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/line_name.xml:0
+#: model:account.report,name:odex30_account_reports.general_ledger_report
+#: model:ir.actions.client,name:odex30_account_reports.action_account_report_general_ledger
+#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_general_ledger
+msgid "General Ledger"
+msgstr "دفتر الأستاذ العام"
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_general_ledger_report_handler
+msgid "General Ledger Custom Handler"
+msgstr "المعالج المخصص لدفتر الأستاذ العام "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0
+msgid "Generate entry"
+msgstr "إنشاء قيد "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/wizard/report_export_wizard.py:0
+msgid "Generated Documents"
+msgstr "إنشاء المستندات "
+
+#. module: odex30_account_reports
+#: model:account.report,name:odex30_account_reports.generic_ec_sales_report
+msgid "Generic EC Sales List"
+msgstr "قائمة مبيعات EC عامة "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_generic_tax_report_handler
+msgid "Generic Tax Report Custom Handler"
+msgstr "معالج مخصص للتقرير الضريبي العام "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_generic_tax_report_handler_account_tax
+msgid "Generic Tax Report Custom Handler (Account -> Tax)"
+msgstr "معالج مخصص للتقرير الضريبي العام (الحساب -> الضريبة) "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_generic_tax_report_handler_tax_account
+msgid "Generic Tax Report Custom Handler (Tax -> Account)"
+msgstr "معالج مخصص للتقرير الضريبي العام (الضريبة -> الحساب) "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.journal_report_pdf_export_main
+msgid "Global Tax Summary"
+msgstr "الملخص الشامل للضريبة "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_sales_report.py:0
+msgid "Goods"
+msgstr "البضائع "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary
+msgid "Grid"
+msgstr "الشبكة"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_gross_profit0
+msgid "Gross Profit"
+msgstr "إجمالي الربح"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_gross_profit0
+msgid "Gross profit"
+msgstr "إجمالي الربح"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_gpmargin0
+msgid "Gross profit margin (gross profit / operating income)"
+msgstr "إجمالي هامش الربح (إجمالي الربح / الدخل التشغيلي)"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_line_form
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_search
+msgid "Group By"
+msgstr "تجميع حسب"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_horizontal_group_form
+msgid "Group Name"
+msgstr "اسم المجموعة"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/deferred_reports/groupby.xml:0
+msgid "Group by"
+msgstr "التجميع حسب "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0
+msgid "Grouped Deferral Entry of %s"
+msgstr "القيد المؤجل المجمع لـ %s"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/partner_ledger/filter_extra_options.xml:0
+msgid "Hide Account"
+msgstr "إخفاء الحساب "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/partner_ledger/filter_extra_options.xml:0
+msgid "Hide Debit/Credit"
+msgstr "إخفاء الخصم / الائتمان "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0
+msgid "Hide lines at 0"
+msgstr "إخفاء البنود في 0 "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0
+msgid "Hierarchy and Subtotals"
+msgstr "التسلسل الهرمي والمجاميع الفرعية"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__horizontal_group_id
+msgid "Horizontal Group"
+msgstr "المجموعة الأفقية "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_horizontal_groups.xml:0
+msgid "Horizontal Group:"
+msgstr "المجموعة الأفقية: "
+
+#. module: odex30_account_reports
+#: model:ir.actions.act_window,name:odex30_account_reports.action_account_report_horizontal_groups
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report__horizontal_group_ids
+#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_horizontal_groups
+msgid "Horizontal Groups"
+msgstr "المجموعات الأفقية "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_report_horizontal_group
+msgid "Horizontal group for reports"
+msgstr "المجموعة الأفقية للتقارير "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_report_horizontal_group_rule
+msgid "Horizontal group rule for reports"
+msgstr "قاعدة المجموعة الأفقية للتقارير "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filters
+msgid "Horizontal:"
+msgstr "أفقي: "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form
+msgid "How often tax returns have to be made"
+msgstr "مدى تواتر الإقرارات الضريبية "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__id
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__id
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__id
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__id
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_file_download_error_wizard__id
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group__id
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__id
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__id
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard__id
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__id
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__id
+msgid "ID"
+msgstr "المُعرف"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary
+msgid "Impact On Grid"
+msgstr "التأثير على الشبكة "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary
+msgid "Impacted Tax Grids"
+msgstr "شبكات الضرائب المتأثرة "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "In %s"
+msgstr "في %s "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_search
+msgid "Inactive"
+msgstr "غير نشط "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/journal_report/filter_extra_options.xml:0
+msgid "Include Payments"
+msgstr "تضمين المدفوعات "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filter_extra_options_template
+msgid "Including Analytic Simulations"
+msgstr "شاملة عمليات المحاكات التحليلية "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.no_statement_unreconciled_payments
+#: model:account.report.line,name:odex30_account_reports.unreconciled_last_statement_payments
+msgid "Including Unreconciled Payments"
+msgstr "شاملة المدفوعات غير المسواة "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.no_statement_unreconciled_receipt
+#: model:account.report.line,name:odex30_account_reports.unreconciled_last_statement_receipts
+msgid "Including Unreconciled Receipts"
+msgstr "شاملة الإيصالات غير المسواة "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__income_provision_account_id
+msgid "Income Account"
+msgstr "حساب الدخل"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__account_revaluation_income_provision_account_id
+msgid "Income Provision Account"
+msgstr "حساب أحكام الدخل "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0
+msgid "Income Provision for %s"
+msgstr "حكم الدخل لـ %s "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/bank_reconciliation_report.py:0
+msgid "Inconsistent Statements"
+msgstr "كشوفات الحساب غير المتسقة "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"Inconsistent data: more than one external value at the same date for a "
+"'most_recent' external line."
+msgstr ""
+"Inconsistent data: more than one external value at the same date for a "
+"'most_recent' external line."
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"Inconsistent report_id in options dictionary. Options says %"
+"(options_report)s; report is %(report)s."
+msgstr ""
+"report_id غير متسق في دليل الخيارات. يقول الخيار %(options_report)s؛ التقرير "
+"%(report)s. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+#: code:addons/odex30_account_reports/models/account_trial_balance_report.py:0
+msgid "Initial Balance"
+msgstr "الرصيد الافتتاحي"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0
+msgid "Integer Rounding"
+msgstr "تقريب العدد الصحيح "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filters.js:0
+msgid "Intervals cannot be smaller than 1"
+msgstr "لا يمكن أن تكون الفترات الزمنية أقل من 1 "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0
+msgid "Intra-community taxes are applied on"
+msgstr "الضرائب بين المجتمعات مطبقة على "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"Invalid domain formula in expression \"%(expression)s\" of line \"%"
+"(line)s\": %(formula)s"
+msgstr ""
+"صيغة النطاق غير صالحة في التعبير \"%(expression)s\" في البند \"%(line)s\": %"
+"(formula)s "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Invalid method “%s”"
+msgstr "الطريقة غير صحيحة \"%s\" "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"Invalid subformula in expression \"%(expression)s\" of line \"%(line)s\": %"
+"(subformula)s"
+msgstr ""
+"الصيغة الفرعية غير صالحة في التعبير \"%(expression)s\" في البند \"%"
+"(line)s\": %(subformula)s "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Invalid token '%(token)s' in account_codes formula '%(formula)s'"
+msgstr "الرمز غير صالح '%(token)s' في صيغة account_codes '%(formula)s' "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filter_aging.xml:0
+#: model:account.report.column,name:odex30_account_reports.aged_payable_report_invoice_date
+#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_invoice_date
+#: model:account.report.column,name:odex30_account_reports.customer_statement_report_invoicing_date
+#: model:account.report.column,name:odex30_account_reports.followup_report_invoicing_date
+#: model:account.report.column,name:odex30_account_reports.partner_ledger_report_invoicing_date
+msgid "Invoice Date"
+msgstr "تاريخ الفاتورة"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_journal_report_audit_move_line_search
+msgid "Invoice lines"
+msgstr "بنود الفاتورة"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report__is_account_coverage_report_available
+msgid "Is Account Coverage Report Available"
+msgstr "تقرير تغطية الحسابات متاح "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_move.py:0
+msgid "It seems there is some depending closing move to be posted"
+msgstr "يبدو أنه توجد حركة إغلاق معتمدة على غيرها يجب أن يتم ترحيلها. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "It's not possible to select a budget with the horizontal group feature."
+msgstr "لا يمكن تحديد ميزانية باستخدام خاصية المجموعة الأفقية. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "It's not possible to select a horizontal group with the budget feature."
+msgstr "لا يمكن تحديد مجموعة أفقية باستخدام خاصية الميزانية. "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__item_ids
+msgid "Items"
+msgstr "العناصر "
+
+#. module: odex30_account_reports
+#: model:account.report.column,name:odex30_account_reports.partner_ledger_report_journal_code
+#: model:ir.model,name:odex30_account_reports.model_account_journal
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_financial_year_op__account_tax_periodicity_journal_id
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__journal_id
+#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__account_tax_periodicity_journal_id
+#: model:ir.model.fields,field_description:odex30_account_reports.field_res_config_settings__account_tax_periodicity_journal_id
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.setup_financial_year_opening_form
+msgid "Journal"
+msgstr "دفتر اليومية"
+
+#. module: odex30_account_reports
+#: model:ir.actions.client,name:odex30_account_reports.action_account_report_ja
+#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_ja
+msgid "Journal Audit"
+msgstr "تدقيق دفتر اليومية "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_move
+msgid "Journal Entry"
+msgstr "قيد اليومية"
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_move_line
+msgid "Journal Item"
+msgstr "عنصر اليومية"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0
+#: code:addons/odex30_account_reports/models/account_report.py:0
+#: code:addons/odex30_account_reports/models/account_trial_balance_report.py:0
+#: code:addons/odex30_account_reports/models/bank_reconciliation_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/general_ledger/line_name.xml:0
+#: code:addons/odex30_account_reports/static/src/components/partner_ledger/line_name.xml:0
+msgid "Journal Items"
+msgstr "عناصر اليومية"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+msgid "Journal Items for Tax Audit"
+msgstr "عناصر اليومية للتدقيق الضريبي"
+
+#. module: odex30_account_reports
+#: model:account.report,name:odex30_account_reports.journal_report
+msgid "Journal Report"
+msgstr "تقرير اليومية "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_journal_report_handler
+msgid "Journal Report Custom Handler"
+msgstr "المعالج المخصص لتقرير اليومية "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0
+msgid "Journal items with archived tax tags"
+msgstr "عناصر دفتر اليومية مع علامات تصنيف مؤرشفة للضريبة "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Journals"
+msgstr "دفاتر اليومية"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filters
+msgid "Journals:"
+msgstr "دفاتر اليومية:"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_liabilities_view0
+msgid "LIABILITIES"
+msgstr "التزامات"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_liabilities_and_equity_view0
+msgid "LIABILITIES + EQUITY"
+msgstr "الالتزامات + رأس المال "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/static/src/components/account_report/line/popover/debug_popover.xml:0
+#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0
+#: model:account.report.column,name:odex30_account_reports.bank_reconciliation_report_label
+msgid "Label"
+msgstr "بطاقة عنوان"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__mail_lang
+msgid "Lang"
+msgstr "اللغة "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_reports_journal_dashboard_kanban_view
+msgid "Last Statement balance + Transactions since statement"
+msgstr "رصيد آخر كشف حساب + المعاملات منذ إصدار كشف الحساب "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__write_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__write_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__write_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__write_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_file_download_error_wizard__write_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group__write_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__write_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__write_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard__write_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__write_uid
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__write_uid
+msgid "Last Updated by"
+msgstr "آخر تحديث بواسطة"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__write_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__write_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__write_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__write_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_file_download_error_wizard__write_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group__write_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__write_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__write_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard__write_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__write_date
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__write_date
+msgid "Last Updated on"
+msgstr "آخر تحديث في"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.last_statement_balance
+msgid "Last statement balance"
+msgstr "رصيد آخر كشف حساب "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0
+msgid "Later"
+msgstr "لاحقاً"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_cost_sales0
+msgid "Less Costs of Revenue"
+msgstr "تكاليف إيرادات أقل "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_expense0
+msgid "Less Operating Expenses"
+msgstr "نفقات تشغيلية أقل "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_depreciation0
+msgid "Less Other Expenses"
+msgstr "نفقات أخرى أقل "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__line_id
+msgid "Line"
+msgstr "البند "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"Line '%(child)s' is configured to appear before its parent '%(parent)s'. "
+"This is not allowed."
+msgstr ""
+"تم تهيئة البند '%(child)s' ليظهر قبل البند الأصلي '%(parent)s'. لا يُسمح "
+"بذلك. "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form
+msgid "Lines"
+msgstr "البنود"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Load more..."
+msgstr "تحميل المزيد... "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__mail_attachments_widget
+msgid "Mail Attachments Widget"
+msgstr "أداة مرفقات البريد الإلكتروني "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__main_company_id
+msgid "Main Company"
+msgstr "الشركة الرئيسية "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,help:odex30_account_reports.field_account_tax_unit__main_company_id
+msgid ""
+"Main company of this unit; the one actually reporting and paying the taxes."
+msgstr ""
+"الشركة الرئيسية لهذه الوحدة؛ الشركة التي تقوم بإصدار التقارير ودفع الضرائب. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_multicurrency_revaluation_report.py:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_multicurrency_revaluation_wizard
+msgid "Make Adjustment Entry"
+msgstr "إنشاء قيد تعديل "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_report_file_download_error_wizard
+msgid "Manage the file generation errors from report exports."
+msgstr "قم بإدارة أخطاء إنشاء الملفات من عمليات تصدير التقارير. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Manual value"
+msgstr "القيمة اليدوية "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Manual values"
+msgstr "القيم اليدوية "
+
+#. module: odex30_account_reports
+#: model:account.report.column,name:odex30_account_reports.partner_ledger_report_matching_number
+msgid "Matching"
+msgstr "مطابقة"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,help:odex30_account_reports.field_account_tax_unit__company_ids
+msgid "Members of this unit"
+msgstr "أعضاء هذه الوحدة "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Method '%(method_name)s' must start with the '%(prefix)s' prefix."
+msgstr "يجب أن تبدأ الطريقة '%(method_name)s' بالبادئة '%(prefix)s'. "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.misc_operations
+msgid "Misc. operations"
+msgstr "العمليات المتنوعة "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__mode
+msgid "Mode"
+msgstr "الوضع"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__res_model_name
+msgid "Model"
+msgstr "النموذج "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "Month"
+msgstr "الشهر"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_budget_form
+msgid "Months"
+msgstr "شهور"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filters
+msgid "Multi-Ledger:"
+msgstr "دفتر الأستاذ المتعدد "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Multi-ledger"
+msgstr "دفتر الأستاذ المتعدد "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_multicurrency_revaluation_report_handler
+msgid "Multicurrency Revaluation Report Custom Handler"
+msgstr "المعالج المخصص لتقرير إعادة التقييم متعدد العملات "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_multicurrency_revaluation_wizard
+msgid "Multicurrency Revaluation Wizard"
+msgstr "مُعالج إعادة التقييم متعدد العملات "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields.selection,name:odex30_account_reports.selection__account_report_send__mode__multi
+msgid "Multiple Recipients"
+msgstr "عدة مستلمين "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/res_company.py:0
+msgid ""
+"Multiple draft tax closing entries exist for fiscal position %(position)s "
+"after %(period_start)s. There should be at most one. \n"
+" %(closing_entries)s"
+msgstr ""
+"توجد عدة قيود إقفال الضريبة بحالة المسودة في الوضع المالي %(position)s بعد %"
+"(period_start)s. يجب أن يكون هناك واحد فقط كحد أقصى. \n"
+" %(closing_entries)s "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/res_company.py:0
+msgid ""
+"Multiple draft tax closing entries exist for your domestic region after %"
+"(period_start)s. There should be at most one. \n"
+" %(closing_entries)s"
+msgstr ""
+"توجد عدة قيود إقفال للضريبة بحالة المسودة في منطقتك بعد%(period_start)s. يجب "
+"أن يكون هناك واحد فقط كحد أقصى. \n"
+" %(closing_entries)s "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_general_ledger.py:0
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0
+#: model:account.report.line,name:odex30_account_reports.journal_report_line
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__name
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group__name
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__name
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__name
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.duplicated_vat_partner_tree_view
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_tax_unit_form
+msgid "Name"
+msgstr "الاسم"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,help:odex30_account_reports.field_account_reports_export_wizard__doc_name
+msgid "Name to give to the generated documents."
+msgstr "الاسم لمنحه للمستندات المنشأة. "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_profit0
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_net_profit0
+msgid "Net Profit"
+msgstr "صافي الربح"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_net_assets0
+msgid "Net assets"
+msgstr "صافي الأصول"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0
+msgid "Net increase in cash and cash equivalents"
+msgstr "صافي الزيادة في النقد وما يعادل النقد "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_npmargin0
+msgid "Net profit margin (net profit / revenue)"
+msgstr "صافي هامش الربح (صافي الربح / الإيرادات) "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_reports_journal_dashboard_kanban_view
+msgid "Never miss a tax deadline."
+msgstr "لا تفوت أي موعد نهائي بعد الآن. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/edit_popover.xml:0
+msgid "No"
+msgstr "لا"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0
+msgid "No Comparison"
+msgstr "بلا مقارنة"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "No Journal"
+msgstr "لا يوجد دفتر يومية "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "No VAT number associated with your company. Please define one."
+msgstr "لا يوجد رقم ضريبة مرتبط بشركتك. الرجاء تحديد واحد. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0
+msgid "No adjustment needed"
+msgstr "لا حاجة لإجراء أي تعديل "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/account_report.xml:0
+msgid "No data to display !"
+msgstr "لا توجد بيانات لعرضها! "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/chart_template.py:0
+msgid "No default miscellaneous journal could be found for the active company"
+msgstr "لم يتم العثور على دفتر يومية افتراضي للمتفرقات للشركة الفعالة "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0
+msgid "No entry to generate."
+msgstr "لا يوجد قيد لإنشائه. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0
+msgid "No provision needed was found."
+msgstr "لم يتم العثور على أي أحكام مطلوبة. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "Non Trade Partners"
+msgstr "الشركاء غير التجاريين. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Non Trade Payable"
+msgstr "حساب الدائنين غير التجاري "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Non Trade Receivable"
+msgstr "حساب المدينين غير التجاري "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary
+msgid "Non-Deductible"
+msgstr "غير قابلة للاقتطاع "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_horizontal_groups.xml:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+#: code:addons/odex30_account_reports/static/src/components/sales_report/filters/filters.js:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form
+msgid "None"
+msgstr "لا شيء"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0
+msgid "Not Started"
+msgstr "لم يبدأ بعد "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "Number of periods cannot be smaller than 1"
+msgstr "لا يمكن أن يكون عدد الفترات أقل من 1 "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_off_sheet
+msgid "OFF BALANCE SHEET ACCOUNTS"
+msgstr "الحسابات خارج الميزانية العمومية "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filters.js:0
+msgid "Odoo Warning"
+msgstr "تحذير من أودو"
+
+#. module: odex30_account_reports
+#: model:account.report.column,name:odex30_account_reports.aged_payable_report_period5
+#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_period5
+msgid "Older"
+msgstr "أقدم"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/wizard/report_export_wizard.py:0
+msgid "One of the formats chosen can not be exported in the DMS"
+msgstr "إحدى الصيغ التي اخترتها لا يمكن تصديرها في برنامج إدارة المستندات "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_move.py:0
+msgid "Only Billing Administrators are allowed to change lock dates!"
+msgstr "مديرو الفوترة وحدهم المصرح لهم بتغيير تواريخ الإقفال! "
+
+#. module: odex30_account_reports
+#: model:ir.actions.server,name:odex30_account_reports.action_account_reports_customer_statements
+msgid "Open Customer Statements"
+msgstr "فتح كشوفات العملاء "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_financial_year_op
+msgid "Opening Balance of Financial Year"
+msgstr "الرصيد الافتتاحي للسنة المالية "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_operating_income0
+msgid "Operating Income (or Loss)"
+msgstr "الدخل التشغيلي (أو الخسائر التشغيلية) "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_expression_form
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form
+msgid "Options"
+msgstr "الخيارات"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filter_extra_options_template
+msgid "Options:"
+msgstr "الخيارات: "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.outstanding
+msgid "Outstanding Receipts/Payments"
+msgstr "المدفوعات/الإيصالات المستحقة "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_followup_report.py:0
+msgid "Overdue"
+msgstr "متأخر"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "PDF"
+msgstr "PDF"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard__report_id
+msgid "Parent Report Id"
+msgstr "معرف التقرير الأساسي "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__export_wizard_id
+msgid "Parent Wizard"
+msgstr "المعالج الأساسي "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/line_name/line_name.xml:0
+#: code:addons/odex30_account_reports/static/src/components/partner_ledger/line_name.xml:0
+#: model:account.report.column,name:odex30_account_reports.general_ledger_report_partner_name
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__partner_ids
+msgid "Partner"
+msgstr "الشريك"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Partner Categories"
+msgstr "فئات الشركاء "
+
+#. module: odex30_account_reports
+#: model:account.report,name:odex30_account_reports.partner_ledger_report
+#: model:ir.actions.client,name:odex30_account_reports.action_account_report_partner_ledger
+#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_partner_ledger
+msgid "Partner Ledger"
+msgstr "دفتر الأستاذ العام للشركاء"
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_partner_ledger_report_handler
+msgid "Partner Ledger Custom Handler"
+msgstr "المعالج المخصص لدفتر الأستاذ العام الخاص بالشريك "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/line_name/line_name.xml:0
+#: code:addons/odex30_account_reports/static/src/components/partner_ledger/line_name.xml:0
+msgid "Partner is bad"
+msgstr "الشريك سيء "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/line_name/line_name.xml:0
+#: code:addons/odex30_account_reports/static/src/components/partner_ledger/line_name.xml:0
+msgid "Partner is good"
+msgstr "الشريك جيد "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/wizard/account_report_send.py:0
+msgid "Partner(s) should have an email address."
+msgstr "يجب أن يكون للوكلاء عنوان بريد إلكتروني. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_partner.xml:0
+msgid "Partners"
+msgstr "الشركاء"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filters
+msgid "Partners Categories:"
+msgstr "فئات الشركاء: "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_sales_report.py:0
+msgid "Partners with duplicated VAT numbers"
+msgstr "الشركاء الذين يملكون أرقام ضريبية مكررة "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filters
+msgid "Partners:"
+msgstr "الشركاء:"
+
+#. module: odex30_account_reports
+#: model:mail.activity.type,name:odex30_account_reports.mail_activity_type_tax_report_to_pay
+msgid "Pay Tax"
+msgstr "دفع الضريبة "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_move.py:0
+msgid "Pay tax: %s"
+msgstr "دفع الضريبة: %s "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_aged_partner_balance.py:0
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Payable"
+msgstr "الدائن"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0
+msgid "Payable tax amount"
+msgstr "مبلغ الضريبة الدائن "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_current_liabilities_payable
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_creditors0
+msgid "Payables"
+msgstr "الدائنون"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_performance0
+msgid "Performance"
+msgstr "الأداء"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "Period"
+msgstr "الفترة"
+
+#. module: odex30_account_reports
+#: model:account.report.column,name:odex30_account_reports.aged_payable_report_period1
+#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_period1
+msgid "Period 1"
+msgstr "الفترة 1 "
+
+#. module: odex30_account_reports
+#: model:account.report.column,name:odex30_account_reports.aged_payable_report_period2
+#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_period2
+msgid "Period 2"
+msgstr "الفترة 2 "
+
+#. module: odex30_account_reports
+#: model:account.report.column,name:odex30_account_reports.aged_payable_report_period3
+#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_period3
+msgid "Period 3"
+msgstr "الفترة 3 "
+
+#. module: odex30_account_reports
+#: model:account.report.column,name:odex30_account_reports.aged_payable_report_period4
+#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_period4
+msgid "Period 4"
+msgstr "الفترة 4 "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0
+msgid "Period order"
+msgstr "طلب المدة "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_res_config_settings__account_tax_periodicity
+#: model:ir.model.fields,help:odex30_account_reports.field_account_financial_year_op__account_tax_periodicity
+#: model:ir.model.fields,help:odex30_account_reports.field_res_company__account_tax_periodicity
+#: model:ir.model.fields,help:odex30_account_reports.field_res_config_settings__account_tax_periodicity
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form
+msgid "Periodicity"
+msgstr "الوتيرة "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_financial_year_op__account_tax_periodicity
+msgid "Periodicity in month"
+msgstr "الوتيرة في الشهر "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "Periods"
+msgstr "الفترات "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_analytic_groupby.xml:0
+msgid "Plans"
+msgstr "الخطط "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/budget.py:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "Please enter a valid budget name."
+msgstr "يُرجى إدخال اسم ميزانية صالح. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/filters/filters.js:0
+msgid "Please enter a valid number."
+msgstr "يُرجى إدخال رقم صالح. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/wizard/account_report_send.py:0
+msgid "Please select a mail template to send multiple statements."
+msgstr "يرجى تحديد قالب بريد لإرسال عدة كشوفات. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"Please select the main company and its branches in the company selector to "
+"proceed."
+msgstr "يرجى تحديد الشركة الرئيسية وفروعها في أداة اختيار الشركة للمتابعة. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0
+msgid "Please set the deferred accounts in the accounting settings."
+msgstr "يرجى تعيين الحسابات المؤجلة في إعدادات المحاسبة. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0
+msgid "Please set the deferred journal in the accounting settings."
+msgstr "يرجى إعداد دفتر اليومية المؤجل في إعدادات المحاسبة. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0
+msgid "Please specify the accounts necessary for the Tax Closing Entry."
+msgstr "يرجى تحديد الحسابات الضرورية للقيد الختامي للضريبة. "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_fixed_assets_view0
+msgid "Plus Fixed Assets"
+msgstr "بالإضافة الى الأصول الثابتة"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_non_current_assets_view0
+msgid "Plus Non-current Assets"
+msgstr "بالإضافة للأصول غير المتداولة"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_non_current_liabilities0
+msgid "Plus Non-current Liabilities"
+msgstr "بالإضافة للالتزامات غير الجارية "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_other_income0
+msgid "Plus Other Income"
+msgstr "بالإضافة إلى دخل آخر "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_position0
+msgid "Position"
+msgstr "المنصب الوظيفي "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/edit_popover.xml:0
+msgid "Post"
+msgstr "منشور "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "Posted Entries"
+msgstr "القيود المُرحّلة "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_prepayements0
+msgid "Prepayments"
+msgstr "المدفوعات المسددة مقدمًا "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__preview_data
+msgid "Preview Data"
+msgstr "معاينة البيانات "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0
+msgid "Previous Period"
+msgstr "الفترة السابقة"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0
+msgid "Previous Periods"
+msgstr "الفترات السابقة "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0
+msgid "Previous Year"
+msgstr "العام الماضي"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0
+msgid "Previous Years"
+msgstr "السنوات الماضية "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_retained_earnings_line_2
+msgid "Previous Years Retained Earnings"
+msgstr "الأرباح المحتجزة للسنة الماضية "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_previous_year_earnings0
+msgid "Previous Years Unallocated Earnings"
+msgstr "أرباح السنين الماضية غير المخصصة"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_send_form
+msgid "Print & Send"
+msgstr "طباعة وإرسال "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0
+msgid "Proceed"
+msgstr "استمرار "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_multicurrency_revaluation_wizard
+msgid ""
+"Proceed with caution as there might be an existing adjustment for this "
+"period ("
+msgstr "الاستمرار بحذر لأنه قد تكون هناك تعديلات لهذه الفترة ("
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0
+#: code:addons/odex30_account_reports/static/src/components/deferred_reports/groupby.xml:0
+msgid "Product"
+msgstr "المنتج "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/deferred_reports/groupby.xml:0
+msgid "Product Category"
+msgstr "فئة المنتج "
+
+#. module: odex30_account_reports
+#: model:account.report,name:odex30_account_reports.profit_and_loss
+#: model:ir.actions.client,name:odex30_account_reports.action_account_report_pl
+#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_profit_and_loss
+msgid "Profit and Loss"
+msgstr "الربح والخسارة "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_profitability0
+msgid "Profitability"
+msgstr "الربحية "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_move_form_vat_return
+msgid "Proposition of tax closing journal entry."
+msgstr "مقترح قيد يومية إقفال الضريبة. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0
+msgid "Provision for %(for_cur)s (1 %(comp_cur)s = %(rate)s %(for_cur)s)"
+msgstr "الحكل لـ %(for_cur)s (1 %(comp_cur)s = %(rate)s %(for_cur)s) "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "Quarter"
+msgstr "ربع السنة"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/line_name.xml:0
+msgid "Rates"
+msgstr "الأسعار"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_aged_partner_balance.py:0
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Receivable"
+msgstr "المدين"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0
+msgid "Receivable tax amount"
+msgstr "مبلغ الضريبة المدين "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_debtors0
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_receivable0
+msgid "Receivables"
+msgstr "المدينين"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__mail_partner_ids
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_send_form
+msgid "Recipients"
+msgstr "المستلمين"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_reports_journal_dashboard_kanban_view
+msgid "Reconciliation Report"
+msgstr "تقرير التسوية "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_financial_year_op__account_tax_periodicity_reminder_day
+#: model:ir.model.fields,field_description:odex30_account_reports.field_res_config_settings__account_tax_periodicity_reminder_day
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.setup_financial_year_opening_form
+msgid "Reminder"
+msgstr "تذكير"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__report_id
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__account_report_id
+msgid "Report"
+msgstr "التقرير"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_line_form
+msgid "Report Line"
+msgstr "بند التقرير"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form
+msgid "Report Name"
+msgstr "اسم التقرير"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__report_options
+msgid "Report Options"
+msgstr "خيارات التقرير"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Report lines mentioning the account code"
+msgstr "بنود التقرير التي تحتوي على كود الحساب "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_variant.xml:0
+msgid "Report:"
+msgstr "التقرير:"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form
+msgid "Reporting"
+msgstr "إعداد التقارير "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group__report_ids
+msgid "Reports"
+msgstr "التقارير"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_line_form
+msgid "Reset to Standard"
+msgstr "إعادة التعيين إلى الوضع القياسي "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_retained_earnings0
+msgid "Retained Earnings"
+msgstr "الأرباح المحتجزة"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_return_investment0
+msgid "Return on investments (net profit / assets)"
+msgstr "العائد على الاستثمار (صافي الربح / الأصول)"
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_income0
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_revenue0
+msgid "Revenue"
+msgstr "الإيرادات "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__reversal_date
+msgid "Reversal Date"
+msgstr "تاريخ الانعكاس"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0
+msgid "Reversal of Grouped Deferral Entry of %s"
+msgstr "عكس القيد المؤجل المجمع لـ %s "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0
+msgid "Reversal of: %s"
+msgstr "عكس: %s"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_search
+msgid "Root Report"
+msgstr "تقرير الجذر "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group__rule_ids
+msgid "Rules"
+msgstr "القواعد"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0
+msgid "Same Period Last Year"
+msgstr "نفس الفترة العام الماضي"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/search_bar/search_bar.xml:0
+msgid "Search..."
+msgstr "بحث..."
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form
+msgid "Sections"
+msgstr "الأقسام "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_customer_statement.py:0
+#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0
+msgid "Send"
+msgstr "إرسال"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0
+msgid "Send %s Statement"
+msgstr "إرسال كشف حساب %s "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report__send_and_print_values
+msgid "Send And Print Values"
+msgstr "إرسال وطباعة القيم "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__send_mail_readonly
+msgid "Send Mail Readonly"
+msgstr "إرسال بريد إلكتروني للقراءة فقط "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0
+msgid "Send Partner Ledgers"
+msgstr "إرسال دفاتر الأستاذ العام للشركاء "
+
+#. module: odex30_account_reports
+#: model:ir.actions.server,name:odex30_account_reports.ir_cron_account_report_send_ir_actions_server
+msgid "Send account reports automatically"
+msgstr "إرسال تقارير الحسابات تلقائياً "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_move.py:0
+msgid "Send tax report: %s"
+msgstr "إرسال التقرير الضريبي: %s "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/wizard/account_report_send.py:0
+msgid "Sending statements"
+msgstr "جاري إرسال كشوفات الحساب "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__sequence
+msgid "Sequence"
+msgstr "تسلسل "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_sales_report.py:0
+msgid "Services"
+msgstr "الخدمات"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_journal_report_audit_move_line_tree
+msgid "Set as Checked"
+msgstr "التعيين كتمّ التحقق منه "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.duplicated_vat_partner_tree_view
+msgid "Set as main"
+msgstr "تعيين كالرئيسي "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_st_cash_forecast0
+msgid "Short term cash forecast"
+msgstr "توقعات النقد قصيرة الأجل"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filter_extra_options.xml:0
+msgid "Show Account"
+msgstr "إظهار الحساب "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_budgets.xml:0
+msgid "Show All Accounts"
+msgstr "إظهار كافة الحسابات "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filter_extra_options.xml:0
+msgid "Show Currency"
+msgstr "إظهار العملة "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__show_warning_move_id
+msgid "Show Warning Move"
+msgstr "إظهار حركة تحذير "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/deferred_reports/warnings.xml:0
+msgid "Show already generated deferrals."
+msgstr "إظهار التأجيلات التي تم إنشاؤها بالفعل. "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields.selection,name:odex30_account_reports.selection__account_report_send__mode__single
+msgid "Single Recipient"
+msgstr "مستلم واحد "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0
+msgid "Some"
+msgstr "بعض "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/tax_report/warnings.xml:0
+msgid "Some journal items appear to point to obsolete report lines."
+msgstr "يبدو أن بعض عناصر دفتر اليومية تشير إلى بنود تقرير قديمة. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0
+msgid ""
+"Some lines in the report involve partners that share the same VAT number.\n"
+"\n"
+" Please review the"
+msgstr ""
+"تتضمن بعض بنود التقرير شركاء لهم نفس رقم ضريبة القيمة المضافة.\n"
+"\n"
+" يُرجى مراجعة "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_date.xml:0
+msgid "Specific Date"
+msgstr "تاريخ محدد"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,help:odex30_account_reports.field_res_company__account_representative_id
+msgid ""
+"Specify an Accounting Firm that will act as a representative when exporting "
+"reports."
+msgstr ""
+"قم بتحديد منشأة محاسبية التي سوف تؤدي دور الوكيل أو الممثل عند تصدير "
+"التقارير. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0
+msgid "Split Horizontally"
+msgstr "التقسيم أفقياَ "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report__tax_closing_start_date
+msgid "Start Date"
+msgstr "تاريخ البدء "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__account_tax_periodicity_reminder_day
+msgid "Start from"
+msgstr "البدء من "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+msgid "Starting Balance"
+msgstr "الرصيد الافتتاحي"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/line_name/line_name.xml:0
+msgid "Statement"
+msgstr "كشف الحساب"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/wizard/account_report_send.py:0
+msgid "Statements are being sent in the background."
+msgstr "يتم إرسال كشوف الحسابات في الخلفية. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/line/popover/debug_popover.xml:0
+msgid "Subformula"
+msgstr "صيغة فرعية "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__mail_subject
+msgid "Subject"
+msgstr "الموضوع "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_send_form
+msgid "Subject..."
+msgstr "الموضوع..."
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+msgid "T: %s"
+msgstr "T: %s"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_partner.xml:0
+msgid "Tags"
+msgstr "علامات التصنيف "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_general_ledger.py:0
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary
+msgid "Tax Amount"
+msgstr "مبلغ الضريبة "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary
+msgid "Tax Applied"
+msgstr "تم تطبيق الضريبة "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_bank_statement_line__tax_closing_alert
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_move__tax_closing_alert
+msgid "Tax Closing Alert"
+msgstr "تنبيه الإقفال الضريبي "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_bank_statement_line__tax_closing_report_id
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_move__tax_closing_report_id
+msgid "Tax Closing Report"
+msgstr "تقرير الإقفال الضريبي "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_general_ledger.py:0
+msgid "Tax Declaration"
+msgstr "الإقرار الضريبي "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+msgid "Tax Grids"
+msgstr "شبكات الضرائب"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__vat
+msgid "Tax ID"
+msgstr "معرف الضريبة"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.company_information
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.tax_information_customer_report
+msgid "Tax ID:"
+msgstr "معرف الضريبة: "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0
+msgid "Tax Paid Adjustment"
+msgstr "تعديل الضريبة المدفوعة "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/tax_report/filters/filter_date.xml:0
+msgid "Tax Period"
+msgstr "الفترة الضريبية "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0
+msgid "Tax Received Adjustment"
+msgstr "تعديل الضريبة المتلقاة "
+
+#. module: odex30_account_reports
+#: model:mail.activity.type,name:odex30_account_reports.tax_closing_activity_type
+#: model:mail.activity.type,summary:odex30_account_reports.tax_closing_activity_type
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_move_form_vat_return
+msgid "Tax Report"
+msgstr "التقرير الضريبي "
+
+#. module: odex30_account_reports
+#: model:mail.activity.type,name:odex30_account_reports.mail_activity_type_tax_report_error
+msgid "Tax Report - Error"
+msgstr ""
+
+#. module: odex30_account_reports
+#: model:mail.activity.type,name:odex30_account_reports.mail_activity_type_tax_report_to_be_sent
+msgid "Tax Report Ready"
+msgstr "التقرير الضريبي جاهز "
+
+#. module: odex30_account_reports
+#: model:ir.actions.client,name:odex30_account_reports.action_account_report_gt
+#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_gt
+msgid "Tax Return"
+msgstr "الإقرار الضريبي "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form
+msgid "Tax Return Periodicity"
+msgstr "مدى دورية الإقرار الضريبي "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_tax_unit
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_tax_unit_form
+msgid "Tax Unit"
+msgstr "الوحدة الضريبية "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_tax_unit.xml:0
+msgid "Tax Unit:"
+msgstr "وحدة الضريبة: "
+
+#. module: odex30_account_reports
+#: model:ir.actions.act_window,name:odex30_account_reports.action_view_tax_units
+#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__account_tax_unit_ids
+#: model:ir.ui.menu,name:odex30_account_reports.menu_view_tax_units
+msgid "Tax Units"
+msgstr "الوحدات الضريبية "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_mail_activity__account_tax_closing_params
+msgid "Tax closing additional params"
+msgstr "المعايير الإضافية للإقفال الضريبي "
+
+#. module: odex30_account_reports
+#: model:mail.activity.type,summary:odex30_account_reports.mail_activity_type_tax_report_to_pay
+msgid "Tax is ready to be paid"
+msgstr "الضريبة جاهزة ليتم دفعها "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields.selection,name:odex30_account_reports.selection__mail_activity_type__category__tax_report
+msgid "Tax report"
+msgstr "التقرير الضريبي "
+
+#. module: odex30_account_reports
+#: model:mail.activity.type,summary:odex30_account_reports.mail_activity_type_tax_report_to_be_sent
+msgid "Tax report is ready to be sent to the administration"
+msgstr "التقرير الضريبي جاهز لإرساله إلى الإدارة "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/res_company.py:0
+msgid "Tax return"
+msgstr "الإقرار الضريبي "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+msgid "Taxes"
+msgstr "الضرائب"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0
+msgid "Taxes Applied"
+msgstr "تم تطبيق الضرائب"
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,help:odex30_account_reports.field_account_tax_unit__fpos_synced
+msgid ""
+"Technical field indicating whether Fiscal Positions exist for all companies "
+"in the unit"
+msgstr ""
+"حقل تقني يشير إلى ما إذا كانت الأوضاع المالية موجودة لكافة الشركات في الوحدة "
+"أم لا "
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_ir_actions_account_report_download
+msgid "Technical model for accounting report downloads"
+msgstr "نموذج تقني لتنزيلات التقارير المحاسبية "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/ellipsis/ellipsis.js:0
+msgid "Text copied"
+msgstr "تم نسخ النص "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "The Accounts Coverage Report is not available for this report."
+msgstr "تقرير تغطية الحسابات غير متاح لهذا التقرير. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/line_name/popover_line/annotation_popover_line.js:0
+msgid "The annotation shouldn't have an empty value."
+msgstr "يجب ألا يحتوي الشرح على قيمة فارغة. "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__text
+msgid "The annotation's content."
+msgstr "محتوى الشرح. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_move.py:0
+msgid ""
+"The attachments of the tax report can be found on the %(link_start)sclosing "
+"entry%(link_end)s of the representative company."
+msgstr ""
+"يمكن العثور على مرفقات التقرير الضريبي في %(link_start)sقيد الإقفال%"
+"(link_end)s للشركة الممثلة. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0
+msgid "The column '%s' is not available for this report."
+msgstr "العمود '%s' غير متاح لهذا التقرير. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_tax.py:0
+msgid ""
+"The country detected for this VAT number does not match the one set on this "
+"Tax Unit."
+msgstr ""
+"لا تطابق الدولة التي تم رصدها لرقم الضريبة الدولة التي تم تعيينها لهذا الوضع "
+"الضريبي. "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,help:odex30_account_reports.field_account_tax_unit__country_id
+msgid ""
+"The country in which this tax unit is used to group your companies' tax "
+"reports declaration."
+msgstr ""
+"الدولة التي تُستخدَم فيها هذه الوحدة الضريبية لتجميع إقرارات التقارير الضريبية "
+"لشركتك. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_multicurrency_revaluation_report.py:0
+msgid "The currency rate cannot be equal to zero"
+msgstr "لا يمكن أن يكون سعر صرف العملة مساوياً لصفر "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0
+msgid "The current balance in the"
+msgstr "الرصيد الحالي في "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0
+msgid ""
+"The currently selected dates don't match a tax period. The closing entry "
+"will be created for the closest-matching period according to your "
+"periodicity setup."
+msgstr ""
+"لا تتطابق التواريخ المحددة حالياً مع إحدى الفترات الضريبية. سيتم إنشاء القيد "
+"الختامي لأقرب فترة مطابقة وفقاً لإعدادات معدل التكرار والدورية الخاصة بك. "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,help:odex30_account_reports.field_account_report_annotation__fiscal_position_id
+msgid "The fiscal position used while annotating."
+msgstr "الوضع المالي المُستَخدَم عند الشرح. "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,help:odex30_account_reports.field_account_report_annotation__line_id
+msgid "The id of the annotated line."
+msgstr "معرِّف البند الذي تم شرحه. "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,help:odex30_account_reports.field_account_report_annotation__report_id
+msgid "The id of the annotated report."
+msgstr "معرِّف التقرير الذي تم شرحه. "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,help:odex30_account_reports.field_account_tax_unit__vat
+msgid "The identifier to be used when submitting a report for this unit."
+msgstr "المعرف لاستخدامه عند تسليم تقرير لهذه الوحدة. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_tax.py:0
+msgid "The main company of a tax unit has to be part of it."
+msgstr "يجب أن تكون الشركة الرئيسية لوحدة ضريبية جزءاً منها. "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,help:odex30_account_reports.field_res_company__account_tax_unit_ids
+msgid "The tax units this company belongs to."
+msgstr "الوحدة الضريبية التي تنتمي إليها هذه الشركة. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "The used operator is not supported for this expression."
+msgstr "المشغل المستخدَم غير مدعوم لهذا التعبير. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/warnings.xml:0
+msgid "There are"
+msgstr "هناك "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/wizard/account_report_send.py:0
+msgid "There are currently reports waiting to be sent, please try again later."
+msgstr "هناك تقارير بانتظار إرسالها حالياً. يُرجى إعادة المحاولة لاحقاً. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/account_report.xml:0
+msgid "There is no data to display for the given filters."
+msgstr "لا توجد بيانات لعرضها لعناصر التصفية المحددة. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"This account exists in the Chart of Accounts but is not mentioned in any "
+"line of the report"
+msgstr ""
+"الحساب موجود في شجرة الحسابات ولكنه غير مذكور في أي بند من بنود التقرير "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"This account is reported in a line of the report but does not exist in the "
+"Chart of Accounts"
+msgstr ""
+"تم إعداد تقرير حول هذا الحساب في بند من التقرير ولكنه غير موجود في شجرة "
+"الحسابات "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "This account is reported in multiple lines of the report"
+msgstr "توجد عدة تقارير حول هذا الحساب في عدة بنود من التقرير "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "This account is reported multiple times on the same line of the report"
+msgstr "توجد عدة تقارير حول هذا الحساب في نفس البند من التقرير "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form
+msgid ""
+"This allows you to choose the position of totals in your financial reports."
+msgstr "يسمح لك هذا باختيار مكان الإجمالي في تقاريرك المالية."
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/warnings.xml:0
+msgid ""
+"This company is part of a tax unit. You're currently not viewing the whole "
+"unit."
+msgstr "هذه الشركة هي جزء من وحدة ضريبية. لا تعرض حالياً الوحدة بأكملها. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/widgets/account_report_x2many/account_report_x2many.js:0
+msgid ""
+"This line and all its children will be deleted. Are you sure you want to "
+"proceed?"
+msgstr "سيتم حذف هذا البند وكافة توابعه. هل أنت متأكد من أنك ترغب بالاستمرار؟ "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/widgets/account_report_x2many/account_report_x2many.xml:0
+msgid "This line is out of sequence."
+msgstr "هذا البند خارج التسلسل. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/widgets/account_report_x2many/account_report_x2many.xml:0
+msgid ""
+"This line is placed before its parent, which is not allowed. You can fix it "
+"by dragging it to the proper position."
+msgstr ""
+"تم وضع هذا البند قبل البند الأصلي، وهو أمر غير مسموح به. يمكنك إصلاحه عن "
+"طريق سحبه إلى المكان المناسب. "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_line_form
+msgid "This line uses a custom user-defined 'Group By' value."
+msgstr ""
+"يستخدم هذا البند قيمة \"التجميع حسب\" المخصصة المعرفة من قِبَل المستخدم. "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form
+msgid "This option hides lines with a value of 0"
+msgstr "يقوم هذا الخيار بإخفاء البنود التي قيمتها 0 "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "This report already has a menuitem."
+msgstr "هذا التقرير يحتوي على عنصر قائمة بالفعل. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/tax_report/warnings.xml:0
+msgid ""
+"This report contains inconsistencies. The affected lines are marked with a "
+"warning."
+msgstr ""
+"يحتوي التقرير على بيانات غير متسقة. تم وضع علامة تحذير على البنود المتأثرة. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/warnings.xml:0
+msgid "This report only displays the data of the active company."
+msgstr "هذا التقرير يعرض فقط بيانات الشركة النشطة. "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form
+msgid ""
+"This report uses report-specific code.\n"
+" You can customize it manually, but any change in the "
+"parameters used for its computation could lead to errors."
+msgstr ""
+"يستخدم هذا التقرير كود خاص بالتقرير.\n"
+" يمكنك تخصيصه يدوياً، ولكن قد يؤدي أي تغيير في العوامل "
+"المستخدمة في حسابه إلى حدوث أخطاء. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/warnings.xml:0
+msgid ""
+"This report uses the CTA conversion method to consolidate multiple companies "
+"using different currencies,\n"
+" which can lead the report to be unbalanced."
+msgstr ""
+"يستخدم هذا التقرير طريقة التحويل CTA لدمج عدة شركات تستخدم عملات مختلفة،\n"
+" مما قد يؤدي إلى اختلال توازن التقرير. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "This subformula references an unknown expression: %s"
+msgstr "تشير الصيغة الفرعية إلى تعبير غير معروف: %s "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"This tag is reported in a line of the report but is not linked to any "
+"account of the Chart of Accounts"
+msgstr ""
+"تم إعداد تقرير حول علامة التصنيف هذه في بند من التقرير ولكنه غير مرتبط بأي "
+"حساب في شجرة الحسابات "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_move_form_vat_return
+msgid ""
+"This tax closing entry is posted, but the tax lock date is earlier than the "
+"covered period's last day. You might need to reset it to draft and refresh "
+"its content, in case other entries using taxes have been posted in the "
+"meantime."
+msgstr ""
+"تم ترحيل قيد الإقفال الضريبي هذا، ولكن تاريخ قفل الضريبة يسبق اليوم الأخير "
+"للفترة المشمولة. قد تحتاج إلى إعادة تعيينه إلى حالة المسودة لتحديث محتواه، "
+"في حال تم ترحيل قيود أخرى تستخدم الضرائب في هذه الأثناء. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_date.xml:0
+msgid "Today"
+msgstr "اليوم "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0
+#: code:addons/odex30_account_reports/models/account_general_ledger.py:0
+#: code:addons/odex30_account_reports/models/account_journal_report.py:0
+#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0
+#: code:addons/odex30_account_reports/models/account_sales_report.py:0
+#: model:account.report.column,name:odex30_account_reports.aged_payable_report_total
+#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_total
+msgid "Total"
+msgstr "الإجمالي"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Total %s"
+msgstr "الإجمالي %s"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "Trade Partners"
+msgstr "شركاء تجاريون "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.transaction_without_statement
+msgid "Transactions without statement"
+msgstr "المعاملات التي ليس لها كشف حساب "
+
+#. module: odex30_account_reports
+#: model:account.report,name:odex30_account_reports.trial_balance_report
+#: model:ir.actions.client,name:odex30_account_reports.action_account_report_coa
+#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_coa
+msgid "Trial Balance"
+msgstr "ميزان المراجعة"
+
+#. module: odex30_account_reports
+#: model:ir.model,name:odex30_account_reports.model_account_trial_balance_report_handler
+msgid "Trial Balance Custom Handler"
+msgstr "معالج مخصص لميزان المراجعة "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_sales_report.py:0
+msgid "Triangular"
+msgstr "مثلث الشكل "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"Trying to dispatch an action on a report not compatible with the provided "
+"options."
+msgstr "جاري محاولة إرسال إجراء في تقرير غير متوافق مع الخيارات المتوفرة. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid ""
+"Trying to expand a group for a line which was not generated by a report "
+"line: %s"
+msgstr "نحاول تفصيل مجموعة لبند لم يتم إنشاؤه بواسطة بند تقرير: %s "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Trying to expand a line without an expansion function."
+msgstr "نحاول تفصيل بند دون استخدام خاصية التفصيل. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Trying to expand groupby results on lines without a groupby value."
+msgstr "نحاول تفصيل نتائج خاصية groupby دون قيمة groupby. "
+
+#. module: odex30_account_reports
+#: model:account.report.line,name:odex30_account_reports.account_financial_unaffected_earnings0
+msgid "Unallocated Earnings"
+msgstr "أرباح غير مخصصة"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Undefined"
+msgstr "غير محدد"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0
+msgid "Unfold All"
+msgstr "كشف الكل"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Unknown"
+msgstr "غير معروف"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0
+#: code:addons/odex30_account_reports/models/account_sales_report.py:0
+msgid "Unknown Partner"
+msgstr "شريك مجهول "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Unknown bound criterium: %s"
+msgstr "فئة ربط غير معروفة: %s "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Unknown date scope: %s"
+msgstr "نطاق البيانات غير معروف: %s "
+
+#. module: odex30_account_reports
+#: model:account.report,name:odex30_account_reports.multicurrency_revaluation_report
+#: model:ir.actions.client,name:odex30_account_reports.action_account_report_multicurrency_revaluation
+#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_multicurrency_revaluation
+msgid "Unrealized Currency Gains/Losses"
+msgstr "الأرباح/الخسائر غير المُدرَكة للعملة "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filter_extra_options_template
+msgid "Unreconciled Entries"
+msgstr "القيود غير المسواة "
+
+#. module: odex30_account_reports
+#: model:account.report.column,name:odex30_account_reports.account_financial_report_ec_sales_vat
+msgid "VAT Number"
+msgstr "رقم ضريبة القيمة المضافة"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.setup_financial_year_opening_form
+msgid "VAT Periodicity"
+msgstr "وتيرة ضريبة القيمة المضافة "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/line/popover/debug_popover.xml:0
+msgid "Value"
+msgstr "القيمة"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_move.py:0
+msgid "Vat closing from %(date_from)s to %(date_to)s"
+msgstr "إغلاق ضريبة القيمة المضافة من %(date_from)s إلى %(date_to)s "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_journal_report_audit_move_line_tree
+msgid "View"
+msgstr "أداة العرض"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "View Bank Statement"
+msgstr "عرض كشف الحساب البنكي "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/carryover_popover.xml:0
+msgid "View Carryover Lines"
+msgstr "عرض بنود الترحيل "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "View Journal Entry"
+msgstr "عرض قيد اليومية"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+#: code:addons/odex30_account_reports/models/account_sales_report.py:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_journal_report_audit_move_line_tree
+msgid "View Partner"
+msgstr "عرض الشريك"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/wizard/account_report_send.py:0
+msgid "View Partner(s)"
+msgstr "View Partner(s)"
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "View Payment"
+msgstr "عرض الدفع "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__warnings
+msgid "Warnings"
+msgstr "تحذيرات"
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form
+msgid ""
+"When ticked, totals and subtotals appear below the sections of the report"
+msgstr "عند التحديد، سوف تظهر المجاميع الكلية والفرعية تحت أقسام التقرير "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,help:odex30_account_reports.field_res_company__totals_below_sections
+#: model:ir.model.fields,help:odex30_account_reports.field_res_config_settings__totals_below_sections
+msgid ""
+"When ticked, totals and subtotals appear below the sections of the report."
+msgstr "عند التحديد، سوف تظهر المجاميع الكلية والفرعية تحت أقسام التقرير "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields,help:odex30_account_reports.field_account_account__exclude_provision_currency_ids
+msgid ""
+"Whether or not we have to make provisions for the selected foreign "
+"currencies."
+msgstr "ما إذا كان علينا إنشاء أحكام للعملات الأجنبية المختارة أم لا. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filter_extra_options_template
+msgid "With Draft Entries"
+msgstr "مع القيود بحالة المسودة "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_general_ledger.py:0
+msgid "Wrong ID for general ledger line to expand: %s"
+msgstr "المعرّف غير صحيح لبند دفتر الأستاذ العام لتفصيله: %s "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0
+msgid "Wrong ID for partner ledger line to expand: %s"
+msgstr "المعرّف غير صحيح لبند دفتر الأستاذ الخاص بالشريك لتفصيله: %s "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "Wrong format for if_other_expr_above/if_other_expr_below formula: %s"
+msgstr "الصيغة غير صحيحة لمعادلة if_other_expr_above/if_other_expr_below: %s "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "XLSX"
+msgstr "XLSX"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0
+msgid "Year"
+msgstr "السنة "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/edit_popover.xml:0
+msgid "Yes"
+msgstr "نعم"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/warnings.xml:0
+msgid "You are using custom exchange rates."
+msgstr "أنت تستخدم أسعار صرف مخصصة "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_move.py:0
+msgid "You can't open a tax report from a move without a VAT closing date."
+msgstr ""
+"لا يمكنك فتح تقرير ضريبي من حركة دون تاريخ إقفال ضريبة القيمة المضافة. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_move_line.py:0
+msgid "You cannot add taxes on a tax closing move line."
+msgstr "لا يمكنك إضافة ضرائب في بند حركة إقفال ضريبي. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0
+msgid ""
+"You cannot generate entries for a period that does not end at the end of the "
+"month."
+msgstr "لا يمكنك إنشاء قيود لفترة لا تنتهي بنهاية الشهر. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0
+msgid "You cannot generate entries for a period that is locked."
+msgstr "لا يمكنك إنشاء قيود لفترة مقفلة. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_move.py:0
+msgid ""
+"You cannot reset this closing entry to draft, as another closing entry has "
+"been posted at a later date."
+msgstr ""
+"لا يمكنك إعادة تعيين قيد الإقفال هذا إلى حالة المسودة، لأنه قد تم ترحيل قيد "
+"إقفال آخر بتاريخ لاحق. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_move.py:0
+msgid ""
+"You cannot reset this closing entry to draft, as it would delete carryover "
+"values impacting the tax report of a locked period. Please change the "
+"following lock dates to proceed: %(lock_date_info)s."
+msgstr ""
+"لا يمكنك إعادة تعيين قيد الإقفال هذا إلى حالة المسودة، لأن ذلك سيؤدي إلى حذف "
+"القيم المرحّلة التي تؤثر على تقرير الضرائب لفترة مقفلة. يرجى تغيير تواريخ "
+"القفل التالية للمتابعة: %(lock_date_info)s."
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "You cannot update this value as it's locked by: %s"
+msgstr ""
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_multicurrency_revaluation_report.py:0
+msgid "You need to activate more than one currency to access this report."
+msgstr "عليك تفعيل أكثر من عملة واحدة للوصول إلى هذا التقرير. "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0
+msgid ""
+"You're about the generate the closing entries of multiple companies at once. "
+"Each of them will be created in accordance with its company tax periodicity."
+msgstr ""
+"أنت على وشك إنشاء القيود الختامية لعدة شركات في آنٍ واحد. سيتم إنشاء كل منها "
+"وفقاً لمعدل تكرار ضريبة الشركة الخاصة بها. "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.journal_report_pdf_export_main
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_main
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_main_customer_report
+msgid "[Draft]"
+msgstr "[Draft]"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0
+msgid "addressed to"
+msgstr "موجهة إلى "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0
+msgid "affected partners"
+msgstr ""
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/tax_report/warnings.xml:0
+msgid "and correct their tax tags if necessary."
+msgstr "وتصحيح علامات تصنيف الضريبة إذا لزم الأمر. "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields.selection,name:odex30_account_reports.selection__res_company__account_tax_periodicity__year
+msgid "annually"
+msgstr "سنوياً "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.setup_financial_year_opening_form
+msgid "days after period"
+msgstr "أيام بعد الفترة "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0
+msgid "doesn't match the balance of your"
+msgstr "لا يطابق رصيد "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields.selection,name:odex30_account_reports.selection__res_company__account_tax_periodicity__2_months
+msgid "every 2 months"
+msgstr "كل شهرين "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields.selection,name:odex30_account_reports.selection__res_company__account_tax_periodicity__4_months
+msgid "every 4 months"
+msgstr "كل 4 شهور "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0
+msgid "have a starting balance different from the previous ending balance."
+msgstr "أن يكون الرصيد الافتتاحي مختلفاً عن الرصيد الختامي السابق "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/carryover_popover.xml:0
+msgid "in the next period."
+msgstr "في الفترة القادمة. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0
+msgid "invoices"
+msgstr "فواتير العملاء "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0
+msgid "journal items"
+msgstr "عناصر اليومية "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0
+msgid "last bank statement"
+msgstr "آخر كشف حساب بنكي "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields.selection,name:odex30_account_reports.selection__res_company__account_tax_periodicity__monthly
+msgid "monthly"
+msgstr "شهرياً "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_report.py:0
+msgid "n/a"
+msgstr "غير منطبق "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0
+msgid "partners"
+msgstr "الشركاء "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/warnings.xml:0
+msgid "prior or included in this period."
+msgstr "قبل هذه الفترة أو مشمول فيها. "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields.selection,name:odex30_account_reports.selection__res_company__account_tax_periodicity__trimester
+msgid "quarterly"
+msgstr "ربع سنوي "
+
+#. module: odex30_account_reports
+#: model:ir.model.fields.selection,name:odex30_account_reports.selection__res_company__account_tax_periodicity__semester
+msgid "semi-annually"
+msgstr "شبه سنوي "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0
+msgid "statements"
+msgstr "كشوفات الحسابات "
+
+#. module: odex30_account_reports
+#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_tax_unit_form
+msgid "synchronize fiscal positions"
+msgstr "مزامنة الأوضاع المالية "
+
+#. module: odex30_account_reports
+#. odoo-python
+#: code:addons/odex30_account_reports/models/account_tax.py:0
+msgid "tax unit [%s]"
+msgstr "وحدة الضريبة [%s] "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0
+msgid "that are not established abroad."
+msgstr "التي لم يتم إنشاؤها في الخارج. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0
+#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_date.xml:0
+msgid "to"
+msgstr "إلى"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0
+msgid "to resolve the duplication."
+msgstr ""
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/warnings.xml:0
+msgid "unposted Journal Entries"
+msgstr "قيود يومية غير مُرحلة"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/carryover_popover.xml:0
+msgid "were carried over to this line from previous period."
+msgstr "تم ترحيلها إلى هذا البند من الفترة السابقة. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0
+msgid "which don't originate from a bank statement nor payment."
+msgstr "والذي لا ينتج عن كشف حساب بنكي أو عملية دفع. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0
+msgid "who are not established in any of the EC countries."
+msgstr "غير المنشئين في أي من دول الاتحاد الأوروبي. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/carryover_popover.xml:0
+msgid "will be carried over to"
+msgstr "سيتم ترحيلها إلى "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/carryover_popover.xml:0
+msgid "will be carried over to this line in the next period."
+msgstr "سيتم ترحيلها إلى هذا البند في الفترة التالية. "
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0
+msgid "without a valid intra-community VAT number."
+msgstr "دون رقم ضريبي صالح لبين المجتمعات. "
+
+#. module: odex30_account_reports
+#: model:mail.template,subject:odex30_account_reports.email_template_customer_statement
+msgid ""
+"{{ (object.company_id or "
+"object._get_followup_responsible().company_id).name }} Statement - "
+"{{ object.commercial_company_name }}"
+msgstr ""
+"{{ (object.company_id or "
+"object._get_followup_responsible().company_id).name }} Statement - "
+"{{ object.commercial_company_name }}"
+
+#. module: odex30_account_reports
+#. odoo-javascript
+#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/warnings.xml:0
+msgid "⇒ Reset to Odoo’s Rate"
+msgstr "-> إعادة التعيين لأسعار أودو "
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__init__.py b/dev_odex30_accounting/odex30_account_reports/models/__init__.py
new file mode 100644
index 0000000..2d52b6b
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/__init__.py
@@ -0,0 +1,32 @@
+
+from . import res_partner
+from . import res_company
+from . import account
+from . import account_report
+from . import account_analytic_report
+from . import bank_reconciliation_report
+from . import account_general_ledger
+from . import account_generic_tax_report
+from . import account_journal_report
+from . import account_cash_flow_report
+from . import account_deferred_reports
+from . import account_multicurrency_revaluation_report
+from . import account_move_line
+from . import account_trial_balance_report
+from . import account_aged_partner_balance
+from . import account_partner_ledger
+from . import mail_activity
+from . import mail_activity_type
+from . import res_config_settings
+from . import chart_template
+from . import account_journal_dashboard
+from . import ir_actions
+from . import account_sales_report
+from . import account_move
+from . import account_tax
+from . import executive_summary_report
+from . import budget
+from . import balance_sheet
+from . import account_fiscal_position
+from . import account_customer_statement
+from . import account_followup_report
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000..e71859a
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/__init__.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account.cpython-311.pyc
new file mode 100644
index 0000000..b70bdaf
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_aged_partner_balance.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_aged_partner_balance.cpython-311.pyc
new file mode 100644
index 0000000..c9b60c4
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_aged_partner_balance.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_analytic_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_analytic_report.cpython-311.pyc
new file mode 100644
index 0000000..c8ae24c
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_analytic_report.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_cash_flow_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_cash_flow_report.cpython-311.pyc
new file mode 100644
index 0000000..f2495e2
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_cash_flow_report.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_customer_statement.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_customer_statement.cpython-311.pyc
new file mode 100644
index 0000000..cfc9213
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_customer_statement.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_deferred_reports.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_deferred_reports.cpython-311.pyc
new file mode 100644
index 0000000..cd4db1b
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_deferred_reports.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_fiscal_position.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_fiscal_position.cpython-311.pyc
new file mode 100644
index 0000000..50f95f4
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_fiscal_position.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_followup_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_followup_report.cpython-311.pyc
new file mode 100644
index 0000000..5726de4
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_followup_report.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_general_ledger.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_general_ledger.cpython-311.pyc
new file mode 100644
index 0000000..41cd339
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_general_ledger.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_generic_tax_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_generic_tax_report.cpython-311.pyc
new file mode 100644
index 0000000..b4119e6
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_generic_tax_report.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_journal_dashboard.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_journal_dashboard.cpython-311.pyc
new file mode 100644
index 0000000..712531f
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_journal_dashboard.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_journal_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_journal_report.cpython-311.pyc
new file mode 100644
index 0000000..cee7be1
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_journal_report.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_move.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_move.cpython-311.pyc
new file mode 100644
index 0000000..ab37b9a
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_move.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_move_line.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_move_line.cpython-311.pyc
new file mode 100644
index 0000000..715c077
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_move_line.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_multicurrency_revaluation_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_multicurrency_revaluation_report.cpython-311.pyc
new file mode 100644
index 0000000..4b6504e
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_multicurrency_revaluation_report.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_partner_ledger.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_partner_ledger.cpython-311.pyc
new file mode 100644
index 0000000..617f41c
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_partner_ledger.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_report.cpython-311.pyc
new file mode 100644
index 0000000..9089e42
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_report.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_sales_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_sales_report.cpython-311.pyc
new file mode 100644
index 0000000..8553fcc
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_sales_report.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_tax.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_tax.cpython-311.pyc
new file mode 100644
index 0000000..307603f
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_tax.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_trial_balance_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_trial_balance_report.cpython-311.pyc
new file mode 100644
index 0000000..906b37c
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_trial_balance_report.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/balance_sheet.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/balance_sheet.cpython-311.pyc
new file mode 100644
index 0000000..35feb17
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/balance_sheet.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/bank_reconciliation_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/bank_reconciliation_report.cpython-311.pyc
new file mode 100644
index 0000000..606e2fd
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/bank_reconciliation_report.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/budget.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/budget.cpython-311.pyc
new file mode 100644
index 0000000..98683bb
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/budget.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/chart_template.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/chart_template.cpython-311.pyc
new file mode 100644
index 0000000..770eec7
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/chart_template.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/executive_summary_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/executive_summary_report.cpython-311.pyc
new file mode 100644
index 0000000..2ab0b5d
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/executive_summary_report.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/ir_actions.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/ir_actions.cpython-311.pyc
new file mode 100644
index 0000000..8f4b74f
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/ir_actions.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/mail_activity.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/mail_activity.cpython-311.pyc
new file mode 100644
index 0000000..012be9e
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/mail_activity.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/mail_activity_type.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/mail_activity_type.cpython-311.pyc
new file mode 100644
index 0000000..592f6a9
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/mail_activity_type.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/res_company.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/res_company.cpython-311.pyc
new file mode 100644
index 0000000..f77f8e8
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/res_company.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/res_config_settings.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/res_config_settings.cpython-311.pyc
new file mode 100644
index 0000000..09fbda3
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/res_config_settings.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/res_partner.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/res_partner.cpython-311.pyc
new file mode 100644
index 0000000..2c75e49
Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/res_partner.cpython-311.pyc differ
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account.py b/dev_odex30_accounting/odex30_account_reports/models/account.py
new file mode 100644
index 0000000..63c5a14
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account.py
@@ -0,0 +1,9 @@
+
+from odoo import api, fields, models
+
+
+class AccountAccount(models.Model):
+ _inherit = "account.account"
+
+ exclude_provision_currency_ids = fields.Many2many('res.currency', relation='account_account_exclude_res_currency_provision', help="Whether or not we have to make provisions for the selected foreign currencies.")
+ budget_item_ids = fields.One2many(comodel_name='account.report.budget.item', inverse_name='account_id') # To use it in the domain when adding accounts from the report
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_aged_partner_balance.py b/dev_odex30_accounting/odex30_account_reports/models/account_aged_partner_balance.py
new file mode 100644
index 0000000..35b0547
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_aged_partner_balance.py
@@ -0,0 +1,447 @@
+
+import datetime
+
+from odoo import models, fields, _
+from odoo.tools import SQL
+from odoo.tools.misc import format_date
+
+from dateutil.relativedelta import relativedelta
+from itertools import chain
+
+
+class AgedPartnerBalanceCustomHandler(models.AbstractModel):
+ _name = 'account.aged.partner.balance.report.handler'
+ _inherit = 'account.report.custom.handler'
+ _description = 'Aged Partner Balance Custom Handler'
+
+ def _get_custom_display_config(self):
+ return {
+ 'css_custom_class': 'aged_partner_balance',
+ 'templates': {
+ 'AccountReportLineName': 'odex30_account_reports.AgedPartnerBalanceLineName',
+ },
+ 'components': {
+ 'AccountReportFilters': 'odex30_account_reports.AgedPartnerBalanceFilters',
+ },
+ }
+
+ def _custom_options_initializer(self, report, options, previous_options):
+ super()._custom_options_initializer(report, options, previous_options=previous_options)
+ hidden_columns = set()
+
+ options['multi_currency'] = report.env.user.has_group('base.group_multi_currency')
+ options['show_currency'] = options['multi_currency'] and (previous_options or {}).get('show_currency', False)
+ options['no_xlsx_currency_code_columns'] = True
+ if not options['show_currency']:
+ hidden_columns.update(['amount_currency', 'currency'])
+
+ options['show_account'] = (previous_options or {}).get('show_account', False)
+ if not options['show_account']:
+ hidden_columns.add('account_name')
+
+ options['columns'] = [
+ column for column in options['columns']
+ if column['expression_label'] not in hidden_columns
+ ]
+
+ default_order_column = {
+ 'expression_label': 'invoice_date',
+ 'direction': 'ASC',
+ }
+
+ options['order_column'] = previous_options.get('order_column') or default_order_column
+ options['aging_based_on'] = previous_options.get('aging_based_on') or 'base_on_maturity_date'
+ options['aging_interval'] = previous_options.get('aging_interval') or 30
+
+ # Set aging column names
+ interval = options['aging_interval']
+ for column in options['columns']:
+ if column['expression_label'].startswith('period'):
+ period_number = int(column['expression_label'].replace('period', '')) - 1
+ if 0 <= period_number < 4:
+ column['name'] = f'{interval * period_number + 1}-{interval * (period_number + 1)}'
+
+ def _custom_line_postprocessor(self, report, options, lines):
+ partner_lines_map = {}
+
+ # Sort line dicts by partner
+ for line in lines:
+ model, model_id = report._get_model_info_from_id(line['id'])
+ if model == 'res.partner':
+ partner_lines_map[model_id] = line
+
+ if partner_lines_map:
+ for partner, line_dict in zip(
+ self.env['res.partner'].browse(partner_lines_map),
+ partner_lines_map.values()
+ ):
+ line_dict['trust'] = partner.with_company(partner.company_id or self.env.company).trust
+
+ return lines
+
+ def _report_custom_engine_aged_receivable(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ return self._aged_partner_report_custom_engine_common(options, 'asset_receivable', current_groupby, next_groupby, offset=offset, limit=limit)
+
+ def _report_custom_engine_aged_payable(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ return self._aged_partner_report_custom_engine_common(options, 'liability_payable', current_groupby, next_groupby, offset=offset, limit=limit)
+
+ def _aged_partner_report_custom_engine_common(self, options, internal_type, current_groupby, next_groupby, offset=0, limit=None):
+ report = self.env['account.report'].browse(options['report_id'])
+ report._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else []))
+
+ def minus_days(date_obj, days):
+ return fields.Date.to_string(date_obj - relativedelta(days=days))
+
+ aging_date_field = SQL.identifier('invoice_date') if options['aging_based_on'] == 'base_on_invoice_date' else SQL.identifier('date_maturity')
+ date_to = fields.Date.from_string(options['date']['date_to'])
+ interval = options['aging_interval']
+ periods = [(False, fields.Date.to_string(date_to))]
+ # Since we added the first period in the list we have to do one less iteration
+ nb_periods = len([column for column in options['columns'] if column['expression_label'].startswith('period')]) - 1
+ for i in range(nb_periods):
+ start_date = minus_days(date_to, (interval * i) + 1)
+ # The last element of the list will have False for the end date
+ end_date = minus_days(date_to, interval * (i + 1)) if i < nb_periods - 1 else False
+ periods.append((start_date, end_date))
+
+ def build_result_dict(report, query_res_lines):
+ rslt = {f'period{i}': 0 for i in range(len(periods))}
+
+ for query_res in query_res_lines:
+ for i in range(len(periods)):
+ period_key = f'period{i}'
+ rslt[period_key] += query_res[period_key]
+
+ if current_groupby == 'id':
+ query_res = query_res_lines[0] # We're grouping by id, so there is only 1 element in query_res_lines anyway
+ currency = self.env['res.currency'].browse(query_res['currency_id'][0]) if len(query_res['currency_id']) == 1 else None
+ rslt.update({
+ 'invoice_date': query_res['invoice_date'][0] if len(query_res['invoice_date']) == 1 else None,
+ 'due_date': query_res['due_date'][0] if len(query_res['due_date']) == 1 else None,
+ 'amount_currency': query_res['amount_currency'],
+ 'currency_id': query_res['currency_id'][0] if len(query_res['currency_id']) == 1 else None,
+ 'currency': currency.display_name if currency else None,
+ 'account_name': query_res['account_name'][0] if len(query_res['account_name']) == 1 else None,
+ 'total': None,
+ 'has_sublines': query_res['aml_count'] > 0,
+
+ # Needed by the custom_unfold_all_batch_data_generator, to speed-up unfold_all
+ 'partner_id': query_res['partner_id'][0] if query_res['partner_id'] else None,
+ })
+ else:
+ rslt.update({
+ 'invoice_date': None,
+ 'due_date': None,
+ 'amount_currency': None,
+ 'currency_id': None,
+ 'currency': None,
+ 'account_name': None,
+ 'total': sum(rslt[f'period{i}'] for i in range(len(periods))),
+ 'has_sublines': False,
+ })
+
+ return rslt
+
+ # Build period table
+ period_table_format = ('(VALUES %s)' % ','.join("(%s, %s, %s)" for period in periods))
+ params = list(chain.from_iterable(
+ (period[0] or None, period[1] or None, i)
+ for i, period in enumerate(periods)
+ ))
+ period_table = SQL(period_table_format, *params)
+
+ # Build query
+ query = report._get_report_query(options, 'strict_range', domain=[('account_id.account_type', '=', internal_type)])
+ account_alias = query.left_join(lhs_alias='account_move_line', lhs_column='account_id', rhs_table='account_account', rhs_column='id', link='account_id')
+ account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query)
+
+ always_present_groupby = SQL("period_table.period_index")
+ if current_groupby:
+ groupby_field_sql = self.env['account.move.line']._field_to_sql("account_move_line", current_groupby, query)
+ select_from_groupby = SQL("%s AS grouping_key,", groupby_field_sql)
+ groupby_clause = SQL("%s, %s", groupby_field_sql, always_present_groupby)
+ else:
+ select_from_groupby = SQL()
+ groupby_clause = always_present_groupby
+ multiplicator = -1 if internal_type == 'liability_payable' else 1
+ select_period_query = SQL(',').join(
+ SQL("""
+ CASE WHEN period_table.period_index = %(period_index)s
+ THEN %(multiplicator)s * SUM(%(balance_select)s)
+ ELSE 0 END AS %(column_name)s
+ """,
+ period_index=i,
+ multiplicator=multiplicator,
+ column_name=SQL.identifier(f"period{i}"),
+ balance_select=report._currency_table_apply_rate(SQL(
+ "account_move_line.balance - COALESCE(part_debit.amount, 0) + COALESCE(part_credit.amount, 0)"
+ )),
+ )
+ for i in range(len(periods))
+ )
+
+ tail_query = report._get_engine_query_tail(offset, limit)
+ query = SQL(
+ """
+ WITH period_table(date_start, date_stop, period_index) AS (%(period_table)s)
+
+ SELECT
+ %(select_from_groupby)s
+ %(multiplicator)s * (
+ SUM(account_move_line.amount_currency)
+ - COALESCE(SUM(part_debit.debit_amount_currency), 0)
+ + COALESCE(SUM(part_credit.credit_amount_currency), 0)
+ ) AS amount_currency,
+ ARRAY_AGG(DISTINCT account_move_line.partner_id) AS partner_id,
+ ARRAY_AGG(account_move_line.payment_id) AS payment_id,
+ ARRAY_AGG(DISTINCT account_move_line.invoice_date) AS invoice_date,
+ ARRAY_AGG(DISTINCT COALESCE(account_move_line.%(aging_date_field)s, account_move_line.date)) AS report_date,
+ ARRAY_AGG(DISTINCT %(account_code)s) AS account_name,
+ ARRAY_AGG(DISTINCT COALESCE(account_move_line.%(aging_date_field)s, account_move_line.date)) AS due_date,
+ ARRAY_AGG(DISTINCT account_move_line.currency_id) AS currency_id,
+ COUNT(account_move_line.id) AS aml_count,
+ ARRAY_AGG(%(account_code)s) AS account_code,
+ %(select_period_query)s
+
+ FROM %(table_references)s
+
+ JOIN account_journal journal ON journal.id = account_move_line.journal_id
+ %(currency_table_join)s
+
+ LEFT JOIN LATERAL (
+ SELECT
+ SUM(part.amount) AS amount,
+ SUM(part.debit_amount_currency) AS debit_amount_currency,
+ part.debit_move_id
+ FROM account_partial_reconcile part
+ WHERE part.max_date <= %(date_to)s AND part.debit_move_id = account_move_line.id
+ GROUP BY part.debit_move_id
+ ) part_debit ON TRUE
+
+ LEFT JOIN LATERAL (
+ SELECT
+ SUM(part.amount) AS amount,
+ SUM(part.credit_amount_currency) AS credit_amount_currency,
+ part.credit_move_id
+ FROM account_partial_reconcile part
+ WHERE part.max_date <= %(date_to)s AND part.credit_move_id = account_move_line.id
+ GROUP BY part.credit_move_id
+ ) part_credit ON TRUE
+
+ JOIN period_table ON
+ (
+ period_table.date_start IS NULL
+ OR COALESCE(account_move_line.%(aging_date_field)s, account_move_line.date) <= DATE(period_table.date_start)
+ )
+ AND
+ (
+ period_table.date_stop IS NULL
+ OR COALESCE(account_move_line.%(aging_date_field)s, account_move_line.date) >= DATE(period_table.date_stop)
+ )
+
+ WHERE %(search_condition)s
+
+ GROUP BY %(groupby_clause)s
+
+ HAVING
+ ROUND(SUM(%(having_debit)s), %(currency_precision)s) != 0
+ OR ROUND(SUM(%(having_credit)s), %(currency_precision)s) != 0
+
+ ORDER BY %(groupby_clause)s
+
+ %(tail_query)s
+ """,
+ account_code=account_code,
+ period_table=period_table,
+ select_from_groupby=select_from_groupby,
+ select_period_query=select_period_query,
+ multiplicator=multiplicator,
+ aging_date_field=aging_date_field,
+ table_references=query.from_clause,
+ currency_table_join=report._currency_table_aml_join(options),
+ date_to=date_to,
+ search_condition=query.where_clause,
+ groupby_clause=groupby_clause,
+ having_debit=report._currency_table_apply_rate(SQL("CASE WHEN account_move_line.balance > 0 THEN account_move_line.balance else 0 END - COALESCE(part_debit.amount, 0)")),
+ having_credit=report._currency_table_apply_rate(SQL("CASE WHEN account_move_line.balance < 0 THEN -account_move_line.balance else 0 END - COALESCE(part_credit.amount, 0)")),
+ currency_precision=self.env.company.currency_id.decimal_places,
+ tail_query=tail_query,
+ )
+
+ self._cr.execute(query)
+ query_res_lines = self._cr.dictfetchall()
+
+ if not current_groupby:
+ return build_result_dict(report, query_res_lines)
+ else:
+ rslt = []
+
+ all_res_per_grouping_key = {}
+ for query_res in query_res_lines:
+ grouping_key = query_res['grouping_key']
+ all_res_per_grouping_key.setdefault(grouping_key, []).append(query_res)
+
+ for grouping_key, query_res_lines in all_res_per_grouping_key.items():
+ rslt.append((grouping_key, build_result_dict(report, query_res_lines)))
+
+ return rslt
+
+ def open_journal_items(self, options, params):
+ params['view_ref'] = 'account.view_move_line_tree_grouped_partner'
+ options_for_audit = {**options, 'date': {**options['date'], 'date_from': None}}
+ report = self.env['account.report'].browse(options['report_id'])
+ action = report.open_journal_items(options=options_for_audit, params=params)
+ action.get('context', {}).update({'search_default_group_by_account': 0, 'search_default_group_by_partner': 1})
+ return action
+
+ def open_customer_statement(self, options, params):
+ report = self.env['account.report'].browse(options['report_id'])
+ record_model, record_id = report._get_model_info_from_id(params.get('line_id'))
+ if self.env.ref('odex30_account_reports.customer_statement_report', raise_if_not_found=False):
+ return self.env[record_model].browse(record_id).open_customer_statement()
+ return self.env[record_model].browse(record_id).open_partner_ledger()
+
+ def _common_custom_unfold_all_batch_data_generator(self, internal_type, report, options, lines_to_expand_by_function):
+ rslt = {} # In the form {full_sub_groupby_key: all_column_group_expression_totals for this groupby computation}
+ report_periods = 6 # The report has 6 periods
+
+ for expand_function_name, lines_to_expand in lines_to_expand_by_function.items():
+ for line_to_expand in lines_to_expand: # In standard, this loop will execute only once
+ if expand_function_name == '_report_expand_unfoldable_line_with_groupby':
+ report_line_id = report._get_res_id_from_line_id(line_to_expand['id'], 'account.report.line')
+ expressions_to_evaluate = report.line_ids.expression_ids.filtered(lambda x: x.report_line_id.id == report_line_id and x.engine == 'custom')
+
+ if not expressions_to_evaluate:
+ continue
+
+ for column_group_key, column_group_options in report._split_options_per_column_group(options).items():
+ # Get all aml results by partner
+ aml_data_by_partner = {}
+ for aml_id, aml_result in self._aged_partner_report_custom_engine_common(column_group_options, internal_type, 'id', None):
+ aml_result['aml_id'] = aml_id
+ aml_data_by_partner.setdefault(aml_result['partner_id'], []).append(aml_result)
+
+ # Iterate on results by partner to generate the content of the column group
+ partner_expression_totals = rslt.setdefault(f"[{report_line_id}]=>partner_id", {})\
+ .setdefault(column_group_key, {expression: {'value': []} for expression in expressions_to_evaluate})
+ for partner_id, aml_data_list in aml_data_by_partner.items():
+ partner_values = self._prepare_partner_values()
+ for i in range(report_periods):
+ partner_values[f'period{i}'] = 0
+
+ # Build expression totals under the right key
+ partner_aml_expression_totals = rslt.setdefault(f"[{report_line_id}]partner_id:{partner_id}=>id", {})\
+ .setdefault(column_group_key, {expression: {'value': []} for expression in expressions_to_evaluate})
+ for aml_data in aml_data_list:
+ for i in range(report_periods):
+ period_value = aml_data[f'period{i}']
+ partner_values[f'period{i}'] += period_value
+ partner_values['total'] += period_value
+
+ for expression in expressions_to_evaluate:
+ partner_aml_expression_totals[expression]['value'].append(
+ (aml_data['aml_id'], aml_data[expression.subformula])
+ )
+
+ for expression in expressions_to_evaluate:
+ partner_expression_totals[expression]['value'].append(
+ (partner_id, partner_values[expression.subformula])
+ )
+
+ return rslt
+
+ def _prepare_partner_values(self):
+ return {
+ 'invoice_date': None,
+ 'due_date': None,
+ 'amount_currency': None,
+ 'currency_id': None,
+ 'currency': None,
+ 'account_name': None,
+ 'total': 0,
+ }
+
+ def aged_partner_balance_audit(self, options, params, journal_type):
+ """ Open a list of invoices/bills and/or deferral entries for the clicked cell
+ :param dict options: the report's `options`
+ :param dict params: a dict containing:
+ `calling_line_dict_id`: line id containing the optional account of the cell
+ `expression_label`: the expression label of the cell
+ """
+ report = self.env['account.report'].browse(options['report_id'])
+ action = self.env['ir.actions.actions']._for_xml_id('account.action_amounts_to_settle')
+ journal_type_to_exclude = {'purchase': 'sale', 'sale': 'purchase'}
+ if options:
+ domain = [
+ ('account_id.reconcile', '=', True),
+ ('journal_id.type', '!=', journal_type_to_exclude.get(journal_type)),
+ *self._build_domain_from_period(options, params['expression_label']),
+ *report._get_options_domain(options, 'from_beginning'),
+ *report._get_audit_line_groupby_domain(params['calling_line_dict_id']),
+ ]
+ action['domain'] = domain
+ return action
+
+ def _build_domain_from_period(self, options, period):
+ if period != "total" and period[-1].isdigit():
+ period_number = int(period[-1])
+ if period_number == 0:
+ domain = [('date_maturity', '>=', options['date']['date_to'])]
+ else:
+ options_date_to = datetime.datetime.strptime(options['date']['date_to'], '%Y-%m-%d')
+ period_end = options_date_to - datetime.timedelta(30*(period_number-1)+1)
+ period_start = options_date_to - datetime.timedelta(30*(period_number))
+ domain = [('date_maturity', '>=', period_start), ('date_maturity', '<=', period_end)]
+ if period_number == 5:
+ domain = [('date_maturity', '<=', period_end)]
+ else:
+ domain = []
+ return domain
+
+class AgedPayableCustomHandler(models.AbstractModel):
+ _name = 'account.aged.payable.report.handler'
+ _inherit = 'account.aged.partner.balance.report.handler'
+ _description = 'Aged Payable Custom Handler'
+
+ def open_journal_items(self, options, params):
+ payable_account_type = {'id': 'trade_payable', 'name': _("Payable"), 'selected': True}
+
+ if 'account_type' in options:
+ options['account_type'].append(payable_account_type)
+ else:
+ options['account_type'] = [payable_account_type]
+
+ return super().open_journal_items(options, params)
+
+ def _custom_unfold_all_batch_data_generator(self, report, options, lines_to_expand_by_function):
+ # We only optimize the unfold all if the groupby value of the report has not been customized. Else, we'll just run the full computation
+ if self.env.ref('odex30_account_reports.aged_payable_line').groupby.replace(' ', '') == 'partner_id,id':
+ return self._common_custom_unfold_all_batch_data_generator('liability_payable', report, options, lines_to_expand_by_function)
+ return {}
+
+ def action_audit_cell(self, options, params):
+ return super().aged_partner_balance_audit(options, params, 'purchase')
+
+class AgedReceivableCustomHandler(models.AbstractModel):
+ _name = 'account.aged.receivable.report.handler'
+ _inherit = 'account.aged.partner.balance.report.handler'
+ _description = 'Aged Receivable Custom Handler'
+
+ def open_journal_items(self, options, params):
+ receivable_account_type = {'id': 'trade_receivable', 'name': _("Receivable"), 'selected': True}
+
+ if 'account_type' in options:
+ options['account_type'].append(receivable_account_type)
+ else:
+ options['account_type'] = [receivable_account_type]
+
+ return super().open_journal_items(options, params)
+
+ def _custom_unfold_all_batch_data_generator(self, report, options, lines_to_expand_by_function):
+ # We only optimize the unfold all if the groupby value of the report has not been customized. Else, we'll just run the full computation
+ if self.env.ref('odex30_account_reports.aged_receivable_line').groupby.replace(' ', '') == 'partner_id,id':
+ return self._common_custom_unfold_all_batch_data_generator('asset_receivable', report, options, lines_to_expand_by_function)
+ return {}
+
+ def action_audit_cell(self, options, params):
+ return super().aged_partner_balance_audit(options, params, 'sale')
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_analytic_report.py b/dev_odex30_accounting/odex30_account_reports/models/account_analytic_report.py
new file mode 100644
index 0000000..f972be1
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_analytic_report.py
@@ -0,0 +1,257 @@
+
+from odoo import models, fields, api, osv
+from odoo.addons.web.controllers.utils import clean_action
+from odoo.tools import SQL, Query
+
+
+class AccountReport(models.AbstractModel):
+ _inherit = 'account.report'
+
+ filter_analytic_groupby = fields.Boolean(
+ string="Analytic Group By",
+ compute=lambda x: x._compute_report_option_filter('filter_analytic_groupby'), readonly=False, store=True, depends=['root_report_id', 'section_main_report_ids'],
+ )
+
+ def _get_options_initializers_forced_sequence_map(self):
+ """ Force the sequence for the init_options so columns headers are already generated but not the columns
+ So, between _init_options_column_headers and _init_options_columns"""
+ sequence_map = super(AccountReport, self)._get_options_initializers_forced_sequence_map()
+ sequence_map[self._init_options_analytic_groupby] = 995
+ return sequence_map
+
+ def _init_options_analytic_groupby(self, options, previous_options):
+ if not self.filter_analytic_groupby:
+ return
+ enable_analytic_accounts = self.env.user.has_group('analytic.group_analytic_accounting')
+ if not enable_analytic_accounts:
+ return
+
+ options['display_analytic_groupby'] = True
+ options['display_analytic_plan_groupby'] = True
+
+ options['include_analytic_without_aml'] = previous_options.get('include_analytic_without_aml', False)
+ previous_analytic_accounts = previous_options.get('analytic_accounts_groupby', [])
+ analytic_account_ids = [int(x) for x in previous_analytic_accounts]
+ selected_analytic_accounts = self.env['account.analytic.account'].with_context(active_test=False).search(
+ [('id', 'in', analytic_account_ids)])
+ options['analytic_accounts_groupby'] = selected_analytic_accounts.ids
+ options['selected_analytic_account_groupby_names'] = selected_analytic_accounts.mapped('name')
+
+ previous_analytic_plans = previous_options.get('analytic_plans_groupby', [])
+ analytic_plan_ids = [int(x) for x in previous_analytic_plans]
+ selected_analytic_plans = self.env['account.analytic.plan'].search([('id', 'in', analytic_plan_ids)])
+ options['analytic_plans_groupby'] = selected_analytic_plans.ids
+ options['selected_analytic_plan_groupby_names'] = selected_analytic_plans.mapped('name')
+
+ self._create_column_analytic(options)
+
+ def _init_options_readonly_query(self, options, previous_options):
+ super()._init_options_readonly_query(options, previous_options)
+ options['readonly_query'] = options['readonly_query'] and not options.get('analytic_groupby_option')
+
+ def _create_column_analytic(self, options):
+ """ Creates the analytic columns for each plan or account in the filters.
+ This will duplicate all previous columns and adding the analytic accounts in the domain of the added columns.
+
+ The analytic_groupby_option is used so the table used is the shadowed table.
+ The domain on analytic_distribution can just use simple comparison as the column of the shadowed
+ table will simply be filled with analytic_account_ids.
+ """
+ analytic_headers = []
+ plans = self.env['account.analytic.plan'].browse(options.get('analytic_plans_groupby'))
+ for plan in plans:
+ account_list = []
+ accounts = self.env['account.analytic.account'].search([('plan_id', 'child_of', plan.id)])
+ for account in accounts:
+ account_list.append(account.id)
+ analytic_headers.append({
+ 'name': plan.name,
+ 'forced_options': {
+ 'analytic_groupby_option': True,
+ 'analytic_accounts_list': tuple(account_list), # Analytic accounts used in the domain to filter the lines.
+ 'analytic_plan_id': plan.id,
+ }
+ })
+
+ accounts = self.env['account.analytic.account'].browse(options.get('analytic_accounts_groupby'))
+ for account in accounts:
+ analytic_headers.append({
+ 'name': account.name,
+ 'forced_options': {
+ 'analytic_groupby_option': True,
+ 'analytic_accounts_list': (account.id,),
+ }
+ })
+ if analytic_headers:
+ has_selected_budgets = any([budget for budget in options.get('budgets', []) if budget['selected']])
+ if has_selected_budgets:
+ # if budget is selected, then analytic headers are placed on the same header level
+ options['column_headers'][-1] = analytic_headers + options['column_headers'][-1]
+ else:
+ # We add the analytic layer to the column_headers before creating the columns
+ analytic_headers.append({'name': ''})
+
+ options['column_headers'] = [
+ *options['column_headers'],
+ analytic_headers,
+ ]
+
+ @api.model
+ def _prepare_lines_for_analytic_groupby(self):
+ """Prepare the analytic_temp_account_move_line
+
+ This method should be used once before all the SQL queries using the
+ table account_move_line for the analytic columns for the financial reports.
+ It will create a new table with the schema of account_move_line table, but with
+ the data from account_analytic_line.
+
+ We inherit the schema of account_move_line, make the correspondence between
+ account_move_line fields and account_analytic_line fields and put NULL for those
+ who don't exist in account_analytic_line.
+ We also drop the NOT NULL constraints for fields who are not required in account_analytic_line.
+ """
+ self.env.cr.execute("SELECT 1 FROM information_schema.tables WHERE table_name='analytic_temp_account_move_line'")
+ if self.env.cr.fetchone():
+ return
+
+ project_plan, other_plans = self.env['account.analytic.plan']._get_all_plans()
+ analytic_cols = SQL(", ").join(SQL('"account_analytic_line".%s', SQL.identifier(n._column_name())) for n in (project_plan + other_plans))
+ analytic_distribution_equivalent = SQL('to_jsonb(UNNEST(ARRAY_REMOVE(ARRAY[%s], NULL)))', analytic_cols)
+
+ change_equivalence_dict = {
+ 'id': SQL("account_analytic_line.id"),
+ 'balance': SQL("-amount"),
+ 'display_type': 'product',
+ 'parent_state': 'posted',
+ 'account_id': SQL.identifier("general_account_id"),
+ 'debit': SQL("CASE WHEN (amount < 0) THEN -amount else 0 END"),
+ 'credit': SQL("CASE WHEN (amount > 0) THEN amount else 0 END"),
+ 'analytic_distribution': analytic_distribution_equivalent,
+ 'date': SQL("account_analytic_line.date"),
+ 'company_id': SQL("account_analytic_line.company_id"),
+ }
+
+ all_stored_aml_fields = {
+ field
+ for field, attrs in self.env['account.move.line'].fields_get().items()
+ if attrs['type'] not in ['many2many', 'one2many'] and attrs.get('store')
+ }
+
+ for aml_field in all_stored_aml_fields:
+ if aml_field not in change_equivalence_dict:
+ change_equivalence_dict[aml_field] = SQL('"account_move_line".%s', SQL.identifier(aml_field))
+
+ stored_aml_fields, fields_to_insert = self.env['account.move.line']._prepare_aml_shadowing_for_report(change_equivalence_dict)
+
+ query = SQL("""
+ CREATE OR REPLACE TEMPORARY VIEW analytic_temp_account_move_line (%(stored_aml_fields)s) AS
+ SELECT %(fields_to_insert)s
+ FROM account_analytic_line
+ LEFT JOIN account_move_line
+ ON account_analytic_line.move_line_id = account_move_line.id
+ WHERE
+ account_analytic_line.general_account_id IS NOT NULL;
+ """, stored_aml_fields=stored_aml_fields, fields_to_insert=fields_to_insert)
+
+ self.env.cr.execute(query)
+
+ def _get_report_query(self, options, date_scope, domain=None) -> Query:
+ # Override to add the context key which will eventually trigger the shadowing of the table
+ context_self = self.with_context(account_report_analytic_groupby=options.get('analytic_groupby_option'))
+
+ # We add the domain filter for analytic_distribution here, as the search is not available
+ query = super(AccountReport, context_self)._get_report_query(options, date_scope, domain)
+ if options.get('analytic_accounts'):
+ if 'analytic_accounts_list' in options:
+ # the table will be `analytic_temp_account_move_line` and thus analytic_distribution will be a single ID
+ analytic_account_ids = tuple(str(account_id) for account_id in options['analytic_accounts'])
+ query.add_where(SQL("""account_move_line.analytic_distribution IN %s""", analytic_account_ids))
+ else:
+ # Real `account_move_line` table so real JSON with percentage
+ analytic_account_ids = [[str(account_id) for account_id in options['analytic_accounts']]]
+ query.add_where(SQL('%s && %s', analytic_account_ids, self.env['account.move.line']._query_analytic_accounts()))
+
+ return query
+
+ def action_audit_cell(self, options, params):
+ column_group_options = self._get_column_group_options(options, params['column_group_key'])
+
+ if not column_group_options.get('analytic_groupby_option'):
+ return super(AccountReport, self).action_audit_cell(options, params)
+ else:
+ # Start by getting the domain from the options. Note that this domain is targeting account.move.line
+ report_line = self.env['account.report.line'].browse(params['report_line_id'])
+ expression = report_line.expression_ids.filtered(lambda x: x.label == params['expression_label'])
+ line_domain = self._get_audit_line_domain(column_group_options, expression, params)
+ # The line domain is made for move lines, so we need some postprocessing to have it work with analytic lines.
+ domain = []
+ AccountAnalyticLine = self.env['account.analytic.line']
+ for expression in line_domain:
+ if len(expression) == 1: # For operators such as '&' or '|' we can juste add them again.
+ domain.append(expression)
+ continue
+
+ field, operator, right_term = expression
+ # On analytic lines, the account.account field is named general_account_id and not account_id.
+ if field.split('.')[0] == 'account_id':
+ field = field.replace('account_id', 'general_account_id')
+ expression = [(field, operator, right_term)]
+ # Replace the 'analytic_distribution' by the account_id domain as we expect for analytic lines.
+ elif field == 'analytic_distribution':
+ expression = [('auto_account_id', 'in', right_term)]
+ # For other fields not present in on the analytic line model, map them to get the info from the move_line.
+ # Or ignore these conditions if there is no move lines.
+ elif field.split('.')[0] not in AccountAnalyticLine._fields:
+ expression = [(f'move_line_id.{field}', operator, right_term)]
+ if options.get('include_analytic_without_aml'):
+ expression = osv.expression.OR([
+ [('move_line_id', '=', False)],
+ expression,
+ ])
+ else:
+ expression = [expression] # just for the extend
+ domain.extend(expression)
+
+ action = clean_action(self.env.ref('analytic.account_analytic_line_action_entries')._get_action_dict(), env=self.env)
+ action['domain'] = domain
+ return action
+
+ @api.model
+ def _get_options_journals_domain(self, options):
+ domain = super(AccountReport, self)._get_options_journals_domain(options)
+ # Add False to the domain in order to select lines without journals for analytics columns.
+ if options.get('include_analytic_without_aml'):
+ domain = osv.expression.OR([
+ domain,
+ [('journal_id', '=', False)],
+ ])
+ return domain
+
+ def _get_options_domain(self, options, date_scope):
+ self.ensure_one()
+ domain = super()._get_options_domain(options, date_scope)
+
+ # Get the analytic accounts that we need to filter on from the options and add a domain for them.
+ if 'analytic_accounts_list' in options:
+ domain = osv.expression.AND([
+ domain,
+ [('analytic_distribution', 'in', options.get('analytic_accounts_list', []))],
+ ])
+
+ return domain
+
+
+class AccountMoveLine(models.Model):
+ _inherit = "account.move.line"
+
+ def _where_calc(self, domain, active_test=True):
+ """ In case we need an analytic column in an account_report, we shadow the account_move_line table
+ with a temp table filled with analytic data, that will be used for the analytic columns.
+ We do it in this function to only create and fill it once for all computations of a report.
+ The following analytic columns and computations will just query the shadowed table instead of the real one.
+ """
+ query = super()._where_calc(domain, active_test)
+ if self.env.context.get('account_report_analytic_groupby') and not self.env.context.get('account_report_cash_basis'):
+ self.env['account.report']._prepare_lines_for_analytic_groupby()
+ query._tables['account_move_line'] = SQL.identifier('analytic_temp_account_move_line')
+ return query
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_cash_flow_report.py b/dev_odex30_accounting/odex30_account_reports/models/account_cash_flow_report.py
new file mode 100644
index 0000000..555a497
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_cash_flow_report.py
@@ -0,0 +1,711 @@
+from odoo import models, _
+from odoo.tools import SQL, Query
+
+
+class CashFlowReportCustomHandler(models.AbstractModel):
+ _name = 'account.cash.flow.report.handler'
+ _inherit = 'account.report.custom.handler'
+ _description = 'Cash Flow Report Custom Handler'
+
+ def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None):
+ lines = []
+
+ layout_data = self._get_layout_data()
+ report_data = self._get_report_data(report, options, layout_data)
+
+ for layout_line_id, layout_line_data in layout_data.items():
+ lines.append((0, self._get_layout_line(report, options, layout_line_id, layout_line_data, report_data)))
+
+ if layout_line_id in report_data and 'aml_groupby_account' in report_data[layout_line_id]:
+ aml_data_values = report_data[layout_line_id]['aml_groupby_account'].values()
+
+ aml_data_values_with_account_code = []
+ aml_data_values_without_account_code = []
+
+ for aml_data in aml_data_values:
+ if aml_data['account_code'] is not None:
+ aml_data_values_with_account_code.append(aml_data)
+ else:
+ aml_data_values_without_account_code.append(aml_data)
+
+ for aml_data in (sorted(aml_data_values_with_account_code, key=lambda x: x['account_code'])
+ + aml_data_values_without_account_code):
+ lines.append((0, self._get_aml_line(report, options, aml_data)))
+
+ unexplained_difference_line = self._get_unexplained_difference_line(report, options, report_data)
+
+ if unexplained_difference_line:
+ lines.append((0, unexplained_difference_line))
+
+ return lines
+
+ def _custom_options_initializer(self, report, options, previous_options):
+ super()._custom_options_initializer(report, options, previous_options=previous_options)
+ report._init_options_journals(options, previous_options=previous_options, additional_journals_domain=[('type', 'in', ('bank', 'cash', 'general'))])
+
+ def _get_report_data(self, report, options, layout_data):
+ report_data = {}
+
+ payment_account_ids = self._get_account_ids(report, options)
+ if not payment_account_ids:
+ return report_data
+
+ # Compute 'Cash and cash equivalents, beginning of period'
+ for aml_data in self._compute_liquidity_balance(report, options, payment_account_ids, 'to_beginning_of_period'):
+ self._add_report_data('opening_balance', aml_data, layout_data, report_data)
+ self._add_report_data('closing_balance', aml_data, layout_data, report_data)
+
+ # Compute 'Cash and cash equivalents, closing balance'
+ for aml_data in self._compute_liquidity_balance(report, options, payment_account_ids, 'strict_range'):
+ self._add_report_data('closing_balance', aml_data, layout_data, report_data)
+
+ tags_ids = self._get_tags_ids()
+ cashflow_tag_ids = self._get_cashflow_tag_ids()
+
+ # Process liquidity moves
+ for aml_groupby_account in self._get_liquidity_moves(report, options, payment_account_ids, cashflow_tag_ids):
+ for aml_data in aml_groupby_account.values():
+ self._dispatch_aml_data(tags_ids, aml_data, layout_data, report_data)
+
+ # Process reconciled moves
+ for aml_groupby_account in self._get_reconciled_moves(report, options, payment_account_ids, cashflow_tag_ids):
+ for aml_data in aml_groupby_account.values():
+ self._dispatch_aml_data(tags_ids, aml_data, layout_data, report_data)
+
+ return report_data
+
+ def _add_report_data(self, layout_line_id, aml_data, layout_data, report_data):
+ """
+ Add or update the report_data dictionnary with aml_data.
+
+ report_data is a dictionnary where the keys are keys from _cash_flow_report_get_layout_data() (used for mapping)
+ and the values can contain 2 dictionnaries:
+ * (required) 'balance' where the key is the column_group_key and the value is the balance of the line
+ * (optional) 'aml_groupby_account' where the key is an account_id and the values are the aml data
+ """
+ def _report_update_parent(layout_line_id, aml_column_group_key, aml_balance, layout_data, report_data):
+ # Update the balance in report_data of the parent of the layout_line_id recursively (Stops when the line has no parent)
+ if 'parent_line_id' in layout_data[layout_line_id]:
+ parent_line_id = layout_data[layout_line_id]['parent_line_id']
+
+ report_data.setdefault(parent_line_id, {'balance': {}})
+ report_data[parent_line_id]['balance'].setdefault(aml_column_group_key, 0.0)
+ report_data[parent_line_id]['balance'][aml_column_group_key] += aml_balance
+
+ _report_update_parent(parent_line_id, aml_column_group_key, aml_balance, layout_data, report_data)
+
+ aml_column_group_key = aml_data['column_group_key']
+ aml_account_id = aml_data['account_id']
+ aml_account_code = aml_data['account_code']
+ aml_account_name = aml_data['account_name']
+ aml_balance = aml_data['balance']
+ aml_account_tag = aml_data.get('account_tag_id', None)
+
+ if self.env.company.currency_id.is_zero(aml_balance):
+ return
+
+ report_data.setdefault(layout_line_id, {
+ 'balance': {},
+ 'aml_groupby_account': {},
+ })
+
+ report_data[layout_line_id]['aml_groupby_account'].setdefault(aml_account_id, {
+ 'parent_line_id': layout_line_id,
+ 'account_id': aml_account_id,
+ 'account_code': aml_account_code,
+ 'account_name': aml_account_name,
+ 'account_tag_id': aml_account_tag,
+ 'level': layout_data[layout_line_id]['level'] + 1,
+ 'balance': {},
+ })
+
+ report_data[layout_line_id]['balance'].setdefault(aml_column_group_key, 0.0)
+ report_data[layout_line_id]['balance'][aml_column_group_key] += aml_balance
+
+ report_data[layout_line_id]['aml_groupby_account'][aml_account_id]['balance'].setdefault(aml_column_group_key, 0.0)
+ report_data[layout_line_id]['aml_groupby_account'][aml_account_id]['balance'][aml_column_group_key] += aml_balance
+
+ _report_update_parent(layout_line_id, aml_column_group_key, aml_balance, layout_data, report_data)
+
+ def _get_tags_ids(self):
+ ''' Get a dict to pass on to _dispatch_aml_data containing information mapping account tags to report lines. '''
+ return {
+ 'operating': self.env.ref('account.account_tag_operating').id,
+ 'investing': self.env.ref('account.account_tag_investing').id,
+ 'financing': self.env.ref('account.account_tag_financing').id,
+ }
+
+ def _get_cashflow_tag_ids(self):
+ ''' Get the list of account tags that are relevant for the cash flow report. '''
+ return self._get_tags_ids().values()
+
+ def _dispatch_aml_data(self, tags_ids, aml_data, layout_data, report_data):
+ # Dispatch the aml_data in the correct layout_line
+ if aml_data['account_account_type'] == 'asset_receivable':
+ self._add_report_data('advance_payments_customer', aml_data, layout_data, report_data)
+ elif aml_data['account_account_type'] == 'liability_payable':
+ self._add_report_data('advance_payments_suppliers', aml_data, layout_data, report_data)
+ elif aml_data['balance'] < 0:
+ if aml_data['account_tag_id'] == tags_ids['operating']:
+ self._add_report_data('paid_operating_activities', aml_data, layout_data, report_data)
+ elif aml_data['account_tag_id'] == tags_ids['investing']:
+ self._add_report_data('investing_activities_cash_out', aml_data, layout_data, report_data)
+ elif aml_data['account_tag_id'] == tags_ids['financing']:
+ self._add_report_data('financing_activities_cash_out', aml_data, layout_data, report_data)
+ else:
+ self._add_report_data('unclassified_activities_cash_out', aml_data, layout_data, report_data)
+ elif aml_data['balance'] > 0:
+ if aml_data['account_tag_id'] == tags_ids['operating']:
+ self._add_report_data('received_operating_activities', aml_data, layout_data, report_data)
+ elif aml_data['account_tag_id'] == tags_ids['investing']:
+ self._add_report_data('investing_activities_cash_in', aml_data, layout_data, report_data)
+ elif aml_data['account_tag_id'] == tags_ids['financing']:
+ self._add_report_data('financing_activities_cash_in', aml_data, layout_data, report_data)
+ else:
+ self._add_report_data('unclassified_activities_cash_in', aml_data, layout_data, report_data)
+
+ # -------------------------------------------------------------------------
+ # QUERIES
+ # -------------------------------------------------------------------------
+ def _get_account_ids(self, report, options):
+ ''' Retrieve all accounts to be part of the cash flow statement and also the accounts making them.
+
+ :param options: The report options.
+ :return: payment_account_ids: A tuple containing all account.account's ids being used in a liquidity journal.
+ '''
+ # Fetch liquidity accounts:
+ # Accounts being used by at least one bank/cash journal.
+ selected_journal_ids = [j['id'] for j in report._get_options_journals(options)]
+
+ where_clause = "account_journal.id IN %s" if selected_journal_ids else "account_journal.type IN ('bank', 'cash', 'general')"
+ where_params = [tuple(selected_journal_ids)] if selected_journal_ids else []
+
+ self._cr.execute(f'''
+ SELECT
+ array_remove(ARRAY_AGG(DISTINCT account_account.id), NULL),
+ array_remove(ARRAY_AGG(DISTINCT account_payment_method_line.payment_account_id), NULL)
+ FROM account_journal
+ JOIN res_company
+ ON account_journal.company_id = res_company.id
+ LEFT JOIN account_payment_method_line
+ ON account_journal.id = account_payment_method_line.journal_id
+ LEFT JOIN account_account
+ ON account_journal.default_account_id = account_account.id
+ AND account_account.account_type IN ('asset_cash', 'liability_credit_card')
+ WHERE {where_clause}
+ ''', where_params)
+
+ res = self._cr.fetchall()[0]
+ payment_account_ids = set((res[0] or []) + (res[1] or []))
+
+ if not payment_account_ids:
+ return ()
+
+ return tuple(payment_account_ids)
+
+ def _get_move_ids_query(self, report, payment_account_ids, column_group_options) -> SQL:
+ ''' Get all liquidity moves to be part of the cash flow statement.
+ :param payment_account_ids: A tuple containing all account.account's ids being used in a liquidity journal.
+ :return: query: The SQL query to retrieve the move IDs.
+ '''
+
+ query = report._get_report_query(column_group_options, 'strict_range', [('account_id', 'in', list(payment_account_ids))])
+ return SQL(
+ '''
+ SELECT
+ array_agg(DISTINCT account_move_line.move_id) AS move_id
+ FROM %(table_references)s
+ WHERE %(search_condition)s
+ ''',
+ table_references=query.from_clause,
+ search_condition=query.where_clause,
+ )
+
+ def _compute_liquidity_balance(self, report, options, payment_account_ids, date_scope):
+ ''' Compute the balance of all liquidity accounts to populate the following sections:
+ 'Cash and cash equivalents, beginning of period' and 'Cash and cash equivalents, closing balance'.
+
+ :param options: The report options.
+ :param payment_account_ids: A tuple containing all account.account's ids being used in a liquidity journal.
+ :return: A list of tuple (account_id, account_code, account_name, balance).
+ '''
+ queries = []
+
+ for column_group_key, column_group_options in report._split_options_per_column_group(options).items():
+ query = report._get_report_query(column_group_options, date_scope, domain=[('account_id', 'in', payment_account_ids)])
+ account_alias = query.join(lhs_alias='account_move_line', lhs_column='account_id', rhs_table='account_account', rhs_column='id', link='account_id')
+ account_name = self.env['account.account']._field_to_sql(account_alias, 'name')
+ account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query)
+
+ queries.append(SQL(
+ '''
+ SELECT
+ %(column_group_key)s AS column_group_key,
+ account_move_line.account_id,
+ %(account_code)s AS account_code,
+ %(account_name)s AS account_name,
+ SUM(%(balance_select)s) AS balance
+ FROM %(table_references)s
+ %(currency_table_join)s
+ WHERE %(search_condition)s
+ GROUP BY account_move_line.account_id, %(account_code)s, %(account_name)s
+ ''',
+ column_group_key=column_group_key,
+ account_code=account_code,
+ account_name=account_name,
+ table_references=query.from_clause,
+ balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")),
+ currency_table_join=report._currency_table_aml_join(column_group_options),
+ search_condition=query.where_clause,
+ ))
+
+ self._cr.execute(SQL(' UNION ALL ').join(queries))
+
+ return self._cr.dictfetchall()
+
+ def _get_liquidity_moves(self, report, options, payment_account_ids, cash_flow_tag_ids):
+ ''' Fetch all information needed to compute lines from liquidity moves.
+ The difficulty is to represent only the not-reconciled part of balance.
+
+ :param options: The report options.
+ :param payment_account_ids: A tuple containing all account.account's ids being used in a liquidity journal.
+ :return: A list of tuple (account_id, account_code, account_name, account_type, amount).
+ '''
+
+ reconciled_aml_groupby_account = {}
+
+ queries = []
+
+ for column_group_key, column_group_options in report._split_options_per_column_group(options).items():
+ move_ids_query = self._get_move_ids_query(report, payment_account_ids, column_group_options)
+ query = Query(self.env, 'account_move_line')
+ account_alias = query.join(lhs_alias='account_move_line', lhs_column='account_id', rhs_table='account_account', rhs_column='id', link='account_id')
+ account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query)
+ account_name = self.env['account.account']._field_to_sql(account_alias, 'name')
+ account_type = SQL.identifier(account_alias, 'account_type')
+
+ queries.append(SQL(
+ '''
+ (WITH payment_move_ids AS (%(move_ids_query)s)
+ -- Credit amount of each account
+ SELECT
+ %(column_group_key)s AS column_group_key,
+ account_move_line.account_id,
+ %(account_code)s AS account_code,
+ %(account_name)s AS account_name,
+ %(account_type)s AS account_account_type,
+ account_account_account_tag.account_account_tag_id AS account_tag_id,
+ SUM(%(partial_amount_select)s) AS balance
+ FROM %(from_clause)s
+ %(currency_table_join)s
+ LEFT JOIN account_partial_reconcile
+ ON account_partial_reconcile.credit_move_id = account_move_line.id
+ LEFT JOIN account_account_account_tag
+ ON account_account_account_tag.account_account_id = account_move_line.account_id
+ AND account_account_account_tag.account_account_tag_id IN %(cash_flow_tag_ids)s
+ WHERE account_move_line.move_id IN (SELECT unnest(payment_move_ids.move_id) FROM payment_move_ids)
+ AND account_move_line.account_id NOT IN %(payment_account_ids)s
+ AND account_partial_reconcile.max_date BETWEEN %(date_from)s AND %(date_to)s
+ GROUP BY account_move_line.company_id, account_move_line.account_id, %(account_code)s, %(account_name)s, account_account_type, account_account_account_tag.account_account_tag_id
+
+ UNION ALL
+
+ -- Debit amount of each account
+ SELECT
+ %(column_group_key)s AS column_group_key,
+ account_move_line.account_id,
+ %(account_code)s AS account_code,
+ %(account_name)s AS account_name,
+ %(account_type)s AS account_account_type,
+ account_account_account_tag.account_account_tag_id AS account_tag_id,
+ -SUM(%(partial_amount_select)s) AS balance
+ FROM %(from_clause)s
+ %(currency_table_join)s
+ LEFT JOIN account_partial_reconcile
+ ON account_partial_reconcile.debit_move_id = account_move_line.id
+ LEFT JOIN account_account_account_tag
+ ON account_account_account_tag.account_account_id = account_move_line.account_id
+ AND account_account_account_tag.account_account_tag_id IN %(cash_flow_tag_ids)s
+ WHERE account_move_line.move_id IN (SELECT unnest(payment_move_ids.move_id) FROM payment_move_ids)
+ AND account_move_line.account_id NOT IN %(payment_account_ids)s
+ AND account_partial_reconcile.max_date BETWEEN %(date_from)s AND %(date_to)s
+ GROUP BY account_move_line.company_id, account_move_line.account_id, %(account_code)s, %(account_name)s, account_account_type, account_account_account_tag.account_account_tag_id
+
+ UNION ALL
+
+ -- Total amount of each account
+ SELECT
+ %(column_group_key)s AS column_group_key,
+ account_move_line.account_id AS account_id,
+ %(account_code)s AS account_code,
+ %(account_name)s AS account_name,
+ %(account_type)s AS account_account_type,
+ account_account_account_tag.account_account_tag_id AS account_tag_id,
+ SUM(%(aml_balance_select)s) AS balance
+ FROM %(from_clause)s
+ %(currency_table_join)s
+ LEFT JOIN account_account_account_tag
+ ON account_account_account_tag.account_account_id = account_move_line.account_id
+ AND account_account_account_tag.account_account_tag_id IN %(cash_flow_tag_ids)s
+ WHERE account_move_line.move_id IN (SELECT unnest(payment_move_ids.move_id) FROM payment_move_ids)
+ AND account_move_line.account_id NOT IN %(payment_account_ids)s
+ GROUP BY account_move_line.account_id, %(account_code)s, %(account_name)s, account_account_type, account_account_account_tag.account_account_tag_id)
+ ''',
+ column_group_key=column_group_key,
+ move_ids_query=move_ids_query,
+ account_code=account_code,
+ account_name=account_name,
+ account_type=account_type,
+ from_clause=query.from_clause,
+ currency_table_join=report._currency_table_aml_join(column_group_options),
+ partial_amount_select=report._currency_table_apply_rate(SQL("account_partial_reconcile.amount")),
+ aml_balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")),
+ cash_flow_tag_ids=tuple(cash_flow_tag_ids),
+ payment_account_ids=payment_account_ids,
+ date_from=column_group_options['date']['date_from'],
+ date_to=column_group_options['date']['date_to'],
+ ))
+
+ self._cr.execute(SQL(' UNION ALL ').join(queries))
+
+ for aml_data in self._cr.dictfetchall():
+ reconciled_aml_groupby_account.setdefault(aml_data['account_id'], {})
+ reconciled_aml_groupby_account[aml_data['account_id']].setdefault(aml_data['column_group_key'], {
+ 'column_group_key': aml_data['column_group_key'],
+ 'account_id': aml_data['account_id'],
+ 'account_code': aml_data['account_code'],
+ 'account_name': aml_data['account_name'],
+ 'account_account_type': aml_data['account_account_type'],
+ 'account_tag_id': aml_data['account_tag_id'],
+ 'balance': 0.0,
+ })
+
+ reconciled_aml_groupby_account[aml_data['account_id']][aml_data['column_group_key']]['balance'] -= aml_data['balance']
+
+ return list(reconciled_aml_groupby_account.values())
+
+ def _get_reconciled_moves(self, report, options, payment_account_ids, cash_flow_tag_ids):
+ ''' Retrieve all moves being not a liquidity move to be shown in the cash flow statement.
+ Each amount must be valued at the percentage of what is actually paid.
+ E.g. An invoice of 1000 being paid at 50% must be valued at 500.
+
+ :param options: The report options.
+ :param payment_account_ids: A tuple containing all account.account's ids being used in a liquidity journal.
+ :return: A list of tuple (account_id, account_code, account_name, account_type, amount).
+ '''
+
+ reconciled_account_ids = {column_group_key: set() for column_group_key in options['column_groups']}
+ reconciled_percentage_per_move = {column_group_key: {} for column_group_key in options['column_groups']}
+ currency_table = report._get_currency_table(options)
+
+ queries = []
+
+ for column_group_key, column_group_options in report._split_options_per_column_group(options).items():
+ move_ids_query = self._get_move_ids_query(report, payment_account_ids, column_group_options)
+
+ queries.append(SQL(
+ '''
+ (WITH payment_move_ids AS (%(move_ids_query)s)
+ SELECT
+ %(column_group_key)s AS column_group_key,
+ debit_line.move_id,
+ debit_line.account_id,
+ SUM(%(partial_amount)s) AS balance
+ FROM account_move_line AS credit_line
+ LEFT JOIN account_partial_reconcile
+ ON account_partial_reconcile.credit_move_id = credit_line.id
+ JOIN %(currency_table)s
+ ON account_currency_table.company_id = account_partial_reconcile.company_id
+ AND account_currency_table.rate_type = 'current' -- For payable/receivable accounts it'll always be 'current' anyway
+ INNER JOIN account_move_line AS debit_line
+ ON debit_line.id = account_partial_reconcile.debit_move_id
+ WHERE credit_line.move_id IN (SELECT unnest(payment_move_ids.move_id) FROM payment_move_ids)
+ AND credit_line.account_id NOT IN %(payment_account_ids)s
+ AND credit_line.credit > 0.0
+ AND debit_line.move_id NOT IN (SELECT unnest(payment_move_ids.move_id) FROM payment_move_ids)
+ AND account_partial_reconcile.max_date BETWEEN %(date_from)s AND %(date_to)s
+ GROUP BY debit_line.move_id, debit_line.account_id
+
+ UNION ALL
+
+ SELECT
+ %(column_group_key)s AS column_group_key,
+ credit_line.move_id,
+ credit_line.account_id,
+ -SUM(%(partial_amount)s) AS balance
+ FROM account_move_line AS debit_line
+ LEFT JOIN account_partial_reconcile
+ ON account_partial_reconcile.debit_move_id = debit_line.id
+ JOIN %(currency_table)s
+ ON account_currency_table.company_id = account_partial_reconcile.company_id
+ AND account_currency_table.rate_type = 'current' -- For payable/receivable accounts it'll always be 'current' anyway
+ INNER JOIN account_move_line AS credit_line
+ ON credit_line.id = account_partial_reconcile.credit_move_id
+ WHERE debit_line.move_id IN (SELECT unnest(payment_move_ids.move_id) FROM payment_move_ids)
+ AND debit_line.account_id NOT IN %(payment_account_ids)s
+ AND debit_line.debit > 0.0
+ AND credit_line.move_id NOT IN (SELECT unnest(payment_move_ids.move_id) FROM payment_move_ids)
+ AND account_partial_reconcile.max_date BETWEEN %(date_from)s AND %(date_to)s
+ GROUP BY credit_line.move_id, credit_line.account_id)
+ ''',
+ move_ids_query=move_ids_query,
+ column_group_key=column_group_key,
+ payment_account_ids=payment_account_ids,
+ date_from=column_group_options['date']['date_from'],
+ date_to=column_group_options['date']['date_to'],
+ currency_table=currency_table,
+ partial_amount=report._currency_table_apply_rate(SQL("account_partial_reconcile.amount")),
+ ))
+
+ self._cr.execute(SQL(' UNION ALL ').join(queries))
+
+ for aml_data in self._cr.dictfetchall():
+ reconciled_percentage_per_move[aml_data['column_group_key']].setdefault(aml_data['move_id'], {})
+ reconciled_percentage_per_move[aml_data['column_group_key']][aml_data['move_id']].setdefault(aml_data['account_id'], [0.0, 0.0])
+ reconciled_percentage_per_move[aml_data['column_group_key']][aml_data['move_id']][aml_data['account_id']][0] += aml_data['balance']
+
+ reconciled_account_ids[aml_data['column_group_key']].add(aml_data['account_id'])
+
+ if not reconciled_percentage_per_move:
+ return []
+
+ queries = []
+
+ for column in options['columns']:
+ queries.append(SQL(
+ '''
+ SELECT
+ %(column_group_key)s AS column_group_key,
+ account_move_line.move_id,
+ account_move_line.account_id,
+ SUM(%(balance_select)s) AS balance
+ FROM account_move_line
+ JOIN %(currency_table)s
+ ON account_currency_table.company_id = account_move_line.company_id
+ AND account_currency_table.rate_type = 'current' -- For payable/receivable accounts it'll always be 'current' anyway
+ WHERE account_move_line.move_id IN %(move_ids)s
+ AND account_move_line.account_id IN %(account_ids)s
+ GROUP BY account_move_line.move_id, account_move_line.account_id
+ ''',
+ column_group_key=column['column_group_key'],
+ currency_table=currency_table,
+ balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")),
+ move_ids=tuple(reconciled_percentage_per_move[column['column_group_key']].keys()) or (None,),
+ account_ids=tuple(reconciled_account_ids[column['column_group_key']]) or (None,)
+ ))
+
+ self._cr.execute(SQL(' UNION ALL ').join(queries))
+
+ for aml_data in self._cr.dictfetchall():
+ if aml_data['account_id'] in reconciled_percentage_per_move[aml_data['column_group_key']][aml_data['move_id']]:
+ reconciled_percentage_per_move[aml_data['column_group_key']][aml_data['move_id']][aml_data['account_id']][1] += aml_data['balance']
+
+ reconciled_aml_per_account = {}
+
+ queries = []
+
+ query = Query(self.env, 'account_move_line')
+ account_alias = query.join(lhs_alias='account_move_line', lhs_column='account_id', rhs_table='account_account', rhs_column='id', link='account_id')
+ account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query)
+ account_name = self.env['account.account']._field_to_sql(account_alias, 'name')
+ account_type = SQL.identifier(account_alias, 'account_type')
+
+ for column in options['columns']:
+ queries.append(SQL(
+ '''
+ SELECT
+ %(column_group_key)s AS column_group_key,
+ account_move_line.move_id,
+ account_move_line.account_id,
+ %(account_code)s AS account_code,
+ %(account_name)s AS account_name,
+ %(account_type)s AS account_account_type,
+ account_account_account_tag.account_account_tag_id AS account_tag_id,
+ SUM(%(balance_select)s) AS balance
+ FROM %(from_clause)s
+ %(currency_table_join)s
+ LEFT JOIN account_account_account_tag
+ ON account_account_account_tag.account_account_id = account_move_line.account_id
+ AND account_account_account_tag.account_account_tag_id IN %(cash_flow_tag_ids)s
+ WHERE account_move_line.move_id IN %(move_ids)s
+ GROUP BY account_move_line.move_id, account_move_line.account_id, %(account_code)s, %(account_name)s, account_account_type, account_account_account_tag.account_account_tag_id
+ ''',
+ column_group_key=column['column_group_key'],
+ account_code=account_code,
+ account_name=account_name,
+ account_type=account_type,
+ from_clause=query.from_clause,
+ currency_table_join=report._currency_table_aml_join(options),
+ balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")),
+ cash_flow_tag_ids=tuple(cash_flow_tag_ids),
+ move_ids=tuple(reconciled_percentage_per_move[column['column_group_key']].keys()) or (None,)
+ ))
+
+ self._cr.execute(SQL(' UNION ALL ').join(queries))
+
+ for aml_data in self._cr.dictfetchall():
+ aml_column_group_key = aml_data['column_group_key']
+ aml_move_id = aml_data['move_id']
+ aml_account_id = aml_data['account_id']
+ aml_account_code = aml_data['account_code']
+ aml_account_name = aml_data['account_name']
+ aml_account_account_type = aml_data['account_account_type']
+ aml_account_tag_id = aml_data['account_tag_id']
+ aml_balance = aml_data['balance']
+
+ # Compute the total reconciled for the whole move.
+ total_reconciled_amount = 0.0
+ total_amount = 0.0
+
+ for reconciled_amount, amount in reconciled_percentage_per_move[aml_column_group_key][aml_move_id].values():
+ total_reconciled_amount += reconciled_amount
+ total_amount += amount
+
+ # Compute matched percentage for each account.
+ if total_amount and aml_account_id not in reconciled_percentage_per_move[aml_column_group_key][aml_move_id]:
+ # Lines being on reconciled moves but not reconciled with any liquidity move must be valued at the
+ # percentage of what is actually paid.
+ reconciled_percentage = total_reconciled_amount / total_amount
+ aml_balance *= reconciled_percentage
+ elif not total_amount and aml_account_id in reconciled_percentage_per_move[aml_column_group_key][aml_move_id]:
+ # The total amount to reconcile is 0. In that case, only add entries being on these accounts. Otherwise,
+ # this special case will lead to an unexplained difference equivalent to the reconciled amount on this
+ # account.
+ # E.g:
+ #
+ # Liquidity move:
+ # Account | Debit | Credit
+ # --------------------------------------
+ # Bank | | 100
+ # Receivable | 100 |
+ #
+ # Reconciled move: <- reconciled_amount=100, total_amount=0.0
+ # Account | Debit | Credit
+ # --------------------------------------
+ # Receivable | | 200
+ # Receivable | 200 | <- Only the reconciled part of this entry must be added.
+ aml_balance = -reconciled_percentage_per_move[aml_column_group_key][aml_move_id][aml_account_id][0]
+ else:
+ # Others lines are not considered.
+ continue
+
+ reconciled_aml_per_account.setdefault(aml_account_id, {})
+ reconciled_aml_per_account[aml_account_id].setdefault(aml_column_group_key, {
+ 'column_group_key': aml_column_group_key,
+ 'account_id': aml_account_id,
+ 'account_code': aml_account_code,
+ 'account_name': aml_account_name,
+ 'account_account_type': aml_account_account_type,
+ 'account_tag_id': aml_account_tag_id,
+ 'balance': 0.0,
+ })
+
+ reconciled_aml_per_account[aml_account_id][aml_column_group_key]['balance'] -= aml_balance
+
+ return list(reconciled_aml_per_account.values())
+
+ # -------------------------------------------------------------------------
+ # COLUMNS / LINES
+ # -------------------------------------------------------------------------
+ def _get_layout_data(self):
+ # Indentation of the following dict reflects the structure of the report.
+ return {
+ 'opening_balance': {'name': _('Cash and cash equivalents, beginning of period'), 'level': 0},
+ 'net_increase': {'name': _('Net increase in cash and cash equivalents'), 'level': 0, 'unfolded': True},
+ 'operating_activities': {'name': _('Cash flows from operating activities'), 'level': 2, 'parent_line_id': 'net_increase', 'class': 'fw-bold', 'unfolded': True},
+ 'advance_payments_customer': {'name': _('Advance Payments received from customers'), 'level': 4, 'parent_line_id': 'operating_activities'},
+ 'received_operating_activities': {'name': _('Cash received from operating activities'), 'level': 4, 'parent_line_id': 'operating_activities'},
+ 'advance_payments_suppliers': {'name': _('Advance payments made to suppliers'), 'level': 4, 'parent_line_id': 'operating_activities'},
+ 'paid_operating_activities': {'name': _('Cash paid for operating activities'), 'level': 4, 'parent_line_id': 'operating_activities'},
+ 'investing_activities': {'name': _('Cash flows from investing & extraordinary activities'), 'level': 2, 'parent_line_id': 'net_increase', 'class': 'fw-bold', 'unfolded': True},
+ 'investing_activities_cash_in': {'name': _('Cash in'), 'level': 4, 'parent_line_id': 'investing_activities'},
+ 'investing_activities_cash_out': {'name': _('Cash out'), 'level': 4, 'parent_line_id': 'investing_activities'},
+ 'financing_activities': {'name': _('Cash flows from financing activities'), 'level': 2, 'parent_line_id': 'net_increase', 'class': 'fw-bold', 'unfolded': True},
+ 'financing_activities_cash_in': {'name': _('Cash in'), 'level': 4, 'parent_line_id': 'financing_activities'},
+ 'financing_activities_cash_out': {'name': _('Cash out'), 'level': 4, 'parent_line_id': 'financing_activities'},
+ 'unclassified_activities': {'name': _('Cash flows from unclassified activities'), 'level': 2, 'parent_line_id': 'net_increase', 'class': 'fw-bold', 'unfolded': True},
+ 'unclassified_activities_cash_in': {'name': _('Cash in'), 'level': 4, 'parent_line_id': 'unclassified_activities'},
+ 'unclassified_activities_cash_out': {'name': _('Cash out'), 'level': 4, 'parent_line_id': 'unclassified_activities'},
+ 'closing_balance': {'name': _('Cash and cash equivalents, closing balance'), 'level': 0},
+ }
+
+ def _get_layout_line(self, report, options, layout_line_id, layout_line_data, report_data):
+ line_id = report._get_generic_line_id(None, None, markup=layout_line_id)
+ unfoldable = 'aml_groupby_account' in report_data[layout_line_id] if layout_line_id in report_data else False
+
+ column_values = []
+
+ for column in options['columns']:
+ expression_label = column['expression_label']
+ column_group_key = column['column_group_key']
+
+ value = report_data[layout_line_id][expression_label].get(column_group_key, 0.0) if layout_line_id in report_data else 0.0
+
+ column_values.append(report._build_column_dict(value, column, options=options))
+
+ return {
+ 'id': line_id,
+ 'name': layout_line_data['name'],
+ 'level': layout_line_data['level'],
+ 'class': layout_line_data.get('class', ''),
+ 'columns': column_values,
+ 'unfoldable': unfoldable,
+ 'unfolded': line_id in options['unfolded_lines'] or layout_line_data.get('unfolded') or (options.get('unfold_all') and unfoldable),
+ }
+
+ def _get_aml_line(self, report, options, aml_data):
+ parent_line_id = report._get_generic_line_id(None, None, aml_data['parent_line_id'])
+ line_id = report._get_generic_line_id('account.account', aml_data['account_id'], parent_line_id=parent_line_id)
+
+ column_values = []
+
+ for column in options['columns']:
+ expression_label = column['expression_label']
+ column_group_key = column['column_group_key']
+
+ value = aml_data[expression_label].get(column_group_key, 0.0)
+
+ column_values.append(report._build_column_dict(value, column, options=options))
+
+ return {
+ 'id': line_id,
+ 'name': f"{aml_data['account_code']} {aml_data['account_name']}" if aml_data['account_code'] else aml_data['account_name'],
+ 'caret_options': 'account.account',
+ 'level': aml_data['level'],
+ 'parent_id': parent_line_id,
+ 'columns': column_values,
+ }
+
+ def _get_unexplained_difference_line(self, report, options, report_data):
+ unexplained_difference = False
+ column_values = []
+
+ for column in options['columns']:
+ expression_label = column['expression_label']
+ column_group_key = column['column_group_key']
+
+ opening_balance = report_data['opening_balance'][expression_label].get(column_group_key, 0.0) if 'opening_balance' in report_data else 0.0
+ closing_balance = report_data['closing_balance'][expression_label].get(column_group_key, 0.0) if 'closing_balance' in report_data else 0.0
+ net_increase = report_data['net_increase'][expression_label].get(column_group_key, 0.0) if 'net_increase' in report_data else 0.0
+
+ balance = closing_balance - opening_balance - net_increase
+
+ if not self.env.company.currency_id.is_zero(balance):
+ unexplained_difference = True
+
+ column_values.append(report._build_column_dict(
+ balance,
+ {
+ 'figure_type': 'monetary',
+ 'expression_label': 'balance',
+ },
+ options=options,
+ ))
+
+ if unexplained_difference:
+ return {
+ 'id': report._get_generic_line_id(None, None, markup='unexplained_difference'),
+ 'name': 'Unexplained Difference',
+ 'level': 1,
+ 'columns': column_values,
+ }
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_customer_statement.py b/dev_odex30_accounting/odex30_account_reports/models/account_customer_statement.py
new file mode 100644
index 0000000..c53756b
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_customer_statement.py
@@ -0,0 +1,24 @@
+from odoo import models, _
+
+
+class CustomerStatementCustomHandler(models.AbstractModel):
+ _name = 'account.customer.statement.report.handler'
+ _inherit = 'account.partner.ledger.report.handler'
+ _description = 'Customer Statement Custom Handler'
+
+ def _get_custom_display_config(self):
+ display_config = super()._get_custom_display_config()
+ display_config['css_custom_class'] += ' customer_statement'
+ if self.env.ref('odex30_account_reports.pdf_export_main_customer_report', raise_if_not_found=False):
+ display_config.setdefault('pdf_export', {})['pdf_export_main'] = 'odex30_account_reports.pdf_export_main_customer_report'
+ return display_config
+
+ def _custom_options_initializer(self, report, options, previous_options):
+ super()._custom_options_initializer(report, options, previous_options)
+
+ options['buttons'].append({
+ 'name': _('Send'),
+ 'action': 'action_send_statements',
+ 'sequence': 90,
+ 'always_show': True,
+ })
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_deferred_reports.py b/dev_odex30_accounting/odex30_account_reports/models/account_deferred_reports.py
new file mode 100644
index 0000000..9732ba6
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_deferred_reports.py
@@ -0,0 +1,659 @@
+import calendar
+from collections import defaultdict
+from dateutil.relativedelta import relativedelta
+
+from odoo import models, fields, _, api, Command
+from odoo.exceptions import UserError
+from odoo.tools import groupby, SQL
+from odoo.addons.odex30_account_accountant.models.account_move import DEFERRED_DATE_MIN, DEFERRED_DATE_MAX
+
+
+class DeferredReportCustomHandler(models.AbstractModel):
+ _name = 'account.deferred.report.handler'
+ _inherit = 'account.report.custom.handler'
+ _description = 'Deferred Expense Report Custom Handler'
+
+ def _get_deferred_report_type(self):
+ raise NotImplementedError("This method is not implemented in the deferred report handler.")
+
+
+ def _get_domain_fully_inside_period(self, options):
+ return [ # Exclude if entirely inside the period
+ '!', '&', '&', '&', '&', '&', '&', '&',
+ ('deferred_start_date', '!=', False),
+ ('deferred_end_date', '!=', False),
+ ('deferred_start_date', '>=', options['date']['date_from']),
+ ('deferred_start_date', '<=', options['date']['date_to']),
+ ('deferred_end_date', '>=', options['date']['date_from']),
+ ('deferred_end_date', '<=', options['date']['date_to']),
+ ('move_id.date', '>=', options['date']['date_from']),
+ ('move_id.date', '<=', options['date']['date_to']),
+ ]
+
+ def _get_domain(self, report, options, filter_already_generated=False, filter_not_started=False):
+ domain = report._get_options_domain(options, "from_beginning")
+ account_types = ('expense', 'expense_depreciation', 'expense_direct_cost') if self._get_deferred_report_type() == 'expense' else ('income', 'income_other')
+ domain += [
+ ('account_id.account_type', 'in', account_types),
+ ('deferred_start_date', '!=', False),
+ ('deferred_end_date', '!=', False),
+ ('deferred_end_date', '>=', options['date']['date_from']),
+ ('move_id.date', '<=', options['date']['date_to']),
+ ]
+ domain += self._get_domain_fully_inside_period(options)
+ if filter_already_generated:
+ # Avoid regenerating already generated deferrals
+ domain += [
+ ('deferred_end_date', '>=', options['date']['date_from']),
+ '!',
+ ('move_id.deferred_move_ids', 'any', [
+ ('date', '=', options['date']['date_to']),
+ '|',
+ ('state', '=', 'posted'), # Either posted
+ '&', # Or autoposted in the future
+ ('auto_post', '=', 'at_date'),
+ ('date', '>=', fields.Date.context_today(self)),
+ ])
+ ]
+ if filter_not_started:
+ domain += [('deferred_start_date', '>', options['date']['date_to'])]
+ return domain
+
+ @api.model
+ def _get_select(self, options):
+ account_name = self.env['account.account']._field_to_sql('account_move_line__account_id', 'name')
+ return [
+ SQL("account_move_line.id AS line_id"),
+ SQL("account_move_line.account_id AS account_id"),
+ SQL("account_move_line.partner_id AS partner_id"),
+ SQL("account_move_line.product_id AS product_id"),
+ SQL("account_move_line__product_template_id.categ_id AS product_category_id"),
+ SQL("account_move_line.name AS line_name"),
+ SQL("account_move_line.deferred_start_date AS deferred_start_date"),
+ SQL("account_move_line.deferred_end_date AS deferred_end_date"),
+ SQL("account_move_line.deferred_end_date - account_move_line.deferred_start_date AS diff_days"),
+ SQL("account_move_line.balance AS balance"),
+ SQL("account_move_line.analytic_distribution AS analytic_distribution"),
+ SQL("account_move_line__move_id.id as move_id"),
+ SQL("account_move_line__move_id.name AS move_name"),
+ SQL("""
+ NOT (
+ account_move_line.deferred_end_date >= %(report_date_from)s
+ AND
+ NOT EXISTS (
+ SELECT 1
+ FROM account_move_deferred_rel AS amdr
+ LEFT JOIN account_move AS am ON amdr.deferred_move_id = am.id
+ WHERE amdr.original_move_id = account_move_line.move_id
+ AND am.date = %(report_date_to)s
+ AND (
+ am.state = 'posted'
+ OR (am.auto_post = 'at_date' AND am.date >= %(today)s)
+ )
+ )
+ ) AS is_already_generated
+ """,
+ report_date_from=options['date']['date_from'],
+ report_date_to=options['date']['date_to'],
+ today=fields.Date.context_today(self),
+ ),
+ SQL("%s AS account_name", account_name),
+ ]
+
+ def _get_lines(self, report, options, filter_already_generated=False):
+ if 'report_deferred_lines' not in self.env.cr.cache:
+ self._fetch_lines(report, options, filter_already_generated)
+
+ if not filter_already_generated:
+ # No more filtering needed, we can reuse the cached result
+ return self.env.cr.cache['report_deferred_lines'].values()
+ else:
+ # Filter the cached result to only keep the lines that are not already generated
+ cached_lines = self.env.cr.cache['report_deferred_lines'].values()
+ return [cached_line for cached_line in cached_lines if not cached_line['is_already_generated']]
+
+ def _fetch_lines(self, report, options, filter_already_generated):
+ """Fetch the lines that need to be deferred from the DB and store them in the cache for later reuse"""
+ domain = self._get_domain(report, options, filter_already_generated)
+ query = report._get_report_query(options, domain=domain, date_scope='from_beginning')
+ select_clause = SQL(', ').join(self._get_select(options))
+
+ query = SQL(
+ """
+ SELECT %(select_clause)s
+ FROM %(table_references)s
+ LEFT JOIN product_product AS account_move_line__product_id ON account_move_line.product_id = account_move_line__product_id.id
+ LEFT JOIN product_template AS account_move_line__product_template_id ON account_move_line__product_id.product_tmpl_id = account_move_line__product_template_id.id
+ WHERE %(search_condition)s
+ ORDER BY account_move_line.deferred_start_date, account_move_line.id
+ """,
+ select_clause=select_clause,
+ table_references=query.from_clause,
+ search_condition=query.where_clause,
+ )
+
+ self.env.cr.execute(query)
+ # Cache the result so that it can be reused to check whether a warning banner should be shown
+ # only if it's the generic query (so without filtering already generated deferrals)
+ self.env.cr.cache['report_deferred_lines'] = {
+ r['line_id']: r for r in self.env.cr.dictfetchall()
+ }
+
+ @api.model
+ def _get_grouping_fields_deferred_lines(self, filter_already_generated=False, grouping_field='account_id'):
+ return (grouping_field,)
+
+ @api.model
+ def _group_by_deferred_fields(self, line, filter_already_generated=False, grouping_field='account_id'):
+ return tuple(line[k] for k in self._get_grouping_fields_deferred_lines(filter_already_generated, grouping_field))
+
+ @api.model
+ def _get_grouping_fields_deferral_lines(self):
+ return ()
+
+ @api.model
+ def _group_by_deferral_fields(self, line):
+ return tuple(line[k] for k in self._get_grouping_fields_deferral_lines())
+
+ @api.model
+ def _group_deferred_amounts_by_grouping_field(self, deferred_amounts_by_line, periods, is_reverse, filter_already_generated=False, grouping_field='account_id'):
+ """
+ Groups the deferred amounts by account and computes the totals for each account for each period.
+ And the total for all accounts for each period.
+ E.g. (where period1 = (date1, date2, label1), period2 = (date2, date3, label2), ...)
+ {
+ self._get_grouping_keys_deferred_lines(): {
+ 'account_id': account1, 'amount_total': 600, period_1: 200, period_2: 400
+ },
+ self._get_grouping_keys_deferred_lines(): {
+ 'account_id': account2, 'amount_total': 700, period_1: 300, period_2: 400
+ },
+ }, {'totals_aggregated': 1300, period_1: 500, period_2: 800}
+ """
+ deferred_amounts_by_line = groupby(deferred_amounts_by_line, key=lambda x: self._group_by_deferred_fields(x, filter_already_generated, grouping_field))
+ totals_per_key = {} # {key: {**self._get_grouping_fields_deferral_lines(), total, before, current, later}}
+ totals_aggregated_by_period = {period: 0 for period in periods + ['totals_aggregated']}
+ sign = 1 if is_reverse else -1
+ for key, lines_per_key in deferred_amounts_by_line:
+ lines_per_key = list(lines_per_key)
+ current_key_totals = self._get_current_key_totals_dict(lines_per_key, sign)
+ totals_aggregated_by_period['totals_aggregated'] += current_key_totals['amount_total']
+ for period in periods:
+ current_key_totals[period] = sign * sum(line[period] for line in lines_per_key)
+ totals_aggregated_by_period[period] += self.env.company.currency_id.round(current_key_totals[period])
+ totals_per_key[key] = current_key_totals
+ return totals_per_key, totals_aggregated_by_period
+
+ ###########################
+ # DEFERRED REPORT DISPLAY #
+ ###########################
+
+ def _get_custom_display_config(self):
+ return {
+ 'templates': {
+ 'AccountReportFilters': 'odex30_account_reports.DeferredFilters',
+ },
+ }
+
+ def _custom_options_initializer(self, report, options, previous_options):
+ super()._custom_options_initializer(report, options, previous_options=previous_options)
+
+ options_per_col_group = report._split_options_per_column_group(options)
+ for column_dict in options['columns']:
+ column_options = options_per_col_group[column_dict['column_group_key']]
+ column_dict['name'] = column_options['date']['string']
+ column_dict['date_from'] = column_options['date']['date_from']
+ column_dict['date_to'] = column_options['date']['date_to']
+
+ options['columns'] = list(reversed(options['columns']))
+ total_column = [{
+ **options['columns'][0],
+ 'name': _('Total'),
+ 'expression_label': 'total',
+ 'date_from': DEFERRED_DATE_MIN,
+ 'date_to': DEFERRED_DATE_MAX,
+ }]
+ not_started_column = [{
+ **options['columns'][0],
+ 'name': _('Not Started'),
+ 'expression_label': 'not_started',
+ 'date_from': options['columns'][-1]['date_to'],
+ 'date_to': DEFERRED_DATE_MAX,
+ }]
+ before_column = [{
+ **options['columns'][0],
+ 'name': _('Before'),
+ 'expression_label': 'before',
+ 'date_from': DEFERRED_DATE_MIN,
+ 'date_to': fields.Date.to_string(fields.Date.to_date(options['columns'][0]['date_from']) - relativedelta(days=1)),
+ }]
+ later_column = [{
+ **options['columns'][0],
+ 'name': _('Later'),
+ 'expression_label': 'later',
+ 'date_from': fields.Date.to_string(fields.Date.to_date(options['columns'][-1]['date_to']) + relativedelta(days=1)),
+ 'date_to': DEFERRED_DATE_MAX,
+ }]
+ options['columns'] = total_column + not_started_column + before_column + options['columns'] + later_column
+ options['column_headers'] = []
+ options['deferred_report_type'] = self._get_deferred_report_type()
+ options['deferred_grouping_field'] = previous_options.get('deferred_grouping_field') or 'account_id'
+ if (
+ self._get_deferred_report_type() == 'expense' and self.env.company.generate_deferred_expense_entries_method == 'manual'
+ or self._get_deferred_report_type() == 'revenue' and self.env.company.generate_deferred_revenue_entries_method == 'manual'
+ ):
+ options['buttons'].append({'name': _('Generate entry'), 'action': 'action_generate_entry', 'sequence': 80, 'always_show': True})
+
+ def action_audit_cell(self, options, params):
+ """ Open a list of invoices/bills and/or deferral entries for the clicked cell in a deferred report.
+
+ Specifically, we show the following lines, grouped by their journal entry, filtered by the column date bounds:
+ - Total: Lines of all invoices/bills being deferred in the current period
+ - Not Started: Lines of all deferral entries for which the original invoice/bill date is before or in the
+ current period, but the deferral only starts after the current period, as well as the lines of
+ their original invoices/bills
+ - Before: Lines of all deferral entries with a date before the current period, created by invoices/bills also
+ being deferred in the current period, as well as the lines of their original invoices/bills
+ - Current: Lines of all deferral entries in the current period, as well as these of their original
+ invoices/bills
+ - Later: Lines of all deferral entries with a date after the current period, created by invoices/bills also
+ being deferred in the current period, as well as the lines of their original invoices/bills
+
+ :param dict options: the report's `options`
+ :param dict params: a dict containing:
+ `calling_line_dict_id`: line id containing the optional account of the cell
+ `column_group_id`: the column group id of the cell
+ `expression_label`: the expression label of the cell
+ """
+ report = self.env['account.report'].browse(options['report_id'])
+ column_values = next(
+ (column for column in options['columns'] if (
+ column['column_group_key'] == params.get('column_group_key')
+ and column['expression_label'] == params.get('expression_label')
+ )),
+ None
+ )
+ if not column_values:
+ return
+
+ column_date_from = fields.Date.to_date(column_values['date_from'])
+ column_date_to = fields.Date.to_date(column_values['date_to'])
+ report_date_from = fields.Date.to_date(options['date']['date_from'])
+ report_date_to = fields.Date.to_date(options['date']['date_to'])
+
+ # Corrections for comparisons
+ if column_values['expression_label'] in ('not_started', 'later'):
+ # Not Started and Later period start one day after `report_date_to`
+ column_date_from = report_date_to + relativedelta(days=1)
+ if column_values['expression_label'] == 'before':
+ # Before period ends one day before `report_date_from`
+ column_date_to = report_date_from - relativedelta(days=1)
+
+ # calling_line_dict_id is of the format `~account.report~15|~account.account~25`
+ _grouping_model, grouping_record_id = report._get_model_info_from_id(params.get('calling_line_dict_id'))
+
+ # Find the original lines to be deferred in the report period
+ original_move_lines_domain = self._get_domain(
+ report, options, filter_not_started=column_values['expression_label'] == 'not_started'
+ )
+ if grouping_record_id:
+ # We're auditing a specific account, so we only want moves containing this account
+ original_move_lines_domain.append((options['deferred_grouping_field'], '=', grouping_record_id))
+ # We're getting all lines from the concerned moves. They are filtered later for flexibility.
+ original_moves = self.env['account.move.line'].search(original_move_lines_domain).move_id
+
+ domain = [
+ # For the Total period only show the original move lines
+ '&',
+ ('move_id', 'in', original_moves.ids),
+ ('deferred_end_date', '>=', report_date_from),
+ ]
+
+ # Show both the original move lines and deferral move lines for all other periods
+ if column_values['expression_label'] != 'total' and original_moves.deferred_move_ids:
+ domain = ['|'] + [('move_id', 'in', original_moves.deferred_move_ids.ids)] + domain
+
+ if column_values['expression_label'] == 'not_started':
+ domain += [('deferred_start_date', '>=', column_date_from)]
+ else:
+ # If in manually & grouped mode, and deferrals have not yet been generated
+ # so no move with `date` set => instead show the candidates original deferred moves that
+ # will be deferred upon clicking the button. If totally/partially generated, we'll just
+ # use the `date` filter which will include both the originals and deferrals.
+ if not original_moves.deferred_move_ids:
+ domain += [
+ ('deferred_start_date', '<=', column_date_to),
+ ('deferred_end_date', '>=', column_date_from),
+ ]
+ else:
+ domain += [
+ ('date', '>=', column_date_from),
+ ('date', '<=', column_date_to),
+ ]
+ domain += self._get_domain_fully_inside_period(options)
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Deferred Entries'),
+ 'res_model': 'account.move.line',
+ 'domain': domain,
+ 'views': [(self.env.ref('odex30_account_accountant.view_deferred_entries_tree').id, 'list'), (False, 'pivot'), (False, 'graph'), (False, 'kanban')],
+ # Most filters are set here to allow auditing flexibility to the user
+ 'context': {
+ 'search_default_pl_accounts': True,
+ f'search_default_{options["deferred_grouping_field"]}': grouping_record_id,
+ 'expand': True,
+ },
+ }
+
+ def _caret_options_initializer(self):
+ return {
+ 'deferred_caret': [
+ {'name': _("Journal Items"), 'action': 'open_journal_items'},
+ ],
+ }
+
+ def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings):
+ if (
+ self._get_deferred_report_type() == 'expense' and self.env.company.generate_deferred_expense_entries_method == 'manual'
+ or self._get_deferred_report_type() == 'revenue' and self.env.company.generate_deferred_revenue_entries_method == 'manual'
+ ):
+ already_generated = self.env['account.move'].search_count(
+ report._get_generated_deferral_entries_domain(options)
+ )
+ # This will trigger a second _get_lines call, however the first one was cached, so we just need to filter again on the cache (see _get_lines)
+ moves_lines_to_generate, __, __, __, __ = self._get_moves_to_defer(options)
+ if moves_lines_to_generate and already_generated:
+ warnings['odex30_account_reports.deferred_report_warning_partially_generated'] = {'alert_type': 'warning'}
+ elif moves_lines_to_generate:
+ warnings['odex30_account_reports.deferred_report_warning_never_generated'] = {'alert_type': 'warning'}
+ elif already_generated:
+ warnings['odex30_account_reports.deferred_report_info_fully_generated'] = {'alert_type': 'info'}
+
+
+ def open_journal_items(self, options, params):
+ report = self.env['account.report'].browse(options['report_id'])
+ record_model, record_id = report._get_model_info_from_id(params.get('line_id'))
+ domain = self._get_domain(report, options)
+ if record_model == 'account.account' and record_id:
+ domain += [('account_id', '=', record_id)]
+ elif record_model == 'product.product' and record_id:
+ domain += [('product_id', '=', record_id)]
+ elif record_model == 'product.category' and record_id:
+ domain += [('product_category_id', '=', record_id)]
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _("Deferred Entries"),
+ 'res_model': 'account.move.line',
+ 'domain': domain,
+ 'views': [(self.env.ref('odex30_account_accountant.view_deferred_entries_tree').id, 'list')],
+ 'context': {
+ 'search_default_group_by_move': True,
+ 'expand': True,
+ }
+ }
+
+ def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None):
+ def get_columns(totals):
+ return [
+ {
+ **report._build_column_dict(
+ totals[(
+ fields.Date.to_date(column['date_from']),
+ fields.Date.to_date(column['date_to']),
+ column['expression_label']
+ )],
+ column,
+ options=options,
+ currency=self.env.company.currency_id,
+ ),
+ 'auditable': True,
+ }
+ for column in options['columns']
+ ]
+
+ lines = self._get_lines(report, options)
+ periods = [
+ (
+ fields.Date.from_string(column['date_from']),
+ fields.Date.from_string(column['date_to']),
+ column['expression_label'],
+ )
+ for column in options['columns']
+ ]
+ deferred_amounts_by_line = self.env['account.move']._get_deferred_amounts_by_line(lines, periods, self._get_deferred_report_type())
+ totals_per_grouping_field, totals_all_grouping_field = self._group_deferred_amounts_by_grouping_field(
+ deferred_amounts_by_line=deferred_amounts_by_line,
+ periods=periods,
+ is_reverse=self._get_deferred_report_type() == 'expense',
+ filter_already_generated=False,
+ grouping_field=options['deferred_grouping_field'],
+ )
+
+ report_lines = []
+ grouping_model = self.env['account.move.line'][options['deferred_grouping_field']]._name
+ for totals_grouping_field in totals_per_grouping_field.values():
+ grouping_record = self.env[grouping_model].browse(totals_grouping_field[options['deferred_grouping_field']])
+ grouping_field_description = self.env['account.move.line'][options['deferred_grouping_field']]._description
+ if options['deferred_grouping_field'] == 'product_id':
+ grouping_field_description = _("Product")
+ grouping_name = grouping_record.display_name or _("(No %s)", grouping_field_description)
+ report_lines.append((0, {
+ 'id': report._get_generic_line_id(grouping_model, grouping_record.id),
+ 'name': grouping_name,
+ 'caret_options': 'deferred_caret',
+ 'level': 1,
+ 'columns': get_columns(totals_grouping_field),
+ }))
+ if totals_per_grouping_field:
+ report_lines.append((0, {
+ 'id': report._get_generic_line_id(None, None, markup='total'),
+ 'name': 'Total',
+ 'level': 1,
+ 'columns': get_columns(totals_all_grouping_field),
+ }))
+
+ return report_lines
+
+ #######################
+ # DEFERRED GENERATION #
+ #######################
+
+ def action_generate_entry(self, options):
+ new_deferred_moves = self._generate_deferral_entry(options)
+ return {
+ 'name': _('Deferred Entries'),
+ 'type': 'ir.actions.act_window',
+ 'views': [(False, "list"), (False, "form")],
+ 'domain': [('id', 'in', new_deferred_moves.ids)],
+ 'res_model': 'account.move',
+ 'context': {
+ 'search_default_group_by_move': True,
+ 'expand': True,
+ },
+ 'target': 'current',
+ }
+
+ def _get_moves_to_defer(self, options):
+ date_from = fields.Date.to_date(DEFERRED_DATE_MIN)
+ date_to = fields.Date.from_string(options['date']['date_to'])
+ if date_to.day != calendar.monthrange(date_to.year, date_to.month)[1]:
+ raise UserError(_("You cannot generate entries for a period that does not end at the end of the month."))
+ options['all_entries'] = False # We only want to create deferrals for posted moves
+ report = self.env["account.report"].browse(options["report_id"])
+ self.env['account.move.line'].flush_model()
+ lines = self._get_lines(report, options, filter_already_generated=True)
+ deferral_entry_period = self.env['account.report']._get_dates_period(date_from, date_to, 'range', period_type='month')
+ ref = _("Grouped Deferral Entry of %s", deferral_entry_period['string'])
+ ref_rev = _("Reversal of Grouped Deferral Entry of %s", deferral_entry_period['string'])
+ deferred_account = self.env.company.deferred_expense_account_id if self._get_deferred_report_type() == 'expense' else self.env.company.deferred_revenue_account_id
+ move_lines, original_move_ids = self._get_deferred_lines(lines, deferred_account, (date_from, date_to, 'current'), self._get_deferred_report_type() == 'expense', ref)
+ return move_lines, original_move_ids, ref, ref_rev, date_to
+
+ def _generate_deferral_entry(self, options):
+ journal = self.env.company.deferred_expense_journal_id if self._get_deferred_report_type() == "expense" else self.env.company.deferred_revenue_journal_id
+ if not journal:
+ raise UserError(_("Please set the deferred journal in the accounting settings."))
+ move_lines, original_move_ids, ref, ref_rev, date_to = self._get_moves_to_defer(options)
+ if self.env.company._get_violated_lock_dates(date_to, False, journal):
+ raise UserError(_("You cannot generate entries for a period that is locked."))
+ if not move_lines:
+ raise UserError(_("No entry to generate."))
+
+ deferred_move = self.env['account.move'].with_context(skip_account_deprecation_check=True).create({
+ 'move_type': 'entry',
+ 'deferred_original_move_ids': [Command.set(original_move_ids)],
+ 'journal_id': journal.id,
+ 'date': date_to,
+ 'auto_post': 'at_date',
+ 'ref': ref,
+ })
+ # We write the lines after creation, to make sure the `deferred_original_move_ids` is set.
+ # This way we can avoid adding taxes for deferred moves.
+ deferred_move.write({'line_ids': move_lines})
+ reverse_move = deferred_move._reverse_moves()
+ reverse_move.write({
+ 'date': deferred_move.date + relativedelta(days=1),
+ 'ref': ref_rev,
+ })
+ reverse_move.line_ids.name = ref_rev
+ new_deferred_moves = deferred_move + reverse_move
+ # We create the relation (original deferred move, deferral entry)
+ # using SQL. This avoids a MemoryError using the ORM which will
+ # load huge amounts of moves in memory for nothing
+ self.env.cr.execute_values("""
+ INSERT INTO account_move_deferred_rel(original_move_id, deferred_move_id)
+ VALUES %s
+ ON CONFLICT DO NOTHING
+ """, [
+ (original_move_id, deferral_move.id)
+ for original_move_id in original_move_ids
+ for deferral_move in new_deferred_moves
+ ])
+ new_deferred_moves.invalidate_recordset()
+ new_deferred_moves._post(soft=True)
+ return new_deferred_moves
+
+ @api.model
+ def _get_current_key_totals_dict(self, lines_per_key, sign):
+ return {
+ 'account_id': lines_per_key[0]['account_id'],
+ 'product_id': lines_per_key[0]['product_id'],
+ 'product_category_id': lines_per_key[0]['product_category_id'],
+ 'amount_total': sign * sum(line['balance'] for line in lines_per_key),
+ 'move_ids': {line['move_id'] for line in lines_per_key},
+ }
+
+ @api.model
+ def _get_deferred_lines(self, lines, deferred_account, period, is_reverse, ref):
+ """
+ Returns a list of Command objects to create the deferred lines of a single given period.
+ And the move_ids of the original lines that created these deferred
+ (to keep track of the original invoice in the deferred entries).
+ """
+ if not deferred_account:
+ raise UserError(_("Please set the deferred accounts in the accounting settings."))
+
+ deferred_amounts_by_line = self.env['account.move']._get_deferred_amounts_by_line(lines, [period], is_reverse)
+ deferred_amounts_by_key, deferred_amounts_totals = self._group_deferred_amounts_by_grouping_field(deferred_amounts_by_line, [period], is_reverse, filter_already_generated=True)
+ totals_aggregated = deferred_amounts_totals['totals_aggregated']
+ if totals_aggregated == deferred_amounts_totals[period]:
+ return [], set()
+
+ # compute analytic distribution to populate on deferred lines
+ # structure: {self._get_grouping_keys_deferred_lines(): [analytic distribution]}
+ # dict of keys: self._get_grouping_keys_deferred_lines()
+ # values: dict of keys: "account.analytic.account.id" (string)
+ # values: float
+ anal_dist_by_key = defaultdict(lambda: defaultdict(float))
+ # using another var for the analytic distribution of the deferral account
+ deferred_anal_dist = defaultdict(lambda: defaultdict(float))
+ for line in lines:
+ if not line['analytic_distribution']:
+ continue
+ # Analytic distribution should be computed from the lines with the same _get_grouping_keys_deferred_lines(), except for
+ # the deferred line with the deferral account which will use _get_grouping_fields_deferral_lines()
+ sign = 1 if is_reverse else -1
+ key_amount = deferred_amounts_by_key.get(self._group_by_deferred_fields(line, True))
+ total_amount = key_amount.get('amount_total')
+ key_ratio = sign * line['balance'] / total_amount if total_amount else 0
+ full_ratio = sign * line['balance'] / totals_aggregated if totals_aggregated else 0
+
+ for account_id, distribution in line['analytic_distribution'].items():
+ anal_dist_by_key[self._group_by_deferred_fields(line, True)][account_id] += distribution * key_ratio
+ deferred_anal_dist[self._group_by_deferral_fields(line)][account_id] += distribution * full_ratio
+
+ remaining_balance = 0
+ deferred_lines = []
+ original_move_ids = set()
+ for key, line in deferred_amounts_by_key.items():
+ for balance in (-line['amount_total'], line[period]):
+ if balance != 0 and line[period] != line['amount_total']:
+ original_move_ids |= line['move_ids']
+ deferred_balance = self.env.company.currency_id.round((1 if is_reverse else -1) * balance)
+ deferred_lines.append(
+ Command.create(
+ self.env['account.move.line']._get_deferred_lines_values(
+ account_id=line['account_id'],
+ balance=deferred_balance,
+ ref=ref,
+ analytic_distribution=anal_dist_by_key[key] or False,
+ line=line,
+ )
+ )
+ )
+ remaining_balance += deferred_balance
+
+ grouped_by_key = {
+ key: list(value)
+ for key, value in groupby(
+ deferred_amounts_by_key.values(),
+ key=self._group_by_deferral_fields,
+ )
+ }
+ deferral_lines = []
+ for key, lines_per_key in grouped_by_key.items():
+ balance = 0
+ for line in lines_per_key:
+ if line[period] != line['amount_total']:
+ balance += self.env.company.currency_id.round((1 if is_reverse else -1) * (line['amount_total'] - line[period]))
+ deferral_lines.append(
+ Command.create(
+ self.env['account.move.line']._get_deferred_lines_values(
+ account_id=deferred_account.id,
+ balance=balance,
+ ref=ref,
+ analytic_distribution=deferred_anal_dist[key] or False,
+ line=lines_per_key[0],
+ )
+ )
+ )
+ remaining_balance += balance
+
+ if not self.env.company.currency_id.is_zero(remaining_balance):
+ deferral_lines.append(
+ Command.create({
+ 'account_id': deferred_account.id,
+ 'balance': -remaining_balance,
+ 'name': ref,
+ })
+ )
+ return deferred_lines + deferral_lines, original_move_ids
+
+
+class DeferredExpenseCustomHandler(models.AbstractModel):
+ _name = 'account.deferred.expense.report.handler'
+ _inherit = 'account.deferred.report.handler'
+ _description = 'Deferred Expense Custom Handler'
+
+ def _get_deferred_report_type(self):
+ return 'expense'
+
+
+class DeferredRevenueCustomHandler(models.AbstractModel):
+ _name = 'account.deferred.revenue.report.handler'
+ _inherit = 'account.deferred.report.handler'
+ _description = 'Deferred Revenue Custom Handler'
+
+ def _get_deferred_report_type(self):
+ return 'revenue'
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_fiscal_position.py b/dev_odex30_accounting/odex30_account_reports/models/account_fiscal_position.py
new file mode 100644
index 0000000..bcba747
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_fiscal_position.py
@@ -0,0 +1,19 @@
+from odoo import models
+
+
+class AccountFiscalPosition(models.Model):
+ _inherit = 'account.fiscal.position'
+
+ def _inverse_foreign_vat(self):
+ # EXTENDS account
+ super()._inverse_foreign_vat()
+ for fpos in self:
+ if fpos.foreign_vat:
+ fpos._create_draft_closing_move_for_foreign_vat()
+
+ def _create_draft_closing_move_for_foreign_vat(self):
+ self.ensure_one()
+ existing_draft_closings = self.env['account.move'].search([('tax_closing_report_id', '!=', False), ('state', '=', 'draft')])
+ for closing_date, entries in existing_draft_closings.grouped('date').items():
+ for entry in entries:
+ self.company_id._get_and_update_tax_closing_moves(closing_date, entry.tax_closing_report_id, fiscal_positions=self)
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_followup_report.py b/dev_odex30_accounting/odex30_account_reports/models/account_followup_report.py
new file mode 100644
index 0000000..2b3d59e
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_followup_report.py
@@ -0,0 +1,109 @@
+from odoo import fields, models, _
+from odoo.tools import SQL
+
+
+class AccountFollowupCustomHandler(models.AbstractModel):
+ _name = 'account.followup.report.handler'
+ _inherit = 'account.partner.ledger.report.handler'
+ _description = 'Follow-Up Report Custom Handler'
+
+ def _custom_options_initializer(self, report, options, previous_options):
+ super()._custom_options_initializer(report, options, previous_options)
+
+ options['hide_initial_balance'] = True
+ if len(options['partner_ids']) == 1:
+ options['ignore_totals_below_sections'] = True
+ options['hide_partner_totals'] = True
+
+ if options['report_id'] != previous_options.get('report_id'):
+ options['unreconciled'] = True
+ # by default, select only the 'sales' journals
+ for journal in options.get('journals', []):
+ journal['selected'] = journal.get('type') != 'general' # dividers don't get a type
+ # Since we forced the selection of some journal, we need to recompute the filter label
+ report._init_options_journals_names(options, previous_options=previous_options)
+
+ def _get_custom_display_config(self):
+ display_config = super()._get_custom_display_config()
+ if self.env.ref('odex30_account_reports.pdf_export_main_customer_report', raise_if_not_found=False):
+ display_config.setdefault('pdf_export', {})['pdf_export_main'] = 'odex30_account_reports.pdf_export_main_customer_report'
+ return display_config
+
+ def _filter_overdue_amls_from_results(self, aml_results):
+ return list(filter(lambda aml: aml['date_maturity'] and aml['date_maturity'] < fields.Date.today(), aml_results))
+
+ def _filter_due_amls_from_results(self, aml_results):
+ return list(filter(lambda aml: not aml['date_maturity'] or aml['date_maturity'] >= fields.Date.today(), aml_results))
+
+ def _get_partner_aml_report_lines(self, report, options, partner_line_id, aml_results, progress, offset=0, level_shift=0):
+
+ def create_status_line(status_name):
+ return {
+ 'id': report._get_generic_line_id(None, None, markup=status_name, parent_line_id=partner_line_id),
+ 'name': status_name,
+ 'level': 3 + level_shift,
+ 'parent_id': partner_line_id,
+ 'columns': [{} for _col in options['columns']],
+ 'unfolded': True,
+ }
+
+ def get_aml_lines_with_status_line(status_name, status_line_id, aml_values, treated_results_count, progress):
+ lines = []
+ next_progress = progress
+ has_more = False
+
+ if not status_line_id or offset == 0:
+ status_line = create_status_line(status_name)
+ lines.append(status_line)
+ status_line_id = status_line['id']
+
+ for aml_value in aml_values:
+ if self._is_report_limit_reached(report, options, treated_results_count):
+ # We loaded one more than the limit on purpose: this way we know we need a "load more" line
+ has_more = True
+ break
+
+ aml_report_line = self._get_report_line_move_line(options, aml_value, status_line_id, next_progress, level_shift=level_shift + 1)
+ lines.append(aml_report_line)
+ next_progress = self._init_load_more_progress(options, aml_report_line)
+ treated_results_count += 1
+
+ return lines, next_progress, treated_results_count, has_more
+
+ lines = []
+ next_progress = progress
+ has_more = False
+ treated_results_count = 0
+ due_line_id, overdue_line_id = self._get_unfolded_partner_status_lines(report, options, partner_line_id)
+
+ overdue_aml_values = self._filter_overdue_amls_from_results(aml_results)
+ due_aml_values = self._filter_due_amls_from_results(aml_results)
+
+ if overdue_aml_values:
+ overdue_lines, next_progress, treated_results_count, has_more = get_aml_lines_with_status_line(_('Overdue'), overdue_line_id, overdue_aml_values, treated_results_count, next_progress)
+ lines.extend(overdue_lines)
+ # If we reached the limit just before the due line and have already loaded one extra line, we should skip the due line for now and add a "load more" line
+ if self._is_report_limit_reached(report, options, treated_results_count) and due_aml_values:
+ has_more = True
+
+ if due_aml_values and not has_more:
+ due_lines, next_progress, treated_results_count, has_more = get_aml_lines_with_status_line(_('Due'), due_line_id, due_aml_values, treated_results_count, next_progress)
+ lines.extend(due_lines)
+
+ return lines, next_progress, treated_results_count, has_more
+
+ def _get_unfolded_partner_status_lines(self, report, options, partner_line_id):
+ _dummy1, _dummy2, partner_id = report._parse_line_id(partner_line_id)[-1]
+ due_line_id, overdue_line_id = None, None
+ for line_id in options['unfolded_lines']:
+ res_ids_map = report._get_res_ids_from_line_id(line_id, ['account.report', 'res.partner'])
+ if res_ids_map['account.report'] == report.id and res_ids_map['res.partner'] == partner_id:
+ markup, _dummy1, _dummy2 = report._parse_line_id(line_id)[-1]
+ if markup == 'Due':
+ due_line_id = line_id
+ if markup == 'Overdue':
+ overdue_line_id = line_id
+ return due_line_id, overdue_line_id
+
+ def _get_order_by_aml_values(self):
+ return SQL('account_move_line.date_maturity, %(order_by)s', order_by=super()._get_order_by_aml_values())
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_general_ledger.py b/dev_odex30_accounting/odex30_account_reports/models/account_general_ledger.py
new file mode 100644
index 0000000..6c065f7
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_general_ledger.py
@@ -0,0 +1,761 @@
+import json
+
+from odoo import models, fields, api, _
+from odoo.tools.misc import format_date
+from odoo.tools import get_lang, SQL
+from odoo.exceptions import UserError
+
+from datetime import timedelta
+from collections import defaultdict
+
+
+class GeneralLedgerCustomHandler(models.AbstractModel):
+ _name = 'account.general.ledger.report.handler'
+ _inherit = 'account.report.custom.handler'
+ _description = 'General Ledger Custom Handler'
+
+ def _get_custom_display_config(self):
+ return {
+ 'templates': {
+ 'AccountReportLineName': 'odex30_account_reports.GeneralLedgerLineName',
+ },
+ }
+
+ def _custom_options_initializer(self, report, options, previous_options):
+ # Remove multi-currency columns if needed
+ super()._custom_options_initializer(report, options, previous_options=previous_options)
+ if self.env.user.has_group('base.group_multi_currency'):
+ options['multi_currency'] = True
+ else:
+ options['columns'] = [
+ column for column in options['columns']
+ if column['expression_label'] != 'amount_currency'
+ ]
+
+ # Automatically unfold the report when printing it, unless some specific lines have been unfolded
+ options['unfold_all'] = (options['export_mode'] == 'print' and not options.get('unfolded_lines')) or options['unfold_all']
+
+ def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None):
+ lines = []
+ date_from = fields.Date.from_string(options['date']['date_from'])
+ company_currency = self.env.company.currency_id
+
+ totals_by_column_group = defaultdict(lambda: {'debit': 0, 'credit': 0, 'balance': 0})
+ for account, column_group_results in self._query_values(report, options):
+ eval_dict = {}
+ has_lines = False
+ for column_group_key, results in column_group_results.items():
+ account_sum = results.get('sum', {})
+ account_un_earn = results.get('unaffected_earnings', {})
+
+ account_debit = account_sum.get('debit', 0.0) + account_un_earn.get('debit', 0.0)
+ account_credit = account_sum.get('credit', 0.0) + account_un_earn.get('credit', 0.0)
+ account_balance = account_sum.get('balance', 0.0) + account_un_earn.get('balance', 0.0)
+
+ eval_dict[column_group_key] = {
+ 'amount_currency': account_sum.get('amount_currency', 0.0) + account_un_earn.get('amount_currency', 0.0),
+ 'debit': account_debit,
+ 'credit': account_credit,
+ 'balance': account_balance,
+ }
+
+ max_date = account_sum.get('max_date')
+ has_lines = has_lines or (max_date and max_date >= date_from)
+
+ totals_by_column_group[column_group_key]['debit'] += account_debit
+ totals_by_column_group[column_group_key]['credit'] += account_credit
+ totals_by_column_group[column_group_key]['balance'] += account_balance
+
+ lines.append(self._get_account_title_line(report, options, account, has_lines, eval_dict))
+
+ # Report total line.
+ for totals in totals_by_column_group.values():
+ totals['balance'] = company_currency.round(totals['balance'])
+
+ # Tax Declaration lines.
+ journal_options = report._get_options_journals(options)
+ if len(options['column_groups']) == 1 and len(journal_options) == 1 and journal_options[0]['type'] in ('sale', 'purchase'):
+ lines += self._tax_declaration_lines(report, options, journal_options[0]['type'])
+
+ # Total line
+ lines.append(self._get_total_line(report, options, totals_by_column_group))
+
+ return [(0, line) for line in lines]
+
+ def _custom_unfold_all_batch_data_generator(self, report, options, lines_to_expand_by_function):
+ account_ids_to_expand = []
+ for line_dict in lines_to_expand_by_function.get('_report_expand_unfoldable_line_general_ledger', []):
+ model, model_id = report._get_model_info_from_id(line_dict['id'])
+ if model == 'account.account':
+ account_ids_to_expand.append(model_id)
+
+ limit_to_load = report.load_more_limit if report.load_more_limit and not options.get('export_mode') else None
+ has_more_per_account_id = {}
+
+ unlimited_aml_results_per_account_id = self._get_aml_values(report, options, account_ids_to_expand)[0]
+ if limit_to_load:
+ # Apply the load_more_limit.
+ # load_more_limit cannot be passed to the call to _get_aml_values, otherwise it won't be applied per account but on the whole result.
+ # We gain perf from batching, but load every result ; then we need to filter them.
+
+ aml_results_per_account_id = {}
+ for account_id, account_aml_results in unlimited_aml_results_per_account_id.items():
+ account_values = {}
+ for key, value in account_aml_results.items():
+ if len(account_values) == limit_to_load:
+ has_more_per_account_id[account_id] = True
+ break
+ account_values[key] = value
+ aml_results_per_account_id[account_id] = account_values
+ else:
+ aml_results_per_account_id = unlimited_aml_results_per_account_id
+
+ return {
+ 'initial_balances': self._get_initial_balance_values(report, account_ids_to_expand, options),
+ 'aml_results': aml_results_per_account_id,
+ 'has_more': has_more_per_account_id,
+ }
+
+ def _tax_declaration_lines(self, report, options, tax_type):
+ labels_replacement = {
+ 'debit': _("Base Amount"),
+ 'credit': _("Tax Amount"),
+ }
+
+ rslt = [{
+ 'id': report._get_generic_line_id(None, None, markup='tax_decl_header_1'),
+ 'name': _('Tax Declaration'),
+ 'columns': [{} for column in options['columns']],
+ 'level': 1,
+ 'unfoldable': False,
+ 'unfolded': False,
+ }, {
+ 'id': report._get_generic_line_id(None, None, markup='tax_decl_header_2'),
+ 'name': _('Name'),
+ 'columns': [{'name': labels_replacement.get(col['expression_label'], '')} for col in options['columns']],
+ 'level': 3,
+ 'unfoldable': False,
+ 'unfolded': False,
+ }]
+
+ # Call the generic tax report
+ generic_tax_report = self.env.ref('account.generic_tax_report')
+ tax_report_options = generic_tax_report.get_options({**options, 'selected_variant_id': generic_tax_report.id, 'forced_domain': [('tax_line_id.type_tax_use', '=', tax_type)]})
+ tax_report_lines = generic_tax_report._get_lines(tax_report_options)
+ tax_type_parent_line_id = generic_tax_report._get_generic_line_id(None, None, markup=tax_type)
+
+ for tax_report_line in tax_report_lines:
+ if tax_report_line.get('parent_id') == tax_type_parent_line_id:
+ original_columns = tax_report_line['columns']
+ row_column_map = {
+ 'debit': original_columns[0],
+ 'credit': original_columns[1],
+ }
+
+ tax_report_line['columns'] = [row_column_map.get(col['expression_label'], {}) for col in options['columns']]
+ rslt.append(tax_report_line)
+
+ return rslt
+
+ def _query_values(self, report, options):
+ """ Executes the queries, and performs all the computations.
+
+ :return: [(record, values_by_column_group), ...], where
+ - record is an account.account record.
+ - values_by_column_group is a dict in the form {column_group_key: values, ...}
+ - column_group_key is a string identifying a column group, as in options['column_groups']
+ - values is a list of dictionaries, one per period containing:
+ - sum: {'debit': float, 'credit': float, 'balance': float}
+ - (optional) initial_balance: {'debit': float, 'credit': float, 'balance': float}
+ - (optional) unaffected_earnings: {'debit': float, 'credit': float, 'balance': float}
+ """
+ # Execute the queries and dispatch the results.
+ query = self._get_query_sums(report, options)
+
+ if not query:
+ return []
+
+ groupby_accounts = {}
+ groupby_companies = {}
+
+ for res in self.env.execute_query_dict(query):
+ # No result to aggregate.
+ if res['groupby'] is None:
+ continue
+
+ column_group_key = res['column_group_key']
+ key = res['key']
+ if key == 'sum':
+ groupby_accounts.setdefault(res['groupby'], {col_group_key: {} for col_group_key in options['column_groups']})
+ groupby_accounts[res['groupby']][column_group_key][key] = res
+
+ elif key == 'initial_balance':
+ groupby_accounts.setdefault(res['groupby'], {col_group_key: {} for col_group_key in options['column_groups']})
+ groupby_accounts[res['groupby']][column_group_key][key] = res
+
+ elif key == 'unaffected_earnings':
+ groupby_companies.setdefault(res['groupby'], {col_group_key: {} for col_group_key in options['column_groups']})
+ groupby_companies[res['groupby']][column_group_key] = res
+
+ # Affect the unaffected earnings to the first fetched account of type 'account.data_unaffected_earnings'.
+ # It's less costly to fetch all candidate accounts in a single search and then iterate it.
+ if groupby_companies:
+ unaffected_earnings_accounts = self.env['account.account'].search([
+ ('display_name', 'ilike', options.get('filter_search_bar')),
+ *self.env['account.account']._check_company_domain(list(groupby_companies.keys())),
+ ('account_type', '=', 'equity_unaffected'),
+ ])
+ for company_id, groupby_company in groupby_companies.items():
+ if equity_unaffected_account := unaffected_earnings_accounts.filtered(lambda a: self.env['res.company'].browse(company_id).root_id in a.company_ids):
+ for column_group_key in options['column_groups']:
+ groupby_accounts.setdefault(
+ equity_unaffected_account.id,
+ {col_group_key: {'unaffected_earnings': {}} for col_group_key in options['column_groups']},
+ )
+ if unaffected_earnings := groupby_company.get(column_group_key):
+ if groupby_accounts[equity_unaffected_account.id][column_group_key].get('unaffected_earnings'):
+ for key in ['amount_currency', 'debit', 'credit', 'balance']:
+ groupby_accounts[equity_unaffected_account.id][column_group_key]['unaffected_earnings'][key] += unaffected_earnings[key]
+ else:
+ groupby_accounts[equity_unaffected_account.id][column_group_key]['unaffected_earnings'] = unaffected_earnings
+
+ # Retrieve the accounts to browse.
+ # groupby_accounts.keys() contains all account ids affected by:
+ # - the amls in the current period.
+ # - the amls affecting the initial balance.
+ # - the unaffected earnings allocation.
+ # Note a search is done instead of a browse to preserve the table ordering.
+ if groupby_accounts:
+ accounts = self.env['account.account'].search([('id', 'in', list(groupby_accounts.keys()))])
+ else:
+ accounts = []
+
+ return [(account, groupby_accounts[account.id]) for account in accounts]
+
+ def _get_query_sums(self, report, options) -> SQL:
+ """ Construct a query retrieving all the aggregated sums to build the report. It includes:
+ - sums for all accounts.
+ - sums for the initial balances.
+ - sums for the unaffected earnings.
+ - sums for the tax declaration.
+ :return: query as SQL object
+ """
+ options_by_column_group = report._split_options_per_column_group(options)
+
+ queries = []
+
+ # ============================================
+ # 1) Get sums for all accounts.
+ # ============================================
+ for column_group_key, options_group in options_by_column_group.items():
+
+ # Sum is computed including the initial balance of the accounts configured to do so, unless a special option key is used
+ # (this is required for trial balance, which is based on general ledger)
+ sum_date_scope = 'strict_range' if options_group.get('general_ledger_strict_range') else 'from_beginning'
+
+ query_domain = []
+
+ if not options_group.get('general_ledger_strict_range'):
+ date_from = fields.Date.from_string(options_group['date']['date_from'])
+ current_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(date_from)
+ query_domain += [
+ '|',
+ ('date', '>=', current_fiscalyear_dates['date_from']),
+ ('account_id.include_initial_balance', '=', True),
+ ]
+
+ if options_group.get('export_mode') == 'print' and options_group.get('filter_search_bar'):
+ if options_group.get('hierarchy'):
+ query_domain += [
+ '|',
+ ('account_id', 'ilike', options_group['filter_search_bar']),
+ ('account_id.id', 'in', SQL(
+ """
+ /*
+ JOIN clause: Check if the account_group include the account_account
+ A group from 10 to 10 include every account with code that begin with 10.
+ If there is an account with a length of 6, it should be included if it's in the range from 100 000 to 109 999 included
+
+ Where clause: Check if the account_group matches the filter
+ */
+ (SELECT distinct account_account.id
+ FROM account_account
+ LEFT JOIN account_group ON
+ (
+ LEFT(account_account.code_store->> '%(company_id)s', LENGTH(code_prefix_start)) BETWEEN
+ code_prefix_start
+ AND code_prefix_end
+ )
+ WHERE ( account_group.name->> %(lang)s ILIKE %(filter_search_bar)s
+ OR account_group.code_prefix_start ILIKE %(filter_search_bar)s)
+ )""",
+ lang=self.env.lang,
+ company_id=self.env.company.id,
+ filter_search_bar="%" + options_group['filter_search_bar'] + "%")),
+ ]
+ else:
+ query_domain.append(('account_id', 'ilike', options_group['filter_search_bar']))
+
+ if options_group.get('include_current_year_in_unaff_earnings'):
+ query_domain += [('account_id.include_initial_balance', '=', True)]
+
+ query = report._get_report_query(options_group, sum_date_scope, domain=query_domain)
+ queries.append(SQL(
+ """
+ SELECT
+ account_move_line.account_id AS groupby,
+ 'sum' AS key,
+ MAX(account_move_line.date) AS max_date,
+ %(column_group_key)s AS column_group_key,
+ COALESCE(SUM(account_move_line.amount_currency), 0.0) AS amount_currency,
+ SUM(%(debit_select)s) AS debit,
+ SUM(%(credit_select)s) AS credit,
+ SUM(%(balance_select)s) AS balance
+ FROM %(table_references)s
+ %(currency_table_join)s
+ WHERE %(search_condition)s
+ GROUP BY account_move_line.account_id
+ """,
+ column_group_key=column_group_key,
+ table_references=query.from_clause,
+ debit_select=report._currency_table_apply_rate(SQL("account_move_line.debit")),
+ credit_select=report._currency_table_apply_rate(SQL("account_move_line.credit")),
+ balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")),
+ currency_table_join=report._currency_table_aml_join(options_group),
+ search_condition=query.where_clause,
+ ))
+
+ # ============================================
+ # 2) Get sums for the unaffected earnings.
+ # ============================================
+ if not options_group.get('general_ledger_strict_range'):
+ unaff_earnings_domain = [('account_id.include_initial_balance', '=', False)]
+
+ # The period domain is expressed as:
+ # [
+ # ('date' <= fiscalyear['date_from'] - 1),
+ # ('account_id.include_initial_balance', '=', False),
+ # ]
+
+ new_options = self._get_options_unaffected_earnings(options_group)
+ query = report._get_report_query(new_options, 'strict_range', domain=unaff_earnings_domain)
+ queries.append(SQL(
+ """
+ SELECT
+ account_move_line.company_id AS groupby,
+ 'unaffected_earnings' AS key,
+ NULL AS max_date,
+ %(column_group_key)s AS column_group_key,
+ COALESCE(SUM(account_move_line.amount_currency), 0.0) AS amount_currency,
+ SUM(%(debit_select)s) AS debit,
+ SUM(%(credit_select)s) AS credit,
+ SUM(%(balance_select)s) AS balance
+ FROM %(table_references)s
+ %(currency_table_join)s
+ WHERE %(search_condition)s
+ GROUP BY account_move_line.company_id
+ """,
+ column_group_key=column_group_key,
+ table_references=query.from_clause,
+ debit_select=report._currency_table_apply_rate(SQL("account_move_line.debit")),
+ credit_select=report._currency_table_apply_rate(SQL("account_move_line.credit")),
+ balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")),
+ currency_table_join=report._currency_table_aml_join(options_group),
+ search_condition=query.where_clause,
+ ))
+
+ return SQL(" UNION ALL ").join(queries)
+
+ def _get_options_unaffected_earnings(self, options):
+ ''' Create options used to compute the unaffected earnings.
+ The unaffected earnings are the amount of benefits/loss that have not been allocated to
+ another account in the previous fiscal years.
+ The resulting dates domain will be:
+ [
+ ('date' <= fiscalyear['date_from'] - 1),
+ ('account_id.include_initial_balance', '=', False),
+ ]
+ :param options: The report options.
+ :return: A copy of the options.
+ '''
+ new_options = options.copy()
+ new_options.pop('filter_search_bar', None)
+ fiscalyear_dates = self.env.company.compute_fiscalyear_dates(fields.Date.from_string(options['date']['date_from']))
+
+ # Trial balance uses the options key, general ledger does not
+ new_date_to = fields.Date.from_string(new_options['date']['date_to']) if options.get('include_current_year_in_unaff_earnings') else fiscalyear_dates['date_from'] - timedelta(days=1)
+
+ new_options['date'] = self.env['account.report']._get_dates_period(None, new_date_to, 'single')
+
+ return new_options
+
+ def _get_aml_values(self, report, options, expanded_account_ids, offset=0, limit=None):
+ rslt = {account_id: {} for account_id in expanded_account_ids}
+ aml_query = self._get_query_amls(report, options, expanded_account_ids, offset=offset, limit=limit)
+ self._cr.execute(aml_query)
+ aml_results_number = 0
+ has_more = False
+ for aml_result in self._cr.dictfetchall():
+ aml_results_number += 1
+ if aml_results_number == limit:
+ has_more = True
+ break
+
+ # For asset_receivable the name will already contains the ref with the _compute_name
+ if aml_result['ref'] and aml_result['account_type'] != 'asset_receivable':
+ aml_result['communication'] = f"{aml_result['ref']} - {aml_result['name']}"
+ else:
+ aml_result['communication'] = aml_result['name']
+
+ # The same aml can return multiple results when using account_report_cash_basis module, if the receivable/payable
+ # is reconciled with multiple payments. In this case, the date shown for the move lines actually corresponds to the
+ # reconciliation date. In order to keep distinct lines in this case, we include date in the grouping key.
+ aml_key = (aml_result['id'], aml_result['date'])
+
+ account_result = rslt[aml_result['account_id']]
+ if not aml_key in account_result:
+ account_result[aml_key] = {col_group_key: {} for col_group_key in options['column_groups']}
+
+ account_result[aml_key][aml_result['column_group_key']] = aml_result
+
+ return rslt, has_more
+
+ def _get_query_amls(self, report, options, expanded_account_ids, offset=0, limit=None) -> SQL:
+ """ Construct a query retrieving the account.move.lines when expanding a report line with or without the load
+ more.
+ :param options: The report options.
+ :param expanded_account_ids: The account.account ids corresponding to consider. If None, match every account.
+ :param offset: The offset of the query (used by the load more).
+ :param limit: The limit of the query (used by the load more).
+ :return: (query, params)
+ """
+ additional_domain = [('account_id', 'in', expanded_account_ids)] if expanded_account_ids is not None else None
+ queries = []
+ journal_name = self.env['account.journal']._field_to_sql('journal', 'name')
+ for column_group_key, group_options in report._split_options_per_column_group(options).items():
+ # Get sums for the account move lines.
+ # period: [('date' <= options['date_to']), ('date', '>=', options['date_from'])]
+ query = report._get_report_query(group_options, domain=additional_domain, date_scope='strict_range')
+ account_alias = query.left_join(lhs_alias='account_move_line', lhs_column='account_id', rhs_table='account_account', rhs_column='id', link='account_id')
+ account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query)
+ account_name = self.env['account.account']._field_to_sql(account_alias, 'name')
+ account_type = self.env['account.account']._field_to_sql(account_alias, 'account_type')
+
+ query = SQL(
+ '''
+ SELECT
+ account_move_line.id,
+ account_move_line.date,
+ MIN(account_move_line.date_maturity) AS date_maturity,
+ MIN(account_move_line.name) AS name,
+ MIN(account_move_line.ref) AS ref,
+ MIN(account_move_line.company_id) AS company_id,
+ MIN(account_move_line.account_id) AS account_id,
+ MIN(account_move_line.payment_id) AS payment_id,
+ MIN(account_move_line.partner_id) AS partner_id,
+ MIN(account_move_line.currency_id) AS currency_id,
+ SUM(account_move_line.amount_currency) AS amount_currency,
+ MIN(COALESCE(account_move_line.invoice_date, account_move_line.date)) AS invoice_date,
+ account_move_line.date AS date,
+ SUM(%(debit_select)s) AS debit,
+ SUM(%(credit_select)s) AS credit,
+ SUM(%(balance_select)s) AS balance,
+ MIN(move.name) AS move_name,
+ MIN(company.currency_id) AS company_currency_id,
+ MIN(partner.name) AS partner_name,
+ MIN(move.move_type) AS move_type,
+ MIN(%(account_code)s) AS account_code,
+ MIN(%(account_name)s) AS account_name,
+ MIN(%(account_type)s) AS account_type,
+ MIN(journal.code) AS journal_code,
+ MIN(%(journal_name)s) AS journal_name,
+ MIN(full_rec.id) AS full_rec_name,
+ %(column_group_key)s AS column_group_key
+ FROM %(table_references)s
+ JOIN account_move move ON move.id = account_move_line.move_id
+ %(currency_table_join)s
+ LEFT JOIN res_company company ON company.id = account_move_line.company_id
+ LEFT JOIN res_partner partner ON partner.id = account_move_line.partner_id
+ LEFT JOIN account_journal journal ON journal.id = account_move_line.journal_id
+ LEFT JOIN account_full_reconcile full_rec ON full_rec.id = account_move_line.full_reconcile_id
+ WHERE %(search_condition)s
+ GROUP BY account_move_line.id, account_move_line.date
+ ORDER BY account_move_line.date, move_name, account_move_line.id
+ ''',
+ account_code=account_code,
+ account_name=account_name,
+ account_type=account_type,
+ journal_name=journal_name,
+ column_group_key=column_group_key,
+ table_references=query.from_clause,
+ currency_table_join=report._currency_table_aml_join(group_options),
+ debit_select=report._currency_table_apply_rate(SQL("account_move_line.debit")),
+ credit_select=report._currency_table_apply_rate(SQL("account_move_line.credit")),
+ balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")),
+ search_condition=query.where_clause,
+ )
+ queries.append(query)
+
+ full_query = SQL(" UNION ALL ").join(SQL("(%s)", query) for query in queries)
+
+ if offset:
+ full_query = SQL('%s OFFSET %s ', full_query, offset)
+ if limit:
+ full_query = SQL('%s LIMIT %s ', full_query, limit)
+
+ return full_query
+
+ def _get_initial_balance_values(self, report, account_ids, options):
+ """
+ Get sums for the initial balance.
+ """
+ queries = []
+ for column_group_key, options_group in report._split_options_per_column_group(options).items():
+ new_options = self._get_options_initial_balance(options_group)
+ domain = [
+ ('account_id', 'in', account_ids),
+ ]
+ if not new_options.get('general_ledger_strict_range'):
+ domain += [
+ '|',
+ ('date', '>=', new_options['date']['date_from']),
+ ('account_id.include_initial_balance', '=', True),
+ ]
+ if new_options.get('include_current_year_in_unaff_earnings'):
+ domain += [('account_id.include_initial_balance', '=', True)]
+ query = report._get_report_query(new_options, 'from_beginning', domain=domain)
+ queries.append(SQL(
+ """
+ SELECT
+ account_move_line.account_id AS groupby,
+ 'initial_balance' AS key,
+ NULL AS max_date,
+ %(column_group_key)s AS column_group_key,
+ COALESCE(SUM(account_move_line.amount_currency), 0.0) AS amount_currency,
+ SUM(%(debit_select)s) AS debit,
+ SUM(%(credit_select)s) AS credit,
+ SUM(%(balance_select)s) AS balance
+ FROM %(table_references)s
+ %(currency_table_join)s
+ WHERE %(search_condition)s
+ GROUP BY account_move_line.account_id
+ """,
+ column_group_key=column_group_key,
+ table_references=query.from_clause,
+ debit_select=report._currency_table_apply_rate(SQL("account_move_line.debit")),
+ credit_select=report._currency_table_apply_rate(SQL("account_move_line.credit")),
+ balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")),
+ currency_table_join=report._currency_table_aml_join(options_group),
+ search_condition=query.where_clause,
+ ))
+
+ self._cr.execute(SQL(" UNION ALL ").join(queries))
+
+ init_balance_by_col_group = {
+ account_id: {column_group_key: {} for column_group_key in options['column_groups']}
+ for account_id in account_ids
+ }
+ for result in self._cr.dictfetchall():
+ init_balance_by_col_group[result['groupby']][result['column_group_key']] = result
+
+ accounts = self.env['account.account'].browse(account_ids)
+ return {
+ account.id: (account, init_balance_by_col_group[account.id])
+ for account in accounts
+ }
+
+ def _get_options_initial_balance(self, options):
+ """ Create options used to compute the initial balances.
+ The initial balances depict the current balance of the accounts at the beginning of
+ the selected period in the report.
+ The resulting dates domain will be:
+ [
+ ('date' <= options['date_from'] - 1),
+ '|',
+ ('date' >= fiscalyear['date_from']),
+ ('account_id.include_initial_balance', '=', True)
+ ]
+ :param options: The report options.
+ :return: A copy of the options.
+ """
+ #pylint: disable=sql-injection
+ new_options = options.copy()
+ date_to = new_options['comparison']['periods'][-1]['date_from'] if new_options.get('comparison', {}).get('periods') else new_options['date']['date_from']
+ new_date_to = fields.Date.from_string(date_to) - timedelta(days=1)
+
+ # Date from computation
+ # We have two case:
+ # 1) We are choosing a date that starts at the beginning of a fiscal year and we want the initial period to be
+ # the previous fiscal year
+ # 2) We are choosing a date that starts in the middle of a fiscal year and in that case we want the initial period
+ # to be the beginning of the fiscal year
+ date_from = fields.Date.from_string(new_options['date']['date_from'])
+ current_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(date_from)
+
+ if date_from == current_fiscalyear_dates['date_from']:
+ # We want the previous fiscal year
+ previous_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(date_from - timedelta(days=1))
+ new_date_from = previous_fiscalyear_dates['date_from']
+ include_current_year_in_unaff_earnings = True
+ else:
+ # We want the current fiscal year
+ new_date_from = current_fiscalyear_dates['date_from']
+ include_current_year_in_unaff_earnings = False
+
+ new_options['date'] = self.env['account.report']._get_dates_period(
+ new_date_from,
+ new_date_to,
+ 'range',
+ )
+ new_options['include_current_year_in_unaff_earnings'] = include_current_year_in_unaff_earnings
+
+ return new_options
+
+ ####################################################
+ # COLUMN/LINE HELPERS
+ ####################################################
+ def _get_account_title_line(self, report, options, account, has_lines, eval_dict):
+ line_columns = []
+ for column in options['columns']:
+ col_value = eval_dict.get(column['column_group_key'], {}).get(column['expression_label'])
+ col_expr_label = column['expression_label']
+
+ value = None if col_value is None or (col_expr_label == 'amount_currency' and not account.currency_id) else col_value
+
+ line_columns.append(report._build_column_dict(
+ value,
+ column,
+ options=options,
+ currency=account.currency_id if col_expr_label == 'amount_currency' else None,
+ ))
+
+ line_id = report._get_generic_line_id('account.account', account.id)
+ is_in_unfolded_lines = any(
+ report._get_res_id_from_line_id(line_id, 'account.account') == account.id
+ for line_id in options.get('unfolded_lines')
+ )
+ return {
+ 'id': line_id,
+ 'name': account.display_name,
+ 'columns': line_columns,
+ 'level': 1,
+ 'unfoldable': has_lines,
+ 'unfolded': has_lines and (is_in_unfolded_lines or options.get('unfold_all')),
+ 'expand_function': '_report_expand_unfoldable_line_general_ledger',
+ }
+
+ def _get_aml_line(self, report, parent_line_id, options, eval_dict, init_bal_by_col_group):
+ line_columns = []
+ for column in options['columns']:
+ col_expr_label = column['expression_label']
+ col_value = eval_dict[column['column_group_key']].get(col_expr_label)
+ col_currency = None
+
+ if col_value is not None:
+ if col_expr_label == 'amount_currency':
+ col_currency = self.env['res.currency'].browse(eval_dict[column['column_group_key']]['currency_id'])
+ col_value = None if col_currency == self.env.company.currency_id else col_value
+ elif col_expr_label == 'balance':
+ col_value += (init_bal_by_col_group[column['column_group_key']] or 0)
+
+ line_columns.append(report._build_column_dict(
+ col_value,
+ column,
+ options=options,
+ currency=col_currency,
+ ))
+
+ aml_id = None
+ move_name = None
+ caret_type = None
+ for column_group_dict in eval_dict.values():
+ aml_id = column_group_dict.get('id', '')
+ if aml_id:
+ if column_group_dict.get('payment_id'):
+ caret_type = 'account.payment'
+ else:
+ caret_type = 'account.move.line'
+ move_name = column_group_dict['move_name']
+ date = str(column_group_dict.get('date', ''))
+ break
+
+ return {
+ 'id': report._get_generic_line_id('account.move.line', aml_id, parent_line_id=parent_line_id, markup=date),
+ 'caret_options': caret_type,
+ 'parent_id': parent_line_id,
+ 'name': move_name or _('Draft Entry'),
+ 'columns': line_columns,
+ 'level': 3,
+ }
+
+ @api.model
+ def _get_total_line(self, report, options, eval_dict):
+ line_columns = []
+ for column in options['columns']:
+ col_value = eval_dict[column['column_group_key']].get(column['expression_label'])
+ col_value = None if col_value is None else col_value
+
+ line_columns.append(report._build_column_dict(col_value, column, options=options))
+
+ return {
+ 'id': report._get_generic_line_id(None, None, markup='total'),
+ 'name': _('Total'),
+ 'level': 1,
+ 'columns': line_columns,
+ }
+
+ def caret_option_audit_tax(self, options, params):
+ return self.env['account.generic.tax.report.handler'].caret_option_audit_tax(options, params)
+
+ def _report_expand_unfoldable_line_general_ledger(self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None):
+ def init_load_more_progress(line_dict):
+ return {
+ column['column_group_key']: line_col.get('no_format', 0)
+ for column, line_col in zip(options['columns'], line_dict['columns'])
+ if column['expression_label'] == 'balance'
+ }
+
+ report = self.env.ref('odex30_account_reports.general_ledger_report')
+ model, model_id = report._get_model_info_from_id(line_dict_id)
+
+ if model != 'account.account':
+ raise UserError(_("Wrong ID for general ledger line to expand: %s", line_dict_id))
+
+ lines = []
+
+ # Get initial balance
+ if offset == 0:
+ if unfold_all_batch_data:
+ account, init_balance_by_col_group = unfold_all_batch_data['initial_balances'][model_id]
+ else:
+ account, init_balance_by_col_group = self._get_initial_balance_values(report, [model_id], options)[model_id]
+
+ initial_balance_line = report._get_partner_and_general_ledger_initial_balance_line(options, line_dict_id, init_balance_by_col_group, account.currency_id)
+
+ if initial_balance_line:
+ lines.append(initial_balance_line)
+
+ # For the first expansion of the line, the initial balance line gives the progress
+ progress = init_load_more_progress(initial_balance_line)
+
+ # Get move lines
+ limit_to_load = report.load_more_limit + 1 if report.load_more_limit and options['export_mode'] != 'print' else None
+ if unfold_all_batch_data:
+ aml_results = unfold_all_batch_data['aml_results'][model_id]
+ has_more = unfold_all_batch_data['has_more'].get(model_id, False)
+ else:
+ aml_results, has_more = self._get_aml_values(report, options, [model_id], offset=offset, limit=limit_to_load)
+ aml_results = aml_results[model_id]
+
+ next_progress = progress
+ for aml_result in aml_results.values():
+ new_line = self._get_aml_line(report, line_dict_id, options, aml_result, next_progress)
+ lines.append(new_line)
+ next_progress = init_load_more_progress(new_line)
+
+ return {
+ 'lines': lines,
+ 'offset_increment': report.load_more_limit,
+ 'has_more': has_more,
+ 'progress': next_progress,
+ }
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_generic_tax_report.py b/dev_odex30_accounting/odex30_account_reports/models/account_generic_tax_report.py
new file mode 100644
index 0000000..b9adb8e
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_generic_tax_report.py
@@ -0,0 +1,1222 @@
+import ast
+from collections import defaultdict
+
+from odoo import models, api, fields, Command, _
+from odoo.addons.web.controllers.utils import clean_action
+from odoo.exceptions import UserError, RedirectWarning
+from odoo.osv import expression
+from odoo.tools import SQL
+
+
+class AccountTaxReportHandler(models.AbstractModel):
+ _name = 'account.tax.report.handler'
+ _inherit = 'account.report.custom.handler'
+ _description = 'Account Report Handler for Tax Reports'
+
+
+ def _custom_options_initializer(self, report, options, previous_options):
+ optional_periods = {
+ 'monthly': 'month',
+ 'trimester': 'quarter',
+ 'year': 'year',
+ }
+
+ options['buttons'].append({'name': _('Closing Entry'), 'action': 'action_periodic_vat_entries', 'sequence': 110, 'always_show': True})
+ options['enable_export_buttons_for_common_vat_in_branches'] = True
+
+ day, month = self.env.company._get_tax_closing_start_date_attributes(report)
+ periodicity = self.env.company._get_tax_periodicity(report)
+ options['tax_periodicity'] = {
+ 'periodicity': periodicity,
+ 'months_per_period': self.env.company._get_tax_periodicity_months_delay(report),
+ 'start_day': day,
+ 'start_month': month,
+ }
+
+ options['show_tax_period_filter'] = periodicity not in optional_periods or day != 1 or month != 1
+ if not options['show_tax_period_filter'] and 'custom' not in options['date']['filter']:
+ period_type = optional_periods[periodicity]
+ options['date']['filter'] = options['date']['filter'].replace('tax_period', period_type)
+ options['date']['period_type'] = options['date']['period_type'].replace('tax_period', period_type)
+
+ def _get_custom_display_config(self):
+ display_config = defaultdict(dict)
+ display_config['templates']['AccountReportFilters'] = 'odex30_account_reports.GenericTaxReportFiltersCustomizable'
+ return display_config
+
+ def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings):
+ if 'odex30_account_reports.common_warning_draft_in_period' in warnings:
+ # Recompute the warning 'common_warning_draft_in_period' to not include tax closing entries in the banner of unposted moves
+ if not self.env['account.move'].search_count(
+ [('state', '=', 'draft'), ('date', '<=', options['date']['date_to']),
+ ('tax_closing_report_id', '=', False)],
+ limit=1,
+ ):
+ warnings.pop('odex30_account_reports.common_warning_draft_in_period')
+
+ # Chek the use of inactive tags in the period
+ query = report._get_report_query(options, 'strict_range')
+ rows = self.env.execute_query(SQL("""
+ SELECT 1
+ FROM %s
+ JOIN account_account_tag_account_move_line_rel aml_tag
+ ON account_move_line.id = aml_tag.account_move_line_id
+ JOIN account_account_tag tag
+ ON aml_tag.account_account_tag_id = tag.id
+ WHERE %s
+ AND NOT tag.active
+ LIMIT 1
+ """, query.from_clause, query.where_clause))
+ if rows:
+ warnings['odex30_account_reports.tax_report_warning_inactive_tags'] = {}
+
+
+ # -------------------------------------------------------------------------
+ # TAX CLOSING
+ # -------------------------------------------------------------------------
+
+ def _is_period_equal_to_options(self, report, options):
+ options_date_to = fields.Date.from_string(options['date']['date_to'])
+ options_date_from = fields.Date.from_string(options['date']['date_from'])
+ date_from, date_to = self.env.company._get_tax_closing_period_boundaries(options_date_to, report)
+ return date_from == options_date_from and date_to == options_date_to
+
+ def action_periodic_vat_entries(self, options, from_post=False):
+ report = self.env['account.report'].browse(options['report_id'])
+ if (
+ options['date']['period_type'] != 'tax_period'
+ and not self._is_period_equal_to_options(report, options)
+ and not self.env.context.get('override_tax_closing_warning')
+ ):
+ if len(options['companies']) > 1 and (report.filter_multi_company != 'tax_units' or not (report.country_id and options['available_tax_units'])):
+ message = _(
+ "You're about the generate the closing entries of multiple companies at once. Each of them will be created in accordance with its company tax periodicity.")
+ else:
+ message = _(
+ "The currently selected dates don't match a tax period. The closing entry will be created for the closest-matching period according to your periodicity setup.")
+
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'odex30_account_reports.redirect_action',
+ 'target': 'new',
+ 'params': {
+ 'depending_action': self.with_context(
+ {'override_tax_closing_warning': True}).action_periodic_vat_entries(options),
+ 'message': message,
+ 'button_text': _("Proceed"),
+ },
+ 'context': {
+ 'dialog_size': 'medium',
+ 'override_tax_closing_warning': True,
+ },
+ }
+
+ moves = self._get_periodic_vat_entries(options, from_post=from_post)
+ # Make the action for the retrieved move and return it.
+ action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_journal_line")
+ action = clean_action(action, env=self.env)
+ action.pop('domain', None)
+
+ if len(moves) == 1:
+ action['views'] = [(self.env.ref('account.view_move_form').id, 'form')]
+ action['res_id'] = moves.id
+ else:
+ action['domain'] = [('id', 'in', moves.ids)]
+ action['context'] = dict(ast.literal_eval(action['context']))
+ action['context'].pop('search_default_posted', None)
+ return action
+
+ def _get_periodic_vat_entries(self, options, from_post=False):
+ report = self.env['account.report'].browse(options['report_id'])
+
+ # When integer_rounding is available, we always want it for tax closing (as it means it's a legal requirement)
+ if options.get('integer_rounding'):
+ options['integer_rounding_enabled'] = True
+
+ # Return action to open form view of newly created entry
+ moves = self.env['account.move']
+
+ # Get all companies impacting the report.
+ companies = self.env['res.company'].browse(report.get_report_company_ids(options))
+
+ companies_moves = self._get_tax_closing_entries_for_closed_period(report, options, companies, posted_only=False)
+ moves += companies_moves
+ moves += self._generate_tax_closing_entries(report, options, companies=companies - companies_moves.company_id, from_post=from_post)
+
+ return moves
+
+ def _generate_tax_closing_entries(self, report, options, closing_moves=None, companies=None, from_post=False):
+ """Generates and/or updates VAT closing entries.
+
+ This method computes the content of the tax closing in the following way:
+ - Search on all tax lines in the given period, group them by tax_group (each tax group might have its own
+ tax receivable/payable account).
+ - Create a move line that balances each tax account and add the difference in the correct receivable/payable
+ account. Also take into account amounts already paid via advance tax payment account.
+
+ The tax closing is done so that an individual move is created per available VAT number: so, one for each
+ foreign vat fiscal position (each with fiscal_position_id set to this fiscal position), and one for the domestic
+ position (with fiscal_position_id = None). The moves created by this function hence depends on the content of the
+ options dictionary, and what fiscal positions are accepted by it.
+
+ :param options: the tax report options dict to use to make the closing.
+ :param closing_moves: If provided, closing moves to update the content from.
+ They need to be compatible with the provided options (if they have a fiscal_position_id, for example).
+ :param companies: optional params, the companies given will be used instead of taking all the companies impacting
+ the report.
+ :return: The closing moves.
+ """
+ if companies is None:
+ companies = self.env['res.company'].browse(report.get_report_company_ids(options))
+
+ if closing_moves is None:
+ closing_moves = self.env['account.move']
+
+ end_date = fields.Date.from_string(options['date']['date_to'])
+
+ closing_moves_by_company = defaultdict(lambda: self.env['account.move'])
+
+ companies_without_closing = companies.filtered(lambda company: company not in closing_moves.company_id)
+ if closing_moves:
+ for move in closing_moves.filtered(lambda x: x.state == 'draft'):
+ closing_moves_by_company[move.company_id] |= move
+
+ for company in companies_without_closing:
+ include_domestic, fiscal_positions = self._get_fpos_info_for_tax_closing(company, report, options)
+ company_closing_moves = company._get_and_update_tax_closing_moves(end_date, report, fiscal_positions=fiscal_positions, include_domestic=include_domestic)
+ closing_moves_by_company[company] = company_closing_moves
+ closing_moves += company_closing_moves
+
+ for company, company_closing_moves in closing_moves_by_company.items():
+
+ # First gather the countries for which the closing is being done
+ countries = self.env['res.country']
+ for move in company_closing_moves:
+ if move.fiscal_position_id.foreign_vat:
+ countries |= move.fiscal_position_id.country_id
+ else:
+ countries |= company.account_fiscal_country_id
+
+ # Check the tax groups from the company for any misconfiguration in these countries
+ if self.env['account.tax.group']._check_misconfigured_tax_groups(company, countries):
+ self._redirect_to_misconfigured_tax_groups(company, countries)
+
+ for move in company_closing_moves:
+ # When coming from post and that the current move is the closing of the current company we don't want to
+ # write on it again
+ if from_post and move == closing_moves_by_company.get(self.env.company):
+ continue
+
+ # get tax entries by tax_group for the period defined in options
+ move_options = {**options, 'fiscal_position': move.fiscal_position_id.id if move.fiscal_position_id else 'domestic'}
+ line_ids_vals, tax_group_subtotal = self._compute_vat_closing_entry(company, move_options)
+
+ line_ids_vals += self._add_tax_group_closing_items(tax_group_subtotal, move)
+
+ if move.line_ids:
+ line_ids_vals += [Command.delete(aml.id) for aml in move.line_ids]
+
+ move_vals = {}
+ if line_ids_vals:
+ move_vals['line_ids'] = line_ids_vals
+ move.write(move_vals)
+
+ return closing_moves
+
+ def _get_tax_closing_entries_for_closed_period(self, report, options, companies, posted_only=True):
+ """ Fetch the closing entries related to the given companies for the currently selected tax report period.
+ Only used when the selected period already has a tax lock date impacting it, and assuming that these periods
+ all have a tax closing entry.
+ :param report: The tax report for which we are getting the closing entries.
+ :param options: the tax report options dict needed to get the period end date and fiscal position info.
+ :param companies: a recordset of companies for which the period has already been closed.
+ :return: The closing moves.
+ """
+ closing_moves = self.env['account.move']
+ for company in companies:
+ _dummy, period_end = company._get_tax_closing_period_boundaries(fields.Date.from_string(options['date']['date_to']), report)
+ include_domestic, fiscal_positions = self._get_fpos_info_for_tax_closing(company, report, options)
+ fiscal_position_ids = fiscal_positions.ids + ([False] if include_domestic else [])
+ state_domain = ('state', '=', 'posted') if posted_only else ('state', '!=', 'cancel')
+ closing_moves += self.env['account.move'].search([
+ ('company_id', '=', company.id),
+ ('fiscal_position_id', 'in', fiscal_position_ids),
+ ('date', '=', period_end),
+ ('tax_closing_report_id', '!=', False),
+ state_domain,
+ ])
+
+ return closing_moves
+
+ @api.model
+ def _compute_vat_closing_entry(self, company, options):
+ """Compute the VAT closing entry.
+
+ This method returns the one2many commands to balance the tax accounts for the selected period, and
+ a dictionnary that will help balance the different accounts set per tax group.
+ """
+ self = self.with_company(company) # Needed to handle access to property fields correctly
+
+ # first, for each tax group, gather the tax entries per tax and account
+ self.env['account.tax'].flush_model(['name', 'tax_group_id'])
+ self.env['account.tax.repartition.line'].flush_model(['use_in_tax_closing'])
+ self.env['account.move.line'].flush_model(['account_id', 'debit', 'credit', 'move_id', 'tax_line_id', 'date', 'company_id', 'display_type', 'parent_state'])
+ self.env['account.move'].flush_model(['state'])
+
+ new_options = {
+ **options,
+ 'all_entries': False,
+ 'date': dict(options['date']),
+ }
+
+ report = self.env['account.report'].browse(options['report_id'])
+ period_start, period_end = company._get_tax_closing_period_boundaries(fields.Date.from_string(options['date']['date_to']), report)
+ new_options['date']['date_from'] = fields.Date.to_string(period_start)
+ new_options['date']['date_to'] = fields.Date.to_string(period_end)
+ new_options['date']['period_type'] = 'custom'
+ new_options['date']['filter'] = 'custom'
+ new_options = report.with_context(allowed_company_ids=company.ids).get_options(previous_options=new_options)
+ # Force the use of the fiscal position from the original options (_get_options sets the fiscal
+ # position to 'all' when the report is the generic tax report)
+ new_options['fiscal_position'] = options['fiscal_position']
+
+ query = self.env.ref('account.generic_tax_report')._get_report_query(
+ new_options,
+ 'strict_range',
+ domain=self._get_vat_closing_entry_additional_domain()
+ )
+
+ # Check whether it is multilingual, in order to get the translation from the JSON value if present
+ tax_name = self.env['account.tax']._field_to_sql('tax', 'name')
+
+ query = SQL(
+ """
+ SELECT "account_move_line".tax_line_id as tax_id,
+ tax.tax_group_id as tax_group_id,
+ %(tax_name)s as tax_name,
+ "account_move_line".account_id,
+ COALESCE(SUM("account_move_line".balance), 0) as amount
+ FROM account_tax tax, account_tax_repartition_line repartition, %(table_references)s
+ WHERE %(search_condition)s
+ AND tax.id = "account_move_line".tax_line_id
+ AND repartition.id = "account_move_line".tax_repartition_line_id
+ AND repartition.use_in_tax_closing
+ GROUP BY tax.tax_group_id, "account_move_line".tax_line_id, tax.name, "account_move_line".account_id
+ """,
+ tax_name=tax_name,
+ table_references=query.from_clause,
+ search_condition=query.where_clause,
+ )
+ self.env.cr.execute(query)
+ results = self.env.cr.dictfetchall()
+ results = self._postprocess_vat_closing_entry_results(company, new_options, results)
+
+ tax_group_ids = [r['tax_group_id'] for r in results]
+ tax_groups = {}
+ for tg, result in zip(self.env['account.tax.group'].browse(tax_group_ids), results):
+ if tg not in tax_groups:
+ tax_groups[tg] = {}
+ if result.get('tax_id') not in tax_groups[tg]:
+ tax_groups[tg][result.get('tax_id')] = []
+ tax_groups[tg][result.get('tax_id')].append((result.get('tax_name'), result.get('account_id'), result.get('amount')))
+
+ # then loop on previous results to
+ # * add the lines that will balance their sum per account
+ # * make the total per tax group's account triplet
+ # (if 2 tax groups share the same 3 accounts, they should consolidate in the vat closing entry)
+ move_vals_lines = []
+ tax_group_subtotal = {}
+ currency = self.env.company.currency_id
+ for tg, values in tax_groups.items():
+ total = 0
+ # ignore line that have no property defined on tax group
+ if not tg.tax_receivable_account_id or not tg.tax_payable_account_id:
+ continue
+ for dummy, value in values.items():
+ for v in value:
+ tax_name, account_id, amt = v
+ # Line to balance
+ move_vals_lines.append((0, 0, {'name': tax_name, 'debit': abs(amt) if amt < 0 else 0, 'credit': amt if amt > 0 else 0, 'account_id': account_id}))
+ total += amt
+
+ if not currency.is_zero(total):
+ # Add total to correct group
+ key = (tg.advance_tax_payment_account_id.id or False, tg.tax_receivable_account_id.id, tg.tax_payable_account_id.id)
+
+ if tax_group_subtotal.get(key):
+ tax_group_subtotal[key] += total
+ else:
+ tax_group_subtotal[key] = total
+
+ # If the tax report is completely empty, we add two 0-valued lines, using the first in in and out
+ # account id we find on the taxes.
+ if len(move_vals_lines) == 0:
+ rep_ln_in = self.env['account.tax.repartition.line'].search([
+ *self.env['account.tax.repartition.line']._check_company_domain(company),
+ ('account_id.deprecated', '=', False),
+ ('repartition_type', '=', 'tax'),
+ ('document_type', '=', 'invoice'),
+ ('tax_id.type_tax_use', '=', 'purchase')
+ ], limit=1)
+ rep_ln_out = self.env['account.tax.repartition.line'].search([
+ *self.env['account.tax.repartition.line']._check_company_domain(company),
+ ('account_id.deprecated', '=', False),
+ ('repartition_type', '=', 'tax'),
+ ('document_type', '=', 'invoice'),
+ ('tax_id.type_tax_use', '=', 'sale')
+ ], limit=1)
+
+ if rep_ln_out.account_id and rep_ln_in.account_id:
+ move_vals_lines = [
+ Command.create({
+ 'name': _('Tax Received Adjustment'),
+ 'debit': 0,
+ 'credit': 0.0,
+ 'account_id': rep_ln_out.account_id.id
+ }),
+
+ Command.create({
+ 'name': _('Tax Paid Adjustment'),
+ 'debit': 0.0,
+ 'credit': 0,
+ 'account_id': rep_ln_in.account_id.id
+ })
+ ]
+
+ return move_vals_lines, tax_group_subtotal
+
+ def _get_vat_closing_entry_additional_domain(self):
+ return []
+
+ def _postprocess_vat_closing_entry_results(self, company, options, results):
+ # Override this to, for example, apply a rounding to the lines of the closing entry
+ return results
+
+ def _vat_closing_entry_results_rounding(self, company, options, results, rounding_accounts, vat_results_summary):
+ """
+ Apply the rounding from the tax report by adding a line to the end of the query results
+ representing the sum of the roundings on each line of the tax report.
+ """
+ # Ignore if the rounding accounts cannot be found
+ if not rounding_accounts.get('profit') or not rounding_accounts.get('loss'):
+ return results
+
+ total_amount = 0.0
+ tax_group_id = None
+
+ for line in results:
+ total_amount += line['amount']
+ # The accounts on the tax group ids from the results should be uniform,
+ # but we choose the greatest id so that the line appears last on the entry.
+ tax_group_id = line['tax_group_id']
+
+ report = self.env['account.report'].browse(options['report_id'])
+
+ for line in report._get_lines(options):
+ model, record_id = report._get_model_info_from_id(line['id'])
+
+ if model != 'account.report.line':
+ continue
+
+ for (operation_type, report_line_id, column_expression_label) in vat_results_summary:
+ for column in line['columns']:
+ if record_id != report_line_id or column['expression_label'] != column_expression_label:
+ continue
+
+ # We accept 3 types of operations:
+ # 1) due and 2) deductible - This is used for reports that have lines for the payable vat and
+ # lines for the reclaimable vat.
+ # 3) total - This is used for reports that have a single line with the payable/reclaimable vat.
+ if operation_type in {'due', 'total'}:
+ total_amount += column['no_format']
+ elif operation_type == 'deductible':
+ total_amount -= column['no_format']
+
+ currency = company.currency_id
+ total_difference = currency.round(total_amount)
+
+ if not currency.is_zero(total_difference):
+ results.append({
+ 'tax_name': _('Difference from rounding taxes'),
+ 'amount': total_difference * -1,
+ 'tax_group_id': tax_group_id,
+ 'account_id': rounding_accounts['profit'].id if total_difference < 0 else rounding_accounts['loss'].id
+ })
+
+ return results
+
+ @api.model
+ def _add_tax_group_closing_items(self, tax_group_subtotal, closing_move):
+ """Transform the parameter tax_group_subtotal dictionnary into one2many commands.
+
+ Used to balance the tax group accounts for the creation of the vat closing entry.
+ """
+ def _add_line(account, name, company_currency):
+ self.env.cr.execute(sql_account, (
+ account,
+ closing_move.date,
+ closing_move.company_id.id,
+ ))
+ result = self.env.cr.dictfetchone()
+ advance_balance = result.get('balance') or 0
+ # Deduct/Add advance payment
+ if not company_currency.is_zero(advance_balance):
+ line_ids_vals.append((0, 0, {
+ 'name': name,
+ 'debit': abs(advance_balance) if advance_balance < 0 else 0,
+ 'credit': abs(advance_balance) if advance_balance > 0 else 0,
+ 'account_id': account
+ }))
+ return advance_balance
+
+ currency = closing_move.company_id.currency_id
+ sql_account = '''
+ SELECT SUM(aml.balance) AS balance
+ FROM account_move_line aml
+ LEFT JOIN account_move move ON move.id = aml.move_id
+ WHERE aml.account_id = %s
+ AND aml.date <= %s
+ AND move.state = 'posted'
+ AND aml.company_id = %s
+ '''
+ line_ids_vals = []
+ # keep track of already balanced account, as one can be used in several tax group
+ account_already_balanced = []
+ for key, value in tax_group_subtotal.items():
+ total = value
+ # Search if any advance payment done for that configuration
+ if key[0] and key[0] not in account_already_balanced:
+ total += _add_line(key[0], _('Balance tax advance payment account'), currency)
+ account_already_balanced.append(key[0])
+ if key[1] and key[1] not in account_already_balanced:
+ total += _add_line(key[1], _('Balance tax current account (receivable)'), currency)
+ account_already_balanced.append(key[1])
+ if key[2] and key[2] not in account_already_balanced:
+ total += _add_line(key[2], _('Balance tax current account (payable)'), currency)
+ account_already_balanced.append(key[2])
+ # Balance on the receivable/payable tax account
+ if not currency.is_zero(total):
+ line_ids_vals.append(Command.create({
+ 'name': _('Payable tax amount') if total < 0 else _('Receivable tax amount'),
+ 'debit': total if total > 0 else 0,
+ 'credit': abs(total) if total < 0 else 0,
+ 'account_id': key[2] if total < 0 else key[1]
+ }))
+ return line_ids_vals
+
+ @api.model
+ def _redirect_to_misconfigured_tax_groups(self, company, countries):
+ """ Raises a RedirectWarning informing the user his tax groups are missing configuration
+ for a given company, redirecting him to the list view of account.tax.group, filtered
+ accordingly to the provided countries.
+ """
+ need_config_action = {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Tax groups',
+ 'res_model': 'account.tax.group',
+ 'view_mode': 'list',
+ 'views': [[False, 'list']],
+ 'domain': ['|', ('country_id', 'in', countries.ids), ('country_id', '=', False)]
+ }
+
+ raise RedirectWarning(
+ _('Please specify the accounts necessary for the Tax Closing Entry.'),
+ need_config_action,
+ _('Configure your TAX accounts - %s', company.display_name),
+ )
+
+ def _get_fpos_info_for_tax_closing(self, company, report, options):
+ """ Returns the fiscal positions information to use to generate the tax closing
+ for this company, with the provided options.
+
+ :return: (include_domestic, fiscal_positions), where fiscal positions is a recordset
+ and include_domestic is a boolean telling whether or not the domestic closing
+ (i.e. the one without any fiscal position) must also be performed
+ """
+ if options['fiscal_position'] == 'domestic':
+ fiscal_positions = self.env['account.fiscal.position']
+ elif options['fiscal_position'] == 'all':
+ fiscal_positions = self.env['account.fiscal.position'].search([
+ *self.env['account.fiscal.position']._check_company_domain(company),
+ ('foreign_vat', '!=', False),
+ ])
+ else:
+ fpos_ids = [options['fiscal_position']]
+ fiscal_positions = self.env['account.fiscal.position'].browse(fpos_ids)
+
+ if options['fiscal_position'] == 'all':
+ fiscal_country = company.account_fiscal_country_id
+ include_domestic = not fiscal_positions \
+ or not report.country_id \
+ or fiscal_country == fiscal_positions[0].country_id
+ else:
+ include_domestic = options['fiscal_position'] == 'domestic'
+
+ return include_domestic, fiscal_positions
+
+ def _get_amls_with_archived_tags_domain(self, options):
+ domain = [
+ ('tax_tag_ids.active', '=', False),
+ ('parent_state', '=', 'posted'),
+ ('date', '>=', options['date']['date_from']),
+ ]
+ if options['date']['mode'] == 'single':
+ domain.append(('date', '<=', options['date']['date_to']))
+ return domain
+
+ def action_open_amls_with_archived_tags(self, options, params=None):
+ return {
+ 'name': _("Journal items with archived tax tags"),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.move.line',
+ 'domain': self._get_amls_with_archived_tags_domain(options),
+ 'context': {'active_test': False},
+ 'views': [(self.env.ref('odex30_account_reports.view_archived_tag_move_tree').id, 'list')],
+ }
+
+
+class GenericTaxReportCustomHandler(models.AbstractModel):
+ _name = 'account.generic.tax.report.handler'
+ _inherit = 'account.tax.report.handler'
+ _description = 'Generic Tax Report Custom Handler'
+
+ def _get_custom_display_config(self):
+ parent_config = super()._get_custom_display_config()
+ parent_config['css_custom_class'] = 'generic_tax_report'
+ parent_config['templates']['AccountReportLineName'] = 'odex30_account_reports.TaxReportLineName'
+
+ return parent_config
+
+ def _custom_options_initializer(self, report, options, previous_options=None):
+ super()._custom_options_initializer(report, options, previous_options=previous_options)
+
+ # We are on the generic tax report (no country) and the user can not change the fiscal position so we show them all.
+ if not report.country_id and len(options['available_vat_fiscal_positions']) <= (0 if options['allow_domestic'] else 1) and len(options['companies']) <= 1:
+ options['allow_domestic'] = False
+ options['fiscal_position'] = 'all'
+
+ def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None):
+ return self._get_dynamic_lines(report, options, 'default', warnings)
+
+ def _caret_options_initializer(self):
+ return {
+ 'generic_tax_report': [
+ {'name': _("Audit"), 'action': 'caret_option_audit_tax'},
+ ]
+ }
+
+ def _get_dynamic_lines(self, report, options, grouping, warnings=None):
+ """ Compute the report lines for the generic tax report.
+
+ :param options: The report options.
+ :return: A list of lines, each one being a python dictionary.
+ """
+ options_by_column_group = report._split_options_per_column_group(options)
+
+ # Compute tax_base_amount / tax_amount for each selected groupby.
+ if grouping == 'tax_account':
+ groupby_fields = [('src_tax', 'type_tax_use'), ('src_tax', 'id'), ('account', 'id')]
+ comodels = [None, 'account.tax', 'account.account']
+ elif grouping == 'account_tax':
+ groupby_fields = [('src_tax', 'type_tax_use'), ('account', 'id'), ('src_tax', 'id')]
+ comodels = [None, 'account.account', 'account.tax']
+ else:
+ groupby_fields = [('src_tax', 'type_tax_use'), ('src_tax', 'id')]
+ comodels = [None, 'account.tax']
+
+ if grouping in ('tax_account', 'account_tax'):
+ tax_amount_hierarchy = self._read_generic_tax_report_amounts(report, options_by_column_group, groupby_fields)
+ else:
+ tax_amount_hierarchy = self._read_generic_tax_report_amounts_no_tax_details(report, options, options_by_column_group)
+
+
+ # Fetch involved records in order to ensure all lines are sorted according the comodel order.
+ # To do so, we compute 'sorting_map_list' allowing to retrieve each record by id and the order
+ # to be used.
+ record_ids_gb = [set() for dummy in groupby_fields]
+
+ def populate_record_ids_gb_recursively(node, level=0):
+ for k, v in node.items():
+ if k:
+ record_ids_gb[level].add(k)
+ if v.get('children'):
+ populate_record_ids_gb_recursively(v['children'], level=level + 1)
+
+ populate_record_ids_gb_recursively(tax_amount_hierarchy)
+
+ sorting_map_list = []
+ for i, comodel in enumerate(comodels):
+ if comodel:
+ # Relational records.
+ records = self.env[comodel].with_context(active_test=False).search([('id', 'in', tuple(record_ids_gb[i]))])
+ sorting_map = {r.id: (r, j) for j, r in enumerate(records)}
+ sorting_map_list.append(sorting_map)
+ else:
+ # src_tax_type_tax_use.
+ selection = self.env['account.tax']._fields['type_tax_use']._description_selection(self.env)
+ sorting_map_list.append({v[0]: (v, j) for j, v in enumerate(selection) if v[0] in record_ids_gb[i]})
+
+ # Compute report lines.
+ lines = []
+ self._populate_lines_recursively(
+ report,
+ options,
+ lines,
+ sorting_map_list,
+ groupby_fields,
+ tax_amount_hierarchy,
+ warnings=warnings,
+ )
+ return lines
+
+
+ # -------------------------------------------------------------------------
+ # GENERIC TAX REPORT COMPUTATION (DYNAMIC LINES)
+ # -------------------------------------------------------------------------
+
+ @api.model
+ def _read_generic_tax_report_amounts_no_tax_details(self, report, options, options_by_column_group):
+ # Fetch the group of taxes.
+ # If all child taxes have a 'none' type_tax_use, all amounts are aggregated and only the group appears on the report.
+ company_ids = report.get_report_company_ids(options)
+ company_domain = self.env['account.tax']._check_company_domain(company_ids)
+ company_where_query = self.env['account.tax'].with_context(active_test=False)._where_calc(company_domain)
+ self._cr.execute(SQL(
+ '''
+ SELECT
+ account_tax.id,
+ account_tax.type_tax_use,
+ ARRAY_AGG(child_tax.id) AS child_tax_ids,
+ ARRAY_AGG(DISTINCT child_tax.type_tax_use) AS child_types
+ FROM account_tax_filiation_rel account_tax_rel
+ JOIN account_tax ON account_tax.id = account_tax_rel.parent_tax
+ JOIN account_tax child_tax ON child_tax.id = account_tax_rel.child_tax
+ WHERE account_tax.amount_type = 'group'
+ AND %s
+ GROUP BY account_tax.id
+ ''', company_where_query.where_clause or SQL("TRUE")
+ ))
+ group_of_taxes_info = {}
+ child_to_group_of_taxes = {}
+ for row in self._cr.dictfetchall():
+ row['to_expand'] = row['child_types'] != ['none']
+ group_of_taxes_info[row['id']] = row
+ for child_id in row['child_tax_ids']:
+ child_to_group_of_taxes[child_id] = row['id']
+
+ results = defaultdict(lambda: { # key: type_tax_use
+ 'base_amount': {column_group_key: 0.0 for column_group_key in options['column_groups']},
+ 'tax_amount': {column_group_key: 0.0 for column_group_key in options['column_groups']},
+ 'tax_non_deductible': {column_group_key: 0.0 for column_group_key in options['column_groups']},
+ 'tax_deductible': {column_group_key: 0.0 for column_group_key in options['column_groups']},
+ 'tax_due': {column_group_key: 0.0 for column_group_key in options['column_groups']},
+ 'children': defaultdict(lambda: { # key: tax_id
+ 'base_amount': {column_group_key: 0.0 for column_group_key in options['column_groups']},
+ 'tax_amount': {column_group_key: 0.0 for column_group_key in options['column_groups']},
+ 'tax_non_deductible': {column_group_key: 0.0 for column_group_key in options['column_groups']},
+ 'tax_deductible': {column_group_key: 0.0 for column_group_key in options['column_groups']},
+ 'tax_due': {column_group_key: 0.0 for column_group_key in options['column_groups']},
+ }),
+ })
+
+ for column_group_key, options in options_by_column_group.items():
+ query = report._get_report_query(options, 'strict_range')
+ # make sure account_move is always joined
+ if 'account_move_line__move_id' not in query._joins:
+ query.join('account_move_line', 'move_id', 'account_move', 'id', 'move_id')
+
+ # Fetch the base amounts.
+ self._cr.execute(SQL(
+ '''
+ SELECT
+ tax.id AS tax_id,
+ tax.type_tax_use AS tax_type_tax_use,
+ src_group_tax.id AS src_group_tax_id,
+ src_group_tax.type_tax_use AS src_group_tax_type_tax_use,
+ src_tax.id AS src_tax_id,
+ src_tax.type_tax_use AS src_tax_type_tax_use,
+ SUM(account_move_line.balance) AS base_amount
+ FROM %(table_references)s
+ JOIN account_move_line_account_tax_rel tax_rel ON account_move_line.id = tax_rel.account_move_line_id
+ JOIN account_tax tax ON tax.id = tax_rel.account_tax_id
+ LEFT JOIN account_tax src_tax ON src_tax.id = account_move_line.tax_line_id
+ LEFT JOIN account_tax src_group_tax ON src_group_tax.id = account_move_line.group_tax_id
+ WHERE %(search_condition)s
+ AND (
+ /* CABA */
+ account_move_line__move_id.always_tax_exigible
+ OR account_move_line__move_id.tax_cash_basis_rec_id IS NOT NULL
+ OR tax.tax_exigibility != 'on_payment'
+ )
+ AND (
+ (
+ /* Tax lines affecting the base of others. */
+ account_move_line.tax_line_id IS NOT NULL
+ AND (
+ src_tax.type_tax_use IN ('sale', 'purchase')
+ OR src_group_tax.type_tax_use IN ('sale', 'purchase')
+ )
+ )
+ OR
+ (
+ /* For regular base lines. */
+ account_move_line.tax_line_id IS NULL
+ AND tax.type_tax_use IN ('sale', 'purchase')
+ )
+ )
+ GROUP BY tax.id, src_group_tax.id, src_tax.id
+ ORDER BY src_group_tax.sequence, src_group_tax.id, src_tax.sequence, src_tax.id, tax.sequence, tax.id
+ ''',
+ table_references=query.from_clause,
+ search_condition=query.where_clause,
+ ))
+
+ group_of_taxes_with_extra_base_amount = set()
+ for row in self._cr.dictfetchall():
+ is_tax_line = bool(row['src_tax_id'])
+ if is_tax_line:
+ if row['src_group_tax_id'] \
+ and not group_of_taxes_info[row['src_group_tax_id']]['to_expand'] \
+ and row['tax_id'] in group_of_taxes_info[row['src_group_tax_id']]['child_tax_ids']:
+ # Suppose a base of 1000 with a group of taxes 20% affect + 10%.
+ # The base of the group of taxes must be 1000, not 1200 because the group of taxes is not
+ # expanded. So the tax lines affecting the base of its own group of taxes are ignored.
+ pass
+ elif row['tax_type_tax_use'] == 'none' and child_to_group_of_taxes.get(row['tax_id']):
+ # The tax line is affecting the base of a 'none' tax belonging to a group of taxes.
+ # In that case, the amount is accounted as an extra base for that group. However, we need to
+ # account it only once.
+ # For example, suppose a tax 10% affect base of subsequent followed by a group of taxes
+ # 20% + 30%. On a base of 1000.0, the tax line for 10% will affect the base of 20% + 30%.
+ # However, this extra base must be accounted only once since the base of the group of taxes
+ # must be 1100.0 and not 1200.0.
+ group_tax_id = child_to_group_of_taxes[row['tax_id']]
+ if group_tax_id not in group_of_taxes_with_extra_base_amount:
+ group_tax_info = group_of_taxes_info[group_tax_id]
+ results[group_tax_info['type_tax_use']]['children'][group_tax_id]['base_amount'][column_group_key] += row['base_amount']
+ group_of_taxes_with_extra_base_amount.add(group_tax_id)
+ else:
+ tax_type_tax_use = row['src_group_tax_type_tax_use'] or row['src_tax_type_tax_use']
+ results[tax_type_tax_use]['children'][row['tax_id']]['base_amount'][column_group_key] += row['base_amount']
+ else:
+ if row['tax_id'] in group_of_taxes_info and group_of_taxes_info[row['tax_id']]['to_expand']:
+ # Expand the group of taxes since it contains at least one tax with a type != 'none'.
+ group_info = group_of_taxes_info[row['tax_id']]
+ for child_tax_id in group_info['child_tax_ids']:
+ results[group_info['type_tax_use']]['children'][child_tax_id]['base_amount'][column_group_key] += row['base_amount']
+ else:
+ results[row['tax_type_tax_use']]['children'][row['tax_id']]['base_amount'][column_group_key] += row['base_amount']
+
+ # Fetch the tax amounts.
+
+ select_deductible = join_deductible = group_by_deductible = SQL()
+ if options.get('account_journal_report_tax_deductibility_columns'):
+ select_deductible = SQL(""", repartition.use_in_tax_closing AS trl_tax_closing
+ , SIGN(repartition.factor_percent) AS trl_factor""")
+ join_deductible = SQL("""JOIN account_tax_repartition_line repartition
+ ON account_move_line.tax_repartition_line_id = repartition.id""")
+ group_by_deductible = SQL(', repartition.use_in_tax_closing, SIGN(repartition.factor_percent)')
+
+ self._cr.execute(SQL(
+ '''
+ SELECT
+ tax.id AS tax_id,
+ tax.type_tax_use AS tax_type_tax_use,
+ group_tax.id AS group_tax_id,
+ group_tax.type_tax_use AS group_tax_type_tax_use,
+ SUM(account_move_line.balance) AS tax_amount
+ %(select_deductible)s
+ FROM %(table_references)s
+ JOIN account_tax tax ON tax.id = account_move_line.tax_line_id
+ %(join_deductible)s
+ LEFT JOIN account_tax group_tax ON group_tax.id = account_move_line.group_tax_id
+ WHERE %(search_condition)s
+ AND (
+ /* CABA */
+ account_move_line__move_id.always_tax_exigible
+ OR account_move_line__move_id.tax_cash_basis_rec_id IS NOT NULL
+ OR tax.tax_exigibility != 'on_payment'
+ )
+ AND (
+ (group_tax.id IS NULL AND tax.type_tax_use IN ('sale', 'purchase'))
+ OR
+ (group_tax.id IS NOT NULL AND group_tax.type_tax_use IN ('sale', 'purchase'))
+ )
+ GROUP BY tax.id, group_tax.id %(group_by_deductible)s
+ ''',
+ select_deductible=select_deductible,
+ table_references=query.from_clause,
+ join_deductible=join_deductible,
+ search_condition=query.where_clause,
+ group_by_deductible=group_by_deductible,
+ ))
+
+ for row in self._cr.dictfetchall():
+ # Manage group of taxes.
+ # In case the group of taxes is mixing multiple taxes having a type_tax_use != 'none', consider
+ # them instead of the group.
+ tax_id = row['tax_id']
+ if row['group_tax_id']:
+ tax_type_tax_use = row['group_tax_type_tax_use']
+ if not group_of_taxes_info[row['group_tax_id']]['to_expand']:
+ tax_id = row['group_tax_id']
+ else:
+ tax_type_tax_use = row['group_tax_type_tax_use'] or row['tax_type_tax_use']
+
+ results[tax_type_tax_use]['tax_amount'][column_group_key] += row['tax_amount']
+ results[tax_type_tax_use]['children'][tax_id]['tax_amount'][column_group_key] += row['tax_amount']
+
+ if options.get('account_journal_report_tax_deductibility_columns'):
+ tax_detail_label = False
+ if row['trl_factor'] > 0 and tax_type_tax_use == 'purchase':
+ tax_detail_label = 'tax_deductible' if row['trl_tax_closing'] else 'tax_non_deductible'
+ elif row['trl_tax_closing'] and (row['trl_factor'] > 0, tax_type_tax_use) in ((False, 'purchase'), (True, 'sale')):
+ tax_detail_label = 'tax_due'
+
+ if tax_detail_label:
+ results[tax_type_tax_use][tax_detail_label][column_group_key] += row['tax_amount'] * row['trl_factor']
+ results[tax_type_tax_use]['children'][tax_id][tax_detail_label][column_group_key] += row['tax_amount'] * row['trl_factor']
+
+ return results
+
+ def _read_generic_tax_report_amounts(self, report, options_by_column_group, groupby_fields):
+ """ Read the tax details to compute the tax amounts.
+
+ :param options_list: The list of report options, one for each period.
+ :param groupby_fields: A list of tuple (alias, field) representing the way the amounts must be grouped.
+ :return: A dictionary mapping each groupby key (e.g. a tax_id) to a sub dictionary containing:
+
+ base_amount: The tax base amount expressed in company's currency.
+ tax_amount The tax amount expressed in company's currency.
+ children: The children nodes following the same pattern as the current dictionary.
+ """
+ fetch_group_of_taxes = False
+
+ select_clause_list = []
+ groupby_query_list = []
+ for alias, field in groupby_fields:
+ select_clause_list.append(SQL("%s AS %s", SQL.identifier(alias, field), SQL.identifier(f'{alias}_{field}')))
+ groupby_query_list.append(SQL.identifier(alias, field))
+
+ # Fetch both info from the originator tax and the child tax to manage the group of taxes.
+ if alias == 'src_tax':
+ select_clause_list.append(SQL("%s AS %s", SQL.identifier('tax', field), SQL.identifier(f'tax_{field}')))
+ groupby_query_list.append(SQL.identifier('tax', field))
+ fetch_group_of_taxes = True
+
+ # Fetch the group of taxes.
+ # If all children taxes are 'none', all amounts are aggregated and only the group will appear on the report.
+ # If some children taxes are not 'none', the children are displayed.
+ group_of_taxes_to_expand = set()
+ if fetch_group_of_taxes:
+ group_of_taxes = self.env['account.tax'].with_context(active_test=False).search([('amount_type', '=', 'group')])
+ for group in group_of_taxes:
+ if set(group.children_tax_ids.mapped('type_tax_use')) != {'none'}:
+ group_of_taxes_to_expand.add(group.id)
+
+ res = {}
+ for column_group_key, options in options_by_column_group.items():
+ query = report._get_report_query(options, 'strict_range')
+ tax_details_query = self.env['account.move.line']._get_query_tax_details(query.from_clause, query.where_clause)
+
+ # Avoid adding multiple times the same base amount sharing the same grouping_key.
+ # It could happen when dealing with group of taxes for example.
+ row_keys = set()
+
+ self._cr.execute(SQL(
+ '''
+ SELECT
+ %(select_clause)s,
+ trl.document_type = 'refund' AS is_refund,
+ SUM(CASE WHEN tdr.display_type = 'rounding' THEN 0 ELSE tdr.base_amount END) AS base_amount,
+ SUM(tdr.tax_amount) AS tax_amount
+ FROM (%(tax_details_query)s) AS tdr
+ JOIN account_tax_repartition_line trl ON trl.id = tdr.tax_repartition_line_id
+ JOIN account_tax tax ON tax.id = tdr.tax_id
+ JOIN account_tax src_tax ON
+ src_tax.id = COALESCE(tdr.group_tax_id, tdr.tax_id)
+ AND src_tax.type_tax_use IN ('sale', 'purchase')
+ JOIN account_account account ON account.id = tdr.base_account_id
+ WHERE tdr.tax_exigible
+ GROUP BY tdr.tax_repartition_line_id, trl.document_type, %(groupby_query)s
+ ORDER BY src_tax.sequence, src_tax.id, tax.sequence, tax.id
+ ''',
+ select_clause=SQL(',').join(select_clause_list),
+ tax_details_query=tax_details_query,
+ groupby_query=SQL(',').join(groupby_query_list),
+ ))
+
+ for row in self._cr.dictfetchall():
+ node = res
+
+ # tuple of values used to prevent adding multiple times the same base amount.
+ cumulated_row_key = [row['is_refund']]
+
+ for alias, field in groupby_fields:
+ grouping_key = f'{alias}_{field}'
+
+ # Manage group of taxes.
+ # In case the group of taxes is mixing multiple taxes having a type_tax_use != 'none', consider
+ # them instead of the group.
+ if grouping_key == 'src_tax_id' and row['src_tax_id'] in group_of_taxes_to_expand:
+ # Add the originator group to the grouping key, to make sure that its base amount is not
+ # treated twice, for hybrid cases where a tax is both used in a group and independently.
+ cumulated_row_key.append(row[grouping_key])
+
+ # Ensure the child tax is used instead of the group.
+ grouping_key = 'tax_id'
+
+ row_key = row[grouping_key]
+ cumulated_row_key.append(row_key)
+ cumulated_row_key_tuple = tuple(cumulated_row_key)
+
+ node.setdefault(row_key, {
+ 'base_amount': {column_group_key: 0.0 for column_group_key in options['column_groups']},
+ 'tax_amount': {column_group_key: 0.0 for column_group_key in options['column_groups']},
+ 'children': {},
+ })
+ sub_node = node[row_key]
+
+ # Add amounts.
+ if cumulated_row_key_tuple not in row_keys:
+ sub_node['base_amount'][column_group_key] += row['base_amount']
+ sub_node['tax_amount'][column_group_key] += row['tax_amount']
+
+ node = sub_node['children']
+ row_keys.add(cumulated_row_key_tuple)
+
+ return res
+
+ def _populate_lines_recursively(self, report, options, lines, sorting_map_list, groupby_fields, values_node, index=0, type_tax_use=None, parent_line_id=None, warnings=None):
+ ''' Populate the list of report lines passed as parameter recursively. At this point, every amounts is already
+ fetched for every periods and every groupby.
+
+ :param options: The report options.
+ :param lines: The list of report lines to populate.
+ :param sorting_map_list: A list of dictionary mapping each encountered key with a weight to sort the results.
+ :param index: The index of the current element to process (also equals to the level into the hierarchy).
+ :param groupby_fields: A list of tuple defining in which way tax amounts should be grouped.
+ :param values_node: The node containing the amounts and children into the hierarchy.
+ :param type_tax_use: The type_tax_use of the tax.
+ :param parent_line_id: The line id of the parent line (if any)
+ :param warnings The warnings dictionnary.
+ '''
+ if index == len(groupby_fields):
+ return
+
+ alias, field = groupby_fields[index]
+ groupby_key = f'{alias}_{field}'
+
+ # Sort the keys in order to add the lines in the same order as the records.
+ sorting_map = sorting_map_list[index]
+ sorted_keys = sorted(list(values_node.keys()), key=lambda x: sorting_map[x][1])
+
+ for key in sorted_keys:
+
+ # Compute 'type_tax_use' with the first grouping since 'src_tax_type_tax_use' is always
+ # the first one.
+ if groupby_key == 'src_tax_type_tax_use':
+ type_tax_use = key
+ sign = -1 if type_tax_use == 'sale' else 1
+
+ # Prepare columns.
+ tax_amount_dict = values_node[key]
+ columns = []
+ tax_base_amounts = tax_amount_dict['base_amount']
+ tax_amounts = tax_amount_dict['tax_amount']
+
+ for column in options['columns']:
+ tax_base_amount = tax_base_amounts[column['column_group_key']]
+ tax_amount = tax_amounts[column['column_group_key']]
+
+ expr_label = column.get('expression_label')
+ col_value = ''
+
+ if expr_label == 'net' and index == len(groupby_fields) - 1:
+ col_value = sign * tax_base_amount
+
+ if expr_label == 'tax':
+ col_value = sign * tax_amount
+
+ columns.append(report._build_column_dict(col_value, column, options=options))
+
+ # Add the non-deductible, deductible and due tax amounts.
+ if expr_label == 'tax' and options.get('account_journal_report_tax_deductibility_columns'):
+ for deduct_type in ('tax_non_deductible', 'tax_deductible', 'tax_due'):
+ columns.append(report._build_column_dict(
+ col_value=sign * tax_amount_dict[deduct_type][column['column_group_key']],
+ col_data={
+ 'figure_type': 'monetary',
+ 'column_group_key': column['column_group_key'],
+ 'expression_label': deduct_type,
+ },
+ options=options,
+ ))
+
+ # Prepare line.
+ default_vals = {
+ 'columns': columns,
+ 'level': index if index == 0 else index + 1,
+ 'unfoldable': False,
+ }
+ report_line = self._build_report_line(report, options, default_vals, groupby_key, sorting_map[key][0], parent_line_id, warnings)
+
+ if groupby_key == 'src_tax_id':
+ report_line['caret_options'] = 'generic_tax_report'
+
+ lines.append((0, report_line))
+
+ # Process children recursively.
+ self._populate_lines_recursively(
+ report,
+ options,
+ lines,
+ sorting_map_list,
+ groupby_fields,
+ tax_amount_dict.get('children'),
+ index=index + 1,
+ type_tax_use=type_tax_use,
+ parent_line_id=report_line['id'],
+ warnings=warnings,
+ )
+
+ def _build_report_line(self, report, options, default_vals, groupby_key, value, parent_line_id, warnings=None):
+ """ Build the report line accordingly to its type.
+ :param options: The report options.
+ :param default_vals: The pre-computed report line values.
+ :param groupby_key: The grouping_key record.
+ :param value: The value that could be a record.
+ :param parent_line_id The line id of the parent line (if any, can be None otherwise)
+ :param warnings: The warnings dictionary.
+ :return: A python dictionary.
+ """
+ report_line = dict(default_vals)
+ if parent_line_id is not None:
+ report_line['parent_id'] = parent_line_id
+
+ if groupby_key == 'src_tax_type_tax_use':
+ type_tax_use_option = value
+ report_line['id'] = report._get_generic_line_id(None, None, markup=type_tax_use_option[0], parent_line_id=parent_line_id)
+ report_line['name'] = type_tax_use_option[1]
+
+ elif groupby_key == 'src_tax_id':
+ tax = value
+ report_line['id'] = report._get_generic_line_id(tax._name, tax.id, parent_line_id=parent_line_id)
+
+ if tax.amount_type == 'percent':
+ report_line['name'] = f"{tax.name} ({tax.amount}%)"
+
+ if warnings is not None:
+ self._check_line_consistency(report, options, report_line, tax, warnings)
+ elif tax.amount_type == 'fixed':
+ report_line['name'] = f"{tax.name} ({tax.amount})"
+ else:
+ report_line['name'] = tax.name
+
+ if options.get('multi-company'):
+ report_line['name'] = f"{report_line['name']} - {tax.company_id.display_name}"
+
+ elif groupby_key == 'account_id':
+ account = value
+ report_line['id'] = report._get_generic_line_id(account._name, account.id, parent_line_id=parent_line_id)
+
+ if options.get('multi-company'):
+ report_line['name'] = f"{account.display_name} - {account.company_id.display_name}"
+ else:
+ report_line['name'] = account.display_name
+
+ return report_line
+
+ def _check_line_consistency(self, report, options, report_line, tax, warnings=None):
+ tax_applied = tax.amount * sum(tax.invoice_repartition_line_ids.filtered(lambda tax_rep: tax_rep.repartition_type == 'tax').mapped('factor')) / 100
+
+ for column_group_key, column_group_options in report._split_options_per_column_group(options).items():
+ net_value = next((col['no_format'] for col in report_line['columns'] if col['column_group_key'] == column_group_key and col['expression_label'] == 'net'), 0)
+ tax_value = next((col['no_format'] for col in report_line['columns'] if col['column_group_key'] == column_group_key and col['expression_label'] == 'tax'), 0)
+
+ if net_value == '': # noqa: PLC1901
+ continue
+
+ currency = self.env.company.currency_id
+ computed_tax_amount = float(net_value or 0) * tax_applied
+ is_inconsistent = currency.compare_amounts(computed_tax_amount, tax_value)
+
+ if is_inconsistent:
+ error = abs(abs(tax_value) - abs(computed_tax_amount)) / float(net_value or 1)
+
+ # Error is bigger than 0.1%. We can not ignore it.
+ if error > 0.001:
+ report_line['alert'] = True
+ warnings['odex30_account_reports.tax_report_warning_lines_consistency'] = {'alert_type': 'danger'}
+
+ return
+
+ # -------------------------------------------------------------------------
+ # BUTTONS & CARET OPTIONS
+ # -------------------------------------------------------------------------
+
+ def caret_option_audit_tax(self, options, params):
+ report = self.env['account.report'].browse(options['report_id'])
+ model, tax_id = report._get_model_info_from_id(params['line_id'])
+
+ if model != 'account.tax':
+ raise UserError(_("Cannot audit tax from another model than account.tax."))
+
+ tax = self.env['account.tax'].browse(tax_id)
+
+ if tax.amount_type == 'group':
+ tax_affecting_base_domain = [
+ ('tax_ids', 'in', tax.children_tax_ids.ids),
+ ('tax_repartition_line_id', '!=', False),
+ ]
+ else:
+ tax_affecting_base_domain = [
+ ('tax_ids', '=', tax.id),
+ ('tax_ids.type_tax_use', '=', tax.type_tax_use),
+ ('tax_repartition_line_id', '!=', False),
+ ]
+
+ domain = report._get_options_domain(options, 'strict_range') + expression.OR((
+ # Base lines
+ [
+ ('tax_ids', 'in', tax.ids),
+ ('tax_ids.type_tax_use', '=', tax.type_tax_use),
+ ('tax_repartition_line_id', '=', False),
+ ],
+ # Tax lines
+ [
+ ('group_tax_id', '=', tax.id) if tax.amount_type == 'group' else ('tax_line_id', '=', tax.id),
+ ],
+ # Tax lines acting as base lines
+ tax_affecting_base_domain,
+ ))
+
+ ctx = self._context.copy()
+ ctx.update({'search_default_group_by_account': 2, 'expand': 1})
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Journal Items for Tax Audit'),
+ 'res_model': 'account.move.line',
+ 'views': [[self.env.ref('account.view_move_line_tax_audit_tree').id, 'list']],
+ 'domain': domain,
+ 'context': ctx,
+ }
+
+
+class GenericTaxReportCustomHandlerAT(models.AbstractModel):
+ _name = 'account.generic.tax.report.handler.account.tax'
+ _inherit = 'account.generic.tax.report.handler'
+ _description = 'Generic Tax Report Custom Handler (Account -> Tax)'
+
+ def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None):
+ return super()._get_dynamic_lines(report, options, 'account_tax', warnings)
+
+
+class GenericTaxReportCustomHandlerTA(models.AbstractModel):
+ _name = 'account.generic.tax.report.handler.tax.account'
+ _inherit = 'account.generic.tax.report.handler'
+ _description = 'Generic Tax Report Custom Handler (Tax -> Account)'
+
+ def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None):
+ return super()._get_dynamic_lines(report, options, 'tax_account', warnings)
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_journal_dashboard.py b/dev_odex30_accounting/odex30_account_reports/models/account_journal_dashboard.py
new file mode 100644
index 0000000..0b93785
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_journal_dashboard.py
@@ -0,0 +1,28 @@
+from odoo import models
+
+import ast
+
+
+class AccountJournal(models.Model):
+ _inherit = 'account.journal'
+
+ def _fill_general_dashboard_data(self, dashboard_data):
+ super()._fill_general_dashboard_data(dashboard_data)
+ for journal in self.filtered(lambda journal: journal.type == 'general'):
+ dashboard_data[journal.id]['is_account_tax_periodicity_journal'] = journal == journal.company_id._get_tax_closing_journal()
+
+ def action_open_bank_balance_in_gl(self):
+
+ self.ensure_one()
+ action = self.env["ir.actions.actions"]._for_xml_id("odex30_account_reports.action_account_report_general_ledger")
+
+ action['context'] = dict(ast.literal_eval(action['context']), default_filter_accounts=self.default_account_id.code)
+
+ return action
+
+ def _transform_activity_dict(self, activity_data):
+ error_type_id = self.env['ir.model.data']._xmlid_to_res_id('odex30_account_reports.mail_activity_type_tax_report_error', raise_if_not_found=False)
+ return {
+ **super()._transform_activity_dict(activity_data),
+ 'is_tax_report_error': error_type_id and activity_data['act_type_id'] == error_type_id,
+ }
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_journal_report.py b/dev_odex30_accounting/odex30_account_reports/models/account_journal_report.py
new file mode 100644
index 0000000..14e6594
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_journal_report.py
@@ -0,0 +1,1385 @@
+import io
+import datetime
+
+from PIL import ImageFont
+from markupsafe import Markup
+
+from odoo import models, _
+from odoo.tools import SQL
+from odoo.tools.misc import xlsxwriter, file_path
+from collections import defaultdict
+from itertools import chain
+
+XLSX_GRAY_200 = '#EEEEEE'
+XLSX_BORDER_COLOR = '#B4B4B4'
+XLSX_FONT_SIZE_DEFAULT = 8
+XLSX_FONT_SIZE_HEADING = 11
+
+
+class JournalReportCustomHandler(models.AbstractModel):
+ _name = "account.journal.report.handler"
+ _inherit = "account.report.custom.handler"
+ _description = "Journal Report Custom Handler"
+
+ def _custom_options_initializer(self, report, options, previous_options):
+
+ # Initialise the custom option for this report.
+ options['ignore_totals_below_sections'] = True
+ options['show_payment_lines'] = previous_options.get('show_payment_lines', True)
+
+ def _get_custom_display_config(self):
+ return {
+ 'css_custom_class': 'journal_report',
+ 'pdf_css_custom_class': 'journal_report_pdf',
+ 'components': {
+ 'AccountReportLine': 'odex30_account_reports.JournalReportLine',
+ },
+ 'templates': {
+ 'AccountReportFilters': 'odex30_account_reports.JournalReportFilters',
+ 'AccountReportLineName': 'odex30_account_reports.JournalReportLineName',
+ },
+ 'pdf_export': {
+ 'pdf_export_main': 'odex30_account_reports.journal_report_pdf_export_main',
+ },
+ }
+
+
+ def _report_custom_engine_journal_report(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+
+ def build_result_dict(current_groupby, query_line):
+ """
+ Creates a line entry used by the custom engine
+ """
+ if current_groupby == 'account_id':
+ code = query_line['account_code'][0]
+ elif current_groupby == 'journal_id':
+ code = query_line['journal_code'][0]
+ else:
+ code = None
+
+ result_line_dict = {
+ 'code': code,
+ 'credit': query_line['credit'],
+ 'debit': query_line['debit'],
+ 'balance': query_line['balance'] if current_groupby == 'account_id' else None
+ }
+ return query_line['grouping_key'], result_line_dict
+
+ report = self.env['account.report'].browse(options['report_id'])
+ report._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else []))
+
+ # If it is the first line, we want to render our column label
+ # Since we don't use the one from the base report
+ if not current_groupby:
+ return {
+ 'code': None,
+ 'debit': None,
+ 'credit': None,
+ 'balance': None
+ }
+
+ query = report._get_report_query(options, 'strict_range')
+ account_alias = query.join(
+ lhs_alias='account_move_line',
+ lhs_column='account_id',
+ rhs_table='account_account',
+ rhs_column='id',
+ link='account_id_for_code', # Custom link name to avoid potential alias clash with what is generated by _field_to_sql for the groupby below
+ )
+ account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query)
+
+ groupby_clause = self.env['account.move.line']._field_to_sql('account_move_line', current_groupby, query)
+ select_from_groupby = SQL('%s AS grouping_key', groupby_clause)
+
+ query = SQL(
+ """
+ SELECT
+ %(select_from_groupby)s,
+ ARRAY_AGG(DISTINCT %(account_code)s) AS account_code,
+ ARRAY_AGG(DISTINCT j.code) AS journal_code,
+ SUM("account_move_line".debit) AS debit,
+ SUM("account_move_line".credit) AS credit,
+ SUM("account_move_line".balance) AS balance
+ FROM %(table)s
+ JOIN account_move am ON am.id = account_move_line.move_id
+ JOIN account_journal j ON j.id = am.journal_id
+ JOIN res_company cp ON cp.id = am.company_id
+ WHERE %(case_statement)s AND %(search_conditions)s
+ GROUP BY %(groupby_clause)s
+ ORDER BY %(groupby_clause)s
+ """,
+ select_from_groupby=select_from_groupby,
+ account_code=account_code,
+ table=query.from_clause,
+ search_conditions=query.where_clause,
+ case_statement=self._get_payment_lines_filter_case_statement(options),
+ groupby_clause=groupby_clause
+ )
+ self._cr.execute(query)
+ query_lines = self._cr.dictfetchall()
+ result_lines = []
+
+ for query_line in query_lines:
+ result_lines.append(build_result_dict(current_groupby, query_line))
+
+ return result_lines
+
+ def _custom_line_postprocessor(self, report, options, lines):
+
+ new_lines = []
+
+ if not lines:
+ return new_lines
+
+ for i, line in enumerate(lines):
+ new_lines.append(line)
+ line_id = line['id']
+
+ line_model, res_id = report._get_model_info_from_id(line_id)
+ if line_model == 'account.journal':
+ line['journal_id'] = res_id
+ elif line_model == 'account.account':
+ res_ids_map = report._get_res_ids_from_line_id(line_id, ['account.journal', 'account.account'])
+ line['journal_id'] = res_ids_map['account.journal']
+ line['account_id'] = res_ids_map['account.account']
+ line['date'] = options['date']
+
+ journal = self.env['account.journal'].browse(line['journal_id'])
+
+ # If it is the last line of the journal section
+ # Check if the journal has taxes and if so, add the tax summaries
+ if (i + 1 == len(lines) or (i + 1 < len(lines) and report._get_model_info_from_id(lines[i + 1]['id'])[0] != 'account.account')) and self._section_has_tax(options, journal.id):
+ tax_summary_line = {
+ 'id': report._get_generic_line_id(False, False, parent_line_id=line['parent_id'], markup='tax_report_section'),
+ 'name': '',
+ 'parent_id': line['parent_id'],
+ 'journal_id': journal.id,
+ 'is_tax_section_line': True,
+ 'columns': [],
+ 'colspan': len(options['columns']) + 1,
+ 'level': 4,
+ **self._get_tax_summary_section(options, {'id': journal.id, 'type': journal.type})
+ }
+ new_lines.append(tax_summary_line)
+
+ # If we render the first level it means that we need to render
+ # the global tax summary lines
+ if report._get_model_info_from_id(lines[0]['id'])[0] == 'account.report.line':
+ if self._section_has_tax(options, False):
+ # We only add the global summary line if it has taxes
+ new_lines.append({
+ 'id': report._get_generic_line_id(False, False, markup='tax_report_section_heading'),
+ 'name': _('Global Tax Summary'),
+ 'level': 0,
+ 'columns': [],
+ 'unfoldable': False,
+ 'colspan': len(options['columns']) + 1
+ # We want it to take the whole line. It makes it easier to unfold it.
+ })
+ summary_line = {
+ 'id': report._get_generic_line_id(False, False, markup='tax_report_section'),
+ 'name': '',
+ 'is_tax_section_line': True,
+ 'columns': [],
+ 'colspan': len(options['columns']) + 1,
+ 'level': 4,
+ 'class': 'o_odex30_account_reports_ja_subtable',
+ **self._get_tax_summary_section(options)
+ }
+ new_lines.append(summary_line)
+
+ return new_lines
+
+ def format_column_values_from_client(self, options, lines):
+ """
+ Format column values for journal reports, including tax summary sections.
+ Called via dispatch_report_action when rounding unit changes on client side.
+ """
+ report = self.env['account.report'].browse(options['report_id'])
+ for line_dict in lines:
+ if line_dict.get('is_tax_section_line'):
+ self._format_tax_summary_line(report, options, line_dict)
+
+ return report.format_column_values_from_client(options, lines)
+
+ def _format_tax_summary_line(self, report, options, line_dict):
+ """ Apply formatting to tax summary monetary values based on current options. """
+ # Format tax_report_lines (individual tax details)
+ tax_report_lines = line_dict.get('tax_report_lines')
+ if tax_report_lines:
+ monetary_fields = ['base_amount', 'tax_amount', 'tax_non_deductible', 'tax_deductible', 'tax_due']
+ for tax_line in chain.from_iterable(tax_report_lines.values()):
+ for field in monetary_fields:
+ no_format_field = f'{field}_no_format'
+ no_format_value = tax_line.get(no_format_field)
+ if no_format_value is not None:
+ tax_line[field] = report.format_value(options, no_format_value, figure_type='monetary')
+
+ # Format tax_grid_summary_lines (tax grid summaries)
+ tax_grid_lines = line_dict.get('tax_grid_summary_lines')
+ if tax_grid_lines:
+ for country_grids in tax_grid_lines.values():
+ for grid_line in country_grids.values():
+ plus = grid_line.get('+_no_format', 0)
+ minus = grid_line.get('-_no_format', 0)
+ grid_line['+'] = report.format_value(options, plus, figure_type='monetary')
+ grid_line['-'] = report.format_value(options, minus, figure_type='monetary')
+ grid_line['impact'] = report.format_value(options, plus - minus, figure_type='monetary')
+
+ ##########################################################################
+ # PDF Export
+ ##########################################################################
+
+ def export_to_pdf(self, options):
+ """
+ Overrides the default export_to_pdf function from account.report to
+ not use the default lines system since we make a different report
+ from the UI
+ """
+ report = self.env['account.report'].browse(options['report_id'])
+ base_url = report.get_base_url()
+ print_options = {
+ **report.get_options(previous_options={**options, 'export_mode': 'print'}),
+ 'css_custom_class': self._get_custom_display_config().get('pdf_css_custom_class', 'journal_report_pdf')
+ }
+ rcontext = {
+ 'mode': 'print',
+ 'base_url': base_url,
+ 'company': self.env.company,
+ }
+
+ footer = self.env['ir.actions.report']._render_template('odex30_account_reports.internal_layout', values=rcontext)
+ footer = self.env['ir.actions.report']._render_template('web.minimal_layout', values=dict(rcontext, subst=True, body=Markup(footer.decode())))
+
+ document_data = self._generate_document_data_for_export(report, print_options, 'pdf')
+ render_values = {
+ 'report': report,
+ 'options': print_options,
+ 'base_url': base_url,
+ 'document_data': document_data
+ }
+ body = self.env['ir.qweb']._render(self._get_custom_display_config()['pdf_export']['pdf_export_main'], render_values)
+
+ action_report = self.env['ir.actions.report']
+ pdf_file_stream = io.BytesIO(action_report._run_wkhtmltopdf(
+ [body],
+ footer=footer.decode(),
+ landscape=False,
+ specific_paperformat_args={
+ 'data-report-margin-top': 10,
+ 'data-report-header-spacing': 10,
+ 'data-report-margin-bottom': 15,
+ }
+ ))
+
+ pdf_result = pdf_file_stream.getvalue()
+ pdf_file_stream.close()
+
+ return {
+ 'file_name': report.get_default_report_filename(print_options, 'pdf'),
+ 'file_content': pdf_result,
+ 'file_type': 'pdf',
+ }
+
+ ##########################################################################
+ # XLSX Export
+ ##########################################################################
+
+ def export_to_xlsx(self, options, response=None):
+ """
+ Overrides the default XLSX Generation from account.repor to use a custom one.
+ """
+ output = io.BytesIO()
+ workbook = xlsxwriter.Workbook(output, {
+ 'in_memory': True,
+ 'strings_to_formulas': False,
+ })
+ report = self.env['account.report'].search([('id', '=', options['report_id'])], limit=1)
+ print_options = report.get_options(previous_options={**options, 'export_mode': 'print'})
+ document_data = self._generate_document_data_for_export(report, print_options, 'xlsx')
+
+ # We need to use fonts to calculate column width otherwise column width would be ugly
+ # Using Lato as reference font is a hack and is not recommended. Customer computers don't have this font by default and so
+ # the generated xlsx wouldn't have this font. Since it is not by default, we preferred using Arial font as default and keep
+ # Lato as reference for columns width calculations.
+ fonts = {}
+ for font_size in (XLSX_FONT_SIZE_HEADING, XLSX_FONT_SIZE_DEFAULT):
+ fonts[font_size] = defaultdict()
+ for font_type in ('Reg', 'Bol', 'RegIta', 'BolIta'):
+ try:
+ lato_path = f'web/static/fonts/lato/Lato-{font_type}-webfont.ttf'
+ fonts[font_size][font_type] = ImageFont.truetype(file_path(lato_path), font_size)
+ except (OSError, FileNotFoundError):
+ # This won't give great result, but it will work.
+ fonts[font_size][font_type] = ImageFont.load_default()
+
+ for journal_vals in document_data['journals_vals']:
+ cursor_x = 0
+ cursor_y = 0
+
+ # Default sheet properties
+ sheet = workbook.add_worksheet(journal_vals['name'][:31])
+ columns = journal_vals['columns']
+
+ for column in columns:
+ align = 'left'
+ if 'o_right_alignment' in column.get('class', ''):
+ align = 'right'
+ self._write_cell(cursor_x, cursor_y, column['name'], 1, False, report, fonts, workbook, sheet, XLSX_FONT_SIZE_HEADING,
+ True, XLSX_GRAY_200, align, 2, 2)
+ cursor_x = cursor_x + 1
+
+ # Set cursor coordinates for the table generation
+ cursor_y += 1
+ cursor_x = 0
+ for line in journal_vals['lines'][:-1]:
+ is_first_aml_line = False
+ for column in columns:
+ border_top = 0 if not is_first_aml_line else 1
+ align = 'left'
+
+ if line.get(column['label'], {}).get('data'):
+ data = line[column['label']]['data']
+ is_date = isinstance(data, datetime.date)
+ bold = False
+
+ if 'o_right_alignment' in column.get('class', ''):
+ align = 'right'
+
+ if line[column['label']].get('class') and 'o_bold' in line[column['label']]['class']:
+ # if the cell has bold styling, should only be on the first line of each aml
+ is_first_aml_line = True
+ border_top = 1
+ bold = True
+
+ self._write_cell(cursor_x, cursor_y, data, 1, is_date, report, fonts, workbook, sheet, XLSX_FONT_SIZE_DEFAULT,
+ bold, 'white', align, 0, border_top, XLSX_BORDER_COLOR)
+
+ else:
+ # Empty value
+ self._write_cell(cursor_x, cursor_y, '', 1, False, report, fonts, workbook, sheet, XLSX_FONT_SIZE_DEFAULT, False,
+ 'white', align, 0, border_top, XLSX_BORDER_COLOR)
+
+ cursor_x += 1
+ cursor_x = 0
+ cursor_y += 1
+
+ # Draw total line
+ total_line = journal_vals['lines'][-1]
+ for column in columns:
+ data = ''
+ align = 'left'
+
+ if total_line.get(column['label'], {}).get('data'):
+ data = total_line[column['label']]['data']
+
+ if 'o_right_alignment' in column.get('class', ''):
+ align = 'right'
+
+ self._write_cell(cursor_x, cursor_y, data, 1, False, report, fonts, workbook, sheet, XLSX_FONT_SIZE_DEFAULT, True,
+ XLSX_GRAY_200, align, 2, 2)
+ cursor_x += 1
+
+ cursor_x = 0
+
+ sheet.set_default_row(20)
+ sheet.set_row(0, 30)
+
+ # Tax tables drawing
+ if journal_vals.get('tax_summary'):
+ self._write_tax_summaries_to_sheet(report, workbook, sheet, fonts, len(columns) + 1, 1, journal_vals['tax_summary'])
+
+ if document_data.get('global_tax_summary'):
+ self._write_tax_summaries_to_sheet(
+ report,
+ workbook,
+ workbook.add_worksheet(_('Global Tax Summary')[:31]),
+ fonts,
+ 0,
+ 0,
+ document_data['global_tax_summary']
+ )
+
+ report._add_options_xlsx_sheet(workbook, [print_options])
+ workbook.close()
+ output.seek(0)
+ generated_file = output.read()
+ output.close()
+
+ return {
+ 'file_name': report.get_default_report_filename(options, 'xlsx'),
+ 'file_content': generated_file,
+ 'file_type': 'xlsx',
+ }
+
+ def _write_cell(self, x, y, value, colspan, datetime, report, fonts, workbook, sheet, font_size, bold=False,
+ bg_color='white', align='left', border_bottom=0, border_top=0, border_color='0x000000'):
+ """
+ Write a value to a specific cell in the sheet with specific styling
+
+ This helps to not create style format for every use case
+
+ :param x: The x coordinate of the cell to write in
+ :param y: The y coordinate of the cell to write in
+ :param value: The value to write
+ :param colspan: The number of columns to extend
+ :param datetime: True if the value is a date else False
+ :param report: The current report
+ :param fonts: The fonts used to calculate the size of each cells. We use Lato because we cannot get Arial but, we write in Arial since we cannot embed Lato on the worksheet
+ :param workbook: The workbook currently using
+ :param sheet: The sheet from the workbook to write on
+ :param font_size: The font size to write with
+ :param bold: True if the written value should be bold default: False
+ :param bg_color: The background color of the cell in hex or string ex: '#fff' default: 'white'
+ :param align: The alignement of the text ex: 'left', 'right', 'center' default: 'left'
+ :param border_bottom: The width of the bottom border default: 0
+ :param border_top: The width of the top border default: 0
+ :param border_color: The color of the borders in hex or string default: '0x000'
+ """
+ style = workbook.add_format({
+ 'font_name': 'Arial',
+ 'font_size': font_size,
+ 'bold': bold,
+ 'bg_color': bg_color,
+ 'align': align,
+ 'bottom': border_bottom,
+ 'top': border_top,
+ 'border_color': border_color,
+ })
+
+ if colspan == 1:
+ if datetime:
+ style.set_num_format('yyyy-mm-dd')
+ sheet.write_datetime(y, x, value, style)
+ else:
+ # Some account_move_lines cells can have multiple lines: one for the title then some additional lines for text.
+ # On Xlsx it's better to keep everything on one line so when you click on cell, all the value is shown and not juste the title
+ if isinstance(value, str):
+ value = value.replace('\n', ' ')
+ report._set_xlsx_cell_sizes(sheet, fonts[font_size], x, y, value, style, colspan > 1)
+ sheet.write(y, x, value, style)
+ else:
+ sheet.merge_range(y, x, y, x + colspan - 1, value, style)
+
+ def _write_tax_summaries_to_sheet(self, report, workbook, sheet, fonts, start_x, start_y, tax_summary):
+ cursor_x = start_x
+ cursor_y = start_y
+
+ # Tax applied
+ columns = []
+ taxes = tax_summary.get('tax_report_lines')
+ if taxes:
+ start_align_right = start_x + 1
+
+ if len(taxes) > 1:
+ start_align_right += 1
+ columns.append(_('Country'))
+
+ columns += [_('Name'), _('Base Amount'), _('Tax Amount')]
+ if tax_summary.get('tax_non_deductible_column'):
+ columns.append(_('Non-Deductible'))
+ if tax_summary.get('tax_deductible_column'):
+ columns.append(_('Deductible'))
+ if tax_summary.get('tax_due_column'):
+ columns.append(_('Due'))
+
+ # Draw Tax Applied Table
+ # Write tax applied header amd columns
+ self._write_cell(cursor_x, cursor_y, _('Taxes Applied'), len(columns), False, report, fonts, workbook, sheet,
+ XLSX_FONT_SIZE_HEADING, True, 'white', 'left', 2)
+ cursor_y += 1
+ for column in columns:
+ align = 'left'
+ if cursor_x >= start_align_right:
+ align = 'right'
+ self._write_cell(cursor_x, cursor_y, column, 1, False, report, fonts, workbook, sheet, XLSX_FONT_SIZE_DEFAULT, True,
+ XLSX_GRAY_200, align, 2)
+ cursor_x += 1
+
+ cursor_x = start_x
+ cursor_y += 1
+
+ for country in taxes:
+ is_country_first_line = True
+ for tax in taxes[country]:
+ if len(taxes) > 1:
+ if is_country_first_line:
+ is_country_first_line = not is_country_first_line
+ self._write_cell(cursor_x, cursor_y, country, 1, False, report, fonts, workbook, sheet,
+ XLSX_FONT_SIZE_DEFAULT, True, 'white', 'left', 1, 0, XLSX_BORDER_COLOR)
+
+ cursor_x += 1
+
+ self._write_cell(cursor_x, cursor_y, tax['name'], 1, False, report, fonts, workbook, sheet, XLSX_FONT_SIZE_DEFAULT,
+ True, 'white', 'left', 1, 0, XLSX_BORDER_COLOR)
+ self._write_cell(cursor_x + 1, cursor_y, tax['base_amount'], 1, False, report, fonts, workbook, sheet,
+ XLSX_FONT_SIZE_DEFAULT, False, 'white', 'right', 1, 0, XLSX_BORDER_COLOR)
+ self._write_cell(cursor_x + 2, cursor_y, tax['tax_amount'], 1, False, report, fonts, workbook, sheet,
+ XLSX_FONT_SIZE_DEFAULT, False, 'white', 'right', 1, 0, XLSX_BORDER_COLOR)
+ cursor_x += 3
+
+ if tax_summary.get('tax_non_deductible_column'):
+ self._write_cell(cursor_x, cursor_y, tax['tax_non_deductible'], 1, False, report, fonts, workbook, sheet,
+ XLSX_FONT_SIZE_DEFAULT, False, 'white', 'right', 1, 0, XLSX_BORDER_COLOR)
+ cursor_x += 1
+
+ if tax_summary.get('tax_deductible_column'):
+ self._write_cell(cursor_x, cursor_y, tax['tax_deductible'], 1, False, report, fonts, workbook, sheet,
+ XLSX_FONT_SIZE_DEFAULT, False, 'white', 'right', 1, 0, XLSX_BORDER_COLOR)
+ cursor_x += 1
+
+ if tax_summary.get('tax_due_column'):
+ self._write_cell(cursor_x, cursor_y, tax['tax_due'], 1, False, report, fonts, workbook, sheet,
+ XLSX_FONT_SIZE_DEFAULT, False, 'white', 'right', 1, 0, XLSX_BORDER_COLOR)
+
+ cursor_x = start_x
+ cursor_y += 1
+
+ cursor_x = start_x
+ cursor_y += 2
+
+ # Tax grids
+ columns = []
+ grids = tax_summary.get('tax_grid_summary_lines')
+ if grids:
+ start_align_right = start_x + 1
+ if len(grids) > 1:
+ start_align_right += 1
+ columns.append(_('Country'))
+
+ columns += [_('Grid'), _('+'), _('-'), _('Impact On Grid')]
+
+ # Draw Tax Applied Table
+ # Write tax applied columns and header
+ self._write_cell(cursor_x, cursor_y, _('Impact On Grid'), len(columns), False, report, fonts, workbook, sheet,
+ XLSX_FONT_SIZE_HEADING, True, 'white', 'left', 2)
+
+ cursor_y += 1
+ for column in columns:
+ align = 'left'
+ if cursor_x >= start_align_right:
+ align = 'right'
+ self._write_cell(cursor_x, cursor_y, column, 1, False, report, fonts, workbook, sheet, XLSX_FONT_SIZE_DEFAULT, True,
+ XLSX_GRAY_200, align, 2)
+ cursor_x += 1
+
+ cursor_x = start_x
+ cursor_y += 1
+
+ for country in grids:
+ is_country_first_line = True
+ for grid_name in grids[country]:
+ if len(grids) > 1:
+ if is_country_first_line:
+ is_country_first_line = not is_country_first_line
+ self._write_cell(cursor_x, cursor_y, country, 1, False, report, fonts, workbook, sheet, XLSX_FONT_SIZE_DEFAULT,
+ True, 'white', 'left', 1, 0, XLSX_BORDER_COLOR)
+
+ cursor_x += 1
+
+ self._write_cell(cursor_x, cursor_y, grid_name, 1, False, report, fonts, workbook, sheet, XLSX_FONT_SIZE_DEFAULT, True,
+ 'white', 'left', 1, 0, XLSX_BORDER_COLOR)
+ self._write_cell(cursor_x + 1, cursor_y, grids[country][grid_name].get('+', 0), 1, False, report, fonts, workbook,
+ sheet, XLSX_FONT_SIZE_DEFAULT, False, 'white', 'right', 1, 0, XLSX_BORDER_COLOR)
+ self._write_cell(cursor_x + 2, cursor_y, grids[country][grid_name].get('-', 0), 1, False, report, fonts, workbook,
+ sheet, XLSX_FONT_SIZE_DEFAULT, False, 'white', 'right', 1, 0, XLSX_BORDER_COLOR)
+ self._write_cell(cursor_x + 3, cursor_y, grids[country][grid_name]['impact'], 1, False, report, fonts, workbook,
+ sheet, XLSX_FONT_SIZE_DEFAULT, False, 'white', 'right', 1, 0, XLSX_BORDER_COLOR)
+
+ cursor_x = start_x
+ cursor_y += 1
+
+ ##########################################################################
+ # Document Data Generation
+ ##########################################################################
+
+ def _generate_document_data_for_export(self, report, options, export_type='pdf'):
+ """
+ Used to generate all the data needed for the rendering of the export
+
+ :param export_type: The export type the generation need to use can be ('pdf' or 'xslx')
+
+ :return: a dictionnary containing a list of all lines grouped by journals and a dictionnay with the global tax summary lines
+ - journals_vals (mandatory): List of dictionary containing all the lines, columns, and tax summaries
+ - lines (mandatory): A list of dict containing all tha data for each lines in format returned by _get_lines_for_journal
+ - columns (mandatory): A list of columns for this journal returned in the format returned by _get_columns_for_journal
+ - tax_summary (optional): A dict of data for the tax summaries inside journals in the format returned by _get_tax_summary_section
+ - global_tax_summary: A dict with the global tax summaries data in the format returned by _get_tax_summary_section
+ """
+ # Ensure that all the data is synchronized with the database before we read it
+ self.env.flush_all()
+ query = report._get_report_query(options, 'strict_range')
+ account_alias = query.left_join(lhs_alias='account_move_line', lhs_column='account_id', rhs_table='account_account', rhs_column='id', link='account_id')
+ account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query)
+ account_name = self.env['account.account']._field_to_sql(account_alias, 'name')
+
+ query = SQL(
+ """
+ SELECT
+ account_move_line.id AS move_line_id,
+ account_move_line.name,
+ account_move_line.date,
+ account_move_line.invoice_date,
+ account_move_line.amount_currency,
+ account_move_line.tax_base_amount,
+ account_move_line.currency_id AS move_line_currency,
+ account_move_line.display_type AS display_type,
+ am.id AS move_id,
+ am.name AS move_name,
+ am.journal_id,
+ am.currency_id AS move_currency,
+ am.amount_total_in_currency_signed AS amount_currency_total,
+ am.currency_id != cp.currency_id AS is_multicurrency,
+ p.name AS partner_name,
+ %(account_code)s AS account_code,
+ %(account_name)s AS account_name,
+ %(account_alias)s.account_type AS account_type,
+ COALESCE(account_move_line.debit, 0) AS debit,
+ COALESCE(account_move_line.credit, 0) AS credit,
+ COALESCE(account_move_line.balance, 0) AS balance,
+ %(j_name)s AS journal_name,
+ j.code AS journal_code,
+ j.type AS journal_type,
+ cp.currency_id AS company_currency,
+ CASE WHEN j.type = 'sale' THEN am.payment_reference WHEN j.type = 'purchase' THEN am.ref END AS reference,
+ array_remove(array_agg(DISTINCT %(tax_name)s), NULL) AS taxes,
+ array_remove(array_agg(DISTINCT %(tag_name)s), NULL) AS tax_grids
+ FROM %(table)s
+ JOIN account_move am ON am.id = account_move_line.move_id
+ LEFT JOIN res_partner p ON p.id = account_move_line.partner_id
+ JOIN account_journal j ON j.id = am.journal_id
+ JOIN res_company cp ON cp.id = am.company_id
+ LEFT JOIN account_move_line_account_tax_rel aml_at_rel ON aml_at_rel.account_move_line_id = account_move_line.id
+ LEFT JOIN account_tax parent_tax ON parent_tax.id = aml_at_rel.account_tax_id and parent_tax.amount_type = 'group'
+ LEFT JOIN account_tax_filiation_rel tax_filiation_rel ON tax_filiation_rel.parent_tax = parent_tax.id
+ LEFT JOIN account_tax tax ON (tax.id = aml_at_rel.account_tax_id and tax.amount_type != 'group') or tax.id = tax_filiation_rel.child_tax
+ LEFT JOIN account_account_tag_account_move_line_rel tag_rel ON tag_rel.account_move_line_id = account_move_line.id
+ LEFT JOIN account_account_tag tag ON tag_rel.account_account_tag_id = tag.id
+ LEFT JOIN res_currency journal_curr ON journal_curr.id = j.currency_id
+ WHERE %(case_statement)s AND %(search_conditions)s
+ GROUP BY "account_move_line".id, am.id, p.id, %(account_alias)s.id, j.id, cp.id, journal_curr.id, account_code, account_name
+ ORDER BY
+ CASE j.type
+ WHEN 'sale' THEN 1
+ WHEN 'purchase' THEN 2
+ WHEN 'general' THEN 3
+ WHEN 'bank' THEN 4
+ ELSE 5
+ END,
+ j.sequence,
+ CASE WHEN am.name = '/' THEN 1 ELSE 0 END, am.date, am.name, am.id,
+ CASE %(account_alias)s.account_type
+ WHEN 'liability_payable' THEN 1
+ WHEN 'asset_receivable' THEN 1
+ WHEN 'liability_credit_card' THEN 5
+ WHEN 'asset_cash' THEN 5
+ ELSE 2
+ END,
+ account_move_line.tax_line_id NULLS FIRST
+ """,
+ table=query.from_clause,
+ case_statement=self._get_payment_lines_filter_case_statement(options),
+ search_conditions=query.where_clause,
+ account_code=account_code,
+ account_name=account_name,
+ account_alias=SQL.identifier(account_alias),
+ j_name=self.env['account.journal']._field_to_sql('j', 'name'),
+ tax_name=self.env['account.tax']._field_to_sql('tax', 'name'),
+ tag_name=self.env['account.account.tag']._field_to_sql('tag', 'name')
+ )
+
+ self._cr.execute(query)
+ result = {}
+
+ # Grouping by journal_id then move_id
+ for entry in self._cr.dictfetchall():
+ result.setdefault(entry['journal_id'], {})
+ result[entry['journal_id']].setdefault(entry['move_id'], [])
+ result[entry['journal_id']][entry['move_id']].append(entry)
+
+ journals_vals = []
+ any_journal_group_has_taxes = False
+
+ for journal_entry_dict in result.values():
+ account_move_vals_list = list(journal_entry_dict.values())
+ journal_vals = {
+ 'id': account_move_vals_list[0][0]['journal_id'],
+ 'name': account_move_vals_list[0][0]['journal_name'],
+ 'code': account_move_vals_list[0][0]['journal_code'],
+ 'type': account_move_vals_list[0][0]['journal_type']
+ }
+
+ if self._section_has_tax(options, journal_vals['id']):
+ journal_vals['tax_summary'] = self._get_tax_summary_section(options, journal_vals)
+ any_journal_group_has_taxes = True
+
+ journal_vals['lines'] = self._get_export_lines_for_journal(report, options, export_type, journal_vals, account_move_vals_list)
+ journal_vals['columns'] = self._get_columns_for_journal(journal_vals, export_type)
+ journals_vals.append(journal_vals)
+
+ return {
+ 'journals_vals': journals_vals,
+ 'global_tax_summary': self._get_tax_summary_section(options) if any_journal_group_has_taxes else False
+ }
+
+ def _get_columns_for_journal(self, journal, export_type='pdf'):
+ """
+ Creates a columns list that will be used in this journal for the pdf report
+
+ :return: A list of the columns as dict each having:
+ - name (mandatory): A string that will be displayed
+ - label (mandatory): A string used to link lines with the column
+ - class (optional): A string with css classes that need to be applied to all that column
+ """
+ columns = [
+ {'name': _('Document'), 'label': 'document'},
+ ]
+
+ # We have different columns regarding we are exporting to a PDF file or an XLSX document
+ if export_type == 'pdf':
+ columns.append({'name': _('Account'), 'label': 'account_label'})
+ else:
+ columns.extend([
+ {'name': _('Account Code'), 'label': 'account_code'},
+ {'name': _('Account Label'), 'label': 'account_label'}
+ ])
+
+ columns.extend([
+ {'name': _('Name'), 'label': 'name'},
+ {'name': _('Debit'), 'label': 'debit', 'class': 'o_right_alignment '},
+ {'name': _('Credit'), 'label': 'credit', 'class': 'o_right_alignment '},
+ ])
+
+ if journal.get('tax_summary'):
+ columns.append(
+ {'name': _('Taxes'), 'label': 'taxes'},
+ )
+ if journal['tax_summary'].get('tax_grid_summary_lines'):
+ columns.append({'name': _('Tax Grids'), 'label': 'tax_grids'})
+
+ if self._should_use_bank_journal_export(journal):
+ columns.append({
+ 'name': _('Balance'),
+ 'label': 'balance',
+ 'class': 'o_right_alignment '
+ })
+
+ if journal.get('multicurrency_column'):
+ columns.append({
+ 'name': _('Amount Currency'),
+ 'label': 'amount_currency',
+ 'class': 'o_right_alignment '
+ })
+
+ return columns
+
+ def _should_use_bank_journal_export(self, journal_vals):
+ """Returns True if the journal requires bank-specific export logic."""
+ return journal_vals.get('type') == 'bank'
+
+ def _get_export_lines_for_journal(self, report, options, export_type, journal_vals, account_move_vals_list):
+ """
+ Default document lines generation it will generate a list of lines in a format valid for the pdf and xlsx
+
+ If it is a bank journal it will be redirected to _get_lines_for_bank_journal since this type of journals
+ require more complexity
+ We want to be as lightweight as possible and not at unnecessary calculations
+
+ :return: A list of lines. Each line is a dict having:
+ - 'column_label': A dict containing the values for a cell with a key that links to the label of a column
+ - data (mandatory): The formatted cell value
+ - class (optional): Additional css classes to apply to the current cell
+ - line_class (optional): Additional css classes that applies to the entire line
+ """
+ lines = []
+
+ if self._should_use_bank_journal_export(journal_vals):
+ return self._get_export_lines_for_bank_journal(report, options, export_type, journal_vals, account_move_vals_list)
+
+ total_credit = 0
+ total_debit = 0
+
+ for i, account_move_line_vals_list in enumerate(account_move_vals_list):
+ for j, move_line_entry_vals in enumerate(account_move_line_vals_list):
+ document = False
+ if j == 0:
+ document = move_line_entry_vals['move_name']
+ elif j == 1:
+ document = move_line_entry_vals['date']
+
+ line = self._get_base_line(report, options, export_type, document, move_line_entry_vals, j, i % 2 != 0, journal_vals.get('tax_summary'))
+
+ total_credit += move_line_entry_vals['credit']
+ total_debit += move_line_entry_vals['debit']
+
+ lines.append(line)
+
+ # Add other currency amout if this move is using multiple currencies
+ move_vals_entry = account_move_line_vals_list[0]
+ if move_vals_entry['is_multicurrency']:
+ amount_currency_name = _(
+ 'Amount in currency: %s',
+ report._format_value(
+ options,
+ move_vals_entry['amount_currency_total'],
+ 'monetary',
+ format_params={'currency_id': move_vals_entry['move_currency']},
+ ),
+ )
+ if len(account_move_line_vals_list) <= 2:
+ lines.append({
+ 'document': {'data': amount_currency_name},
+ 'line_class': 'o_even ' if i % 2 == 0 else 'o_odd ',
+ 'amount': {'data': move_vals_entry['amount_currency_total']},
+ 'currency_id': {'data': move_vals_entry['move_currency']}
+ })
+ else:
+ lines[-1]['document'] = {'data': amount_currency_name}
+ lines[-1]['amount'] = {'data': move_vals_entry['amount_currency_total']}
+ lines[-1]['currency_id'] = {'data': move_vals_entry['move_currency']}
+
+ # Add an empty line to add a separation between the total section and the data section
+ lines.append({})
+
+ total_line = {
+ 'name': {'data': _('Total')},
+ 'debit': {'data': report._format_value(options, total_debit, 'monetary')},
+ 'credit': {'data': report._format_value(options, total_credit, 'monetary')},
+ }
+
+ lines.append(total_line)
+
+ return lines
+
+ def _get_export_lines_for_bank_journal(self, report, options, export_type, journal_vals, account_moves_vals_list):
+ """
+ Bank journals are more complex and should be calculated separately from other journal types
+
+ :return: A list of lines. Each line is a dict having:
+ - 'column_label': A dict containing the values for a cell with a key that links to the label of a column
+ - data (mandatory): The formatted cell value
+ - class (optional): Additional css classes to apply to the current cell
+ - line_class (optional): Additional css classes that applies to the entire line
+ """
+ lines = []
+
+ # Initial balance
+ current_balance = self._query_bank_journal_initial_balance(options, journal_vals['id'])
+ lines.append({
+ 'name': {'data': _('Starting Balance')},
+ 'balance': {'data': report._format_value(options, current_balance, 'monetary')},
+ })
+
+ # Debit and credit accumulators
+ total_credit = 0
+ total_debit = 0
+
+ for i, account_move_line_vals_list in enumerate(account_moves_vals_list):
+ is_unreconciled_payment = not any(
+ line for line in account_move_line_vals_list if line['account_type'] in ('liability_credit_card', 'asset_cash')
+ )
+
+ for j, move_line_entry_vals in enumerate(account_move_line_vals_list):
+ # Do not display bank account lines for bank journals
+ if move_line_entry_vals['account_type'] not in ('liability_credit_card', 'asset_cash'):
+ document = ''
+ if j == 0:
+ document = f'{move_line_entry_vals["move_name"]} ({move_line_entry_vals["date"]})'
+ line = self._get_base_line(report, options, export_type, document, move_line_entry_vals, j, i % 2 != 0, journal_vals.get('tax_summary'))
+
+ total_credit += move_line_entry_vals['credit']
+ total_debit += move_line_entry_vals['debit']
+
+ if not is_unreconciled_payment:
+ # We need to invert the balance since it is a bank journal
+ line_balance = -move_line_entry_vals['balance']
+ current_balance += line_balance
+ line.update({
+ 'balance': {
+ 'data': report._format_value(options, current_balance, 'monetary'),
+ 'class': 'o_muted ' if self.env.company.currency_id.is_zero(line_balance) else ''
+ },
+ })
+
+ if self.env.user.has_group('base.group_multi_currency') and move_line_entry_vals['move_line_currency'] != move_line_entry_vals['company_currency']:
+ journal_vals['multicurrency_column'] = True
+ amount_currency = -move_line_entry_vals['amount_currency'] if not is_unreconciled_payment else move_line_entry_vals['amount_currency']
+ move_line_currency = self.env['res.currency'].browse(move_line_entry_vals['move_line_currency'])
+ line.update({
+ 'amount_currency': {
+ 'data': report._format_value(
+ options,
+ amount_currency,
+ 'monetary',
+ format_params={'currency_id': move_line_currency.id},
+ ),
+ 'class': 'o_muted ' if move_line_currency.is_zero(amount_currency) else '',
+ }
+ })
+ lines.append(line)
+
+ # Add an empty line to add a separation between the total section and the data section
+ lines.append({})
+
+ total_line = {
+ 'name': {'data': _('Total')},
+ 'balance': {'data': report._format_value(options, current_balance, 'monetary')},
+ }
+ lines.append(total_line)
+
+ return lines
+
+ def _get_base_line(self, report, options, export_type, document, line_entry, line_index, even, has_taxes):
+ """
+ Returns the generic part of a line that is used by both '_get_lines_for_journal' and '_get_lines_for_bank_journal'
+
+ :return: A dict with base values for the line
+ - line_class (mandatory): Css classes that applies to this whole line
+ - document (mandatory): A dict containing the cell data for the column document
+ - data (mandatory): The value of the cell formatted
+ - class (mandatory): css class for this cell
+ - account (mandatory): A dict containing the cell data for the column account
+ - data (mandatory): The value of the cell formatted
+ - account_code (mandatory): A dict containing the cell data for the column account_code
+ - data (mandatory): The value of the cell formatted
+ - account_label (mandatory): A dict containing the cell data for the column account_label
+ - data (mandatory): The value of the cell formatted
+ - name (mandatory): A dict containing the cell data for the column name
+ - data (mandatory): The value of the cell formatted
+ - debit (mandatory): A dict containing the cell data for the column debit
+ - data (mandatory): The value of the cell formatted
+ - class (mandatory): css class for this cell
+ - credit (mandatory): A dict containing the cell data for the column credit
+ - data (mandatory): The value of the cell formatted
+ - class (mandatory): css class for this cell
+
+ - taxes(optional): A dict containing the cell data for the column taxes
+ - data (mandatory): The value of the cell formatted
+ - tax_grids(optional): A dict containing the cell data for the column taxes
+ - data (mandatory): The value of the cell formatted
+ """
+ company_currency = self.env.company.currency_id
+
+ name = line_entry['name'] or line_entry['reference']
+ account_label = line_entry['partner_name'] or line_entry['account_name']
+
+ if line_entry['account_type'] not in ('asset_receivable', 'liability_payable'):
+ account_label = line_entry['account_name']
+ elif line_entry['partner_name'] and line_entry['account_type'] in ('asset_receivable', 'liability_payable'):
+ name = f"{line_entry['partner_name']} {name or ''}"
+
+ line = {
+ 'line_class': 'o_even ' if even else 'o_odd ',
+ 'document': {'data': document, 'class': 'o_bold ' if line_index == 0 else ''},
+ 'account_code': {'data': line_entry['account_code']},
+ 'account_label': {
+ 'data': (
+ account_label
+ if export_type != 'pdf'
+ else (
+ f"{line_entry['account_code']} "
+ + (
+ line_entry['account_name'][:35] + '...'
+ if len(line_entry['account_name']) > 35
+ else line_entry['account_name']
+ )
+ )
+ )
+ },
+ 'name': {'data': name},
+ 'debit': {
+ 'data': report._format_value(options, line_entry['debit'], 'monetary'),
+ 'class': 'o_muted ' if company_currency.is_zero(line_entry['debit']) else ''
+ },
+ 'credit': {
+ 'data': report._format_value(options, line_entry['credit'], 'monetary'),
+ 'class': 'o_muted ' if company_currency.is_zero(line_entry['credit']) else ''
+ },
+ }
+
+ if has_taxes:
+ tax_val = ''
+ if line_entry['taxes']:
+ tax_val = _('T: %s', ', '.join(line_entry['taxes']))
+ elif line_entry['tax_base_amount'] is not None:
+ tax_val = _('B: %s', report._format_value(options, line_entry['tax_base_amount'], 'monetary'))
+
+ line.update({
+ 'taxes': {'data': tax_val},
+ 'tax_grids': {'data': ', '.join(line_entry['tax_grids'])},
+ })
+
+ return line
+
+ ##########################################################################
+ # Queries
+ ##########################################################################
+
+ def _get_payment_lines_filter_case_statement(self, options):
+ if not options.get('show_payment_lines'):
+ return SQL(
+ """
+ (j.type != 'bank' OR EXISTS(
+ SELECT
+ 1
+ FROM account_move_line
+ JOIN account_account acc ON acc.id = account_move_line.account_id
+ WHERE account_move_line.move_id = am.id
+ AND acc.account_type IN ('liability_credit_card', 'asset_cash')
+ ))
+ """
+ )
+ else:
+ return SQL('TRUE')
+
+ def _query_bank_journal_initial_balance(self, options, journal_id):
+ report = self.env.ref('odex30_account_reports.journal_report')
+ query = report._get_report_query(options, 'to_beginning_of_period', domain=[('journal_id', '=', journal_id)])
+ query = SQL(
+ """
+ SELECT
+ COALESCE(SUM(account_move_line.balance), 0) AS balance
+ FROM %(table)s
+ JOIN account_journal journal ON journal.id = "account_move_line".journal_id AND account_move_line.account_id = journal.default_account_id
+ WHERE %(search_conditions)s
+ GROUP BY journal.id
+ """,
+ table=query.from_clause,
+ search_conditions=query.where_clause,
+ )
+ self._cr.execute(query)
+ result = self._cr.dictfetchall()
+ init_balance = result[0]['balance'] if len(result) >= 1 else 0
+ return init_balance
+
+ ##########################################################################
+ # Tax Grids
+ ##########################################################################
+
+ def _section_has_tax(self, options, journal_id):
+ report = self.env['account.report'].browse(options.get('report_id'))
+ aml_has_tax_domain = [('tax_ids', '!=', False)]
+ if journal_id:
+ aml_has_tax_domain.append(('journal_id', '=', journal_id))
+ aml_has_tax_domain += report._get_options_domain(options, 'strict_range')
+ return bool(self.env['account.move.line'].search_count(aml_has_tax_domain, limit=1))
+
+ def _get_tax_summary_section(self, options, journal_vals=None):
+ """
+ Get the journal tax summary if it is passed as parameter.
+ In case no journal is passed, it will return the global tax summary data
+ """
+ tax_data = {
+ 'date_from': options.get('date', {}).get('date_from'),
+ 'date_to': options.get('date', {}).get('date_to'),
+ }
+
+ if journal_vals:
+ tax_data['journal_id'] = journal_vals['id']
+ tax_data['journal_type'] = journal_vals['type']
+
+ tax_report_lines = self._get_generic_tax_summary_for_sections(options, tax_data)
+ tax_non_deductible_column = any(line.get('tax_non_deductible_no_format') for country_vals_list in tax_report_lines.values() for line in country_vals_list)
+ tax_deductible_column = any(line.get('tax_deductible_no_format') for country_vals_list in tax_report_lines.values() for line in country_vals_list)
+ tax_due_column = any(line.get('tax_due_no_format') for country_vals_list in tax_report_lines.values() for line in country_vals_list)
+ extra_columns = int(tax_non_deductible_column) + int(tax_deductible_column) + int(tax_due_column)
+
+ tax_grid_summary_lines = self._get_tax_grids_summary(options, tax_data)
+
+ return {
+ 'tax_report_lines': tax_report_lines,
+ 'tax_non_deductible_column': tax_non_deductible_column,
+ 'tax_deductible_column': tax_deductible_column,
+ 'tax_due_column': tax_due_column,
+ 'extra_columns': extra_columns,
+ 'tax_grid_summary_lines': tax_grid_summary_lines,
+ }
+
+ def _get_generic_tax_report_options(self, options, data):
+ """
+ Return an option dictionnary set to fetch the reports with the parameters needed for this journal.
+ The important bits are the journals, date, and fetch the generic tax reports that contains all taxes.
+ We also provide the information about wether to take all entries or only posted ones.
+ """
+ generic_tax_report = self.env.ref('account.generic_tax_report')
+ previous_option = options.copy()
+ # Force the dates to the selected ones. Allows to get it correctly when grouped by months
+ previous_option.update({
+ 'selected_variant_id': generic_tax_report.id,
+ 'date_from': data.get('date_from'),
+ 'date_to': data.get('date_to'),
+ })
+ tax_report_options = generic_tax_report.get_options(previous_option)
+ journal_report = self.env['account.report'].browse(options['report_id'])
+ tax_report_options['forced_domain'] = tax_report_options.get('forced_domain', []) + journal_report._get_options_domain(options, 'strict_range')
+
+ # Even though it doesn't have a journal selector, we can force a journal in the options to only get the lines for a specific journal.
+ if data.get('journal_id') or data.get('journal_type'):
+ tax_report_options['journals'] = [{
+ 'id': data.get('journal_id'),
+ 'model': 'account.journal',
+ 'type': data.get('journal_type'),
+ 'selected': True,
+ }]
+
+ return tax_report_options
+
+ def _get_tax_grids_summary(self, options, data):
+ """
+ Fetches the details of all grids that have been used in the provided journal.
+ The result is grouped by the country in which the tag exists in case of multivat environment.
+ Returns a dictionary with the following structure:
+ {
+ Country : {
+ tag_name: {+, -, impact},
+ tag_name: {+, -, impact},
+ tag_name: {+, -, impact},
+ ...
+ },
+ Country : [
+ tag_name: {+, -, impact},
+ tag_name: {+, -, impact},
+ tag_name: {+, -, impact},
+ ...
+ ],
+ ...
+ }
+ """
+ report = self.env.ref('odex30_account_reports.journal_report')
+ # Use the same option as we use to get the tax details, but this time to generate the query used to fetch the
+ # grid information
+ tax_report_options = self._get_generic_tax_report_options(options, data)
+ query = report._get_report_query(tax_report_options, 'strict_range')
+ country_name = self.env['res.country']._field_to_sql('country', 'name')
+ tag_name = self.env['account.account.tag']._field_to_sql('tag', 'name')
+ query = SQL("""
+ WITH tag_info (country_name, tag_id, tag_name, tag_sign, balance) AS (
+ SELECT
+ %(country_name)s AS country_name,
+ tag.id,
+ %(tag_name)s AS name,
+ CASE WHEN tag.tax_negate IS TRUE THEN '-' ELSE '+' END,
+ SUM(COALESCE("account_move_line".balance, 0)
+ * CASE WHEN "account_move_line".tax_tag_invert THEN -1 ELSE 1 END
+ ) AS balance
+ FROM account_account_tag tag
+ JOIN account_account_tag_account_move_line_rel rel ON tag.id = rel.account_account_tag_id
+ JOIN res_country country ON country.id = tag.country_id
+ , %(table_references)s
+ WHERE %(search_condition)s
+ AND applicability = 'taxes'
+ AND "account_move_line".id = rel.account_move_line_id
+ GROUP BY country_name, tag.id
+ )
+ SELECT
+ country_name,
+ tag_id,
+ REGEXP_REPLACE(tag_name, '^[+-]', '') AS name, -- Remove the sign from the grid name
+ balance,
+ tag_sign AS sign
+ FROM tag_info
+ ORDER BY country_name, name
+ """, country_name=country_name, tag_name=tag_name, table_references=query.from_clause, search_condition=query.where_clause)
+ self._cr.execute(query)
+ query_res = self.env.cr.fetchall()
+
+ res = {}
+ opposite = {'+': '-', '-': '+'}
+ for country_name, tag_id, name, balance, sign in query_res:
+ res.setdefault(country_name, {}).setdefault(name, {})
+ res[country_name][name].setdefault('tag_ids', []).append(tag_id)
+ res[country_name][name][sign] = report._format_value(options, balance, 'monetary')
+
+ # We need them formatted, to ensure they are displayed correctly in the report. (E.g. 0.0, not 0)
+ if not opposite[sign] in res[country_name][name]:
+ res[country_name][name][opposite[sign]] = report._format_value(options, 0, 'monetary')
+
+ res[country_name][name][sign + '_no_format'] = balance
+ res[country_name][name]['impact'] = report._format_value(options, res[country_name][name].get('+_no_format', 0) - res[country_name][name].get('-_no_format', 0), 'monetary')
+
+ return res
+
+ def _get_generic_tax_summary_for_sections(self, options, data):
+ """
+ Overridden to make use of the generic tax report computation
+ Works by forcing specific options into the tax report to only get the lines we need.
+ The result is grouped by the country in which the tag exists in case of multivat environment.
+ Returns a dictionary with the following structure:
+ {
+ Country : [
+ {name, base_amount{_no_format}, tax_amount{_no_format}, tax_non_deductible{_no_format}, tax_deductible{_no_format}, tax_due{_no_format}},
+ {name, base_amount{_no_format}, tax_amount{_no_format}, tax_non_deductible{_no_format}, tax_deductible{_no_format}, tax_due{_no_format}},
+ {name, base_amount{_no_format}, tax_amount{_no_format}, tax_non_deductible{_no_format}, tax_deductible{_no_format}, tax_due{_no_format}},
+ ...
+ ],
+ Country : [
+ {name, base_amount{_no_format}, tax_amount{_no_format}, tax_non_deductible{_no_format}, tax_deductible{_no_format}, tax_due{_no_format}},
+ {name, base_amount{_no_format}, tax_amount{_no_format}, tax_non_deductible{_no_format}, tax_deductible{_no_format}, tax_due{_no_format}},
+ {name, base_amount{_no_format}, tax_amount{_no_format}, tax_non_deductible{_no_format}, tax_deductible{_no_format}, tax_due{_no_format}},
+ ...
+ ],
+ ...
+ }
+ """
+ report = self.env['account.report'].browse(options['report_id'])
+ tax_report_options = self._get_generic_tax_report_options(options, data)
+ tax_report_options['account_journal_report_tax_deductibility_columns'] = True
+ tax_report = self.env.ref('account.generic_tax_report')
+ tax_report_lines = tax_report._get_lines(tax_report_options)
+
+ tax_values = {}
+ for tax_report_line in tax_report_lines:
+ model, line_id = report._parse_line_id(tax_report_line.get('id'))[-1][1:]
+ if model == 'account.tax':
+ tax_values[line_id] = {
+ 'base_amount': tax_report_line['columns'][0]['no_format'],
+ 'tax_amount': tax_report_line['columns'][1]['no_format'],
+ 'tax_non_deductible': tax_report_line['columns'][2]['no_format'],
+ 'tax_deductible': tax_report_line['columns'][3]['no_format'],
+ 'tax_due': tax_report_line['columns'][4]['no_format'],
+ }
+
+ # Make the final data dict that will be used by the template, using the taxes information.
+ taxes = self.env['account.tax'].browse(tax_values.keys())
+ res = {}
+ for tax in taxes:
+ res.setdefault(tax.country_id.name, []).append({
+ 'base_amount': report._format_value(options, tax_values[tax.id]['base_amount'], 'monetary'),
+ 'base_amount_no_format': tax_values[tax.id]['base_amount'],
+ 'tax_amount': report._format_value(options, tax_values[tax.id]['tax_amount'], 'monetary'),
+ 'tax_amount_no_format': tax_values[tax.id]['tax_amount'],
+ 'tax_non_deductible': report._format_value(options, tax_values[tax.id]['tax_non_deductible'], 'monetary'),
+ 'tax_non_deductible_no_format': tax_values[tax.id]['tax_non_deductible'],
+ 'tax_deductible': report._format_value(options, tax_values[tax.id]['tax_deductible'], 'monetary'),
+ 'tax_deductible_no_format': tax_values[tax.id]['tax_deductible'],
+ 'tax_due': report._format_value(options, tax_values[tax.id]['tax_due'], 'monetary'),
+ 'tax_due_no_format': tax_values[tax.id]['tax_due'],
+ 'name': tax.name,
+ 'line_id': report._get_generic_line_id('account.tax', tax.id)
+ })
+
+ # Return the result, ordered by country name
+ return dict(sorted(res.items()))
+
+ ##########################################################################
+ # Actions
+ ##########################################################################
+
+ def journal_report_tax_tag_template_open_aml(self, options, params=None):
+ """ returns an action to open a list view of the account.move.line having the selected tax tag """
+ tag_ids = params.get('tag_ids')
+ domain = (
+ self.env['account.report'].browse(options['report_id'])._get_options_domain(options, 'strict_range')
+ + [('tax_tag_ids', 'in', tag_ids)]
+ + self.env['account.move.line']._get_tax_exigible_domain()
+ )
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Journal Items for Tax Audit'),
+ 'res_model': 'account.move.line',
+ 'views': [[self.env.ref('account.view_move_line_tax_audit_tree').id, 'list']],
+ 'domain': domain,
+ 'context': self.env.context,
+ }
+
+ def journal_report_action_dropdown_audit_default_tax_report(self, options, params):
+ return self.env['account.generic.tax.report.handler'].caret_option_audit_tax(options, params)
+
+ def journal_report_action_open_tax_journal_items(self, options, params):
+ """
+ Open the journal items related to the tax on this line.
+ Take into account the given/options date and group by taxes then account.
+ :param options: the report options.
+ :param params: a dict containing the line params. (Dates, name, journal_id, tax_type)
+ :return: act_window on journal items grouped by tax or tags and accounts.
+ """
+ ctx = {
+ 'search_default_posted': 0 if options.get('all_entries') else 1,
+ 'search_default_date_between': 1,
+ 'date_from': params and params.get('date_from') or options.get('date', {}).get('date_from'),
+ 'date_to': params and params.get('date_to') or options.get('date', {}).get('date_to'),
+ 'search_default_journal_id': params.get('journal_id'),
+ 'expand': 1,
+ }
+ if params and params.get('tax_type') == 'tag':
+ ctx.update({
+ 'search_default_group_by_tax_tags': 1,
+ 'search_default_group_by_account': 2,
+ })
+ elif params and params.get('tax_type') == 'tax':
+ ctx.update({
+ 'search_default_group_by_taxes': 1,
+ 'search_default_group_by_account': 2,
+ })
+
+ if params and 'journal_id' in params:
+ ctx.update({
+ 'search_default_journal_id': [params['journal_id']],
+ })
+
+ if options and options.get('journals') and not ctx.get('search_default_journal_id'):
+ selected_journals = [journal['id'] for journal in options['journals'] if journal.get('selected') and journal['model'] == 'account.journal']
+ if len(selected_journals) == 1:
+ ctx['search_default_journal_id'] = selected_journals
+
+ return {
+ 'name': params.get('name'),
+ 'view_mode': 'list,pivot,graph,kanban',
+ 'res_model': 'account.move.line',
+ 'views': [(self.env.ref('account.view_move_line_tree').id, 'list')],
+ 'type': 'ir.actions.act_window',
+ 'domain': [('display_type', 'not in', ('line_section', 'line_note'))],
+ 'context': ctx,
+ }
+
+ def journal_report_action_open_account_move_lines_by_account(self, options, params):
+ """
+ Open a list view of the journal account move lines
+ corresponding to the date filter and the current account line clicked
+ :param options: The current options of the report
+ :param params: The params given from the report UI (journal_id, account_id, date)
+ :return: act_window on journal items filtered on the current journal and the current account within a date.
+ """
+ report = self.env['account.report'].browse(options['report_id'])
+ journal = self.env['account.journal'].browse(params['journal_id'])
+ account = self.env['account.account'].browse(params['account_id'])
+
+ domain = [
+ ('journal_id.id', '=', journal.id),
+ ('account_id.id', '=', account.id),
+ ]
+ domain += report._get_options_domain(options, 'strict_range')
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _("%(journal)s - %(account)s", journal=journal.name, account=account.name),
+ 'res_model': 'account.move.line',
+ 'views': [[False, 'list']],
+ 'domain': domain
+ }
+
+ def journal_report_open_aml_by_move(self, options, params):
+ report = self.env['account.report'].browse(options['report_id'])
+ journal = self.env['account.journal'].browse(params['journal_id'])
+
+ context_update = {
+ 'search_default_group_by_account': 0,
+ 'show_more_partner_info': 1,
+ }
+
+ if journal.type in ('bank', 'credit'):
+ params['view_ref'] = 'odex30_account_reports.view_journal_report_audit_bank_move_line_tree'
+ # context_update['search_default_exclude_bank_lines'] = 1
+ else:
+ params['view_ref'] = 'odex30_account_reports.view_journal_report_audit_move_line_tree'
+ context_update.update({
+ 'search_default_group_by_partner': 1,
+ 'search_default_group_by_move': 2,
+ })
+ if journal.type in ('sale', 'purchase'):
+ context_update['search_default_invoices_lines'] = 1
+
+ action = report.open_journal_items(options=options, params=params)
+ action.get('context', {}).update(context_update)
+ return action
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_move.py b/dev_odex30_accounting/odex30_account_reports/models/account_move.py
new file mode 100644
index 0000000..6262e70
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_move.py
@@ -0,0 +1,293 @@
+# -*- coding: utf-8 -*-
+import ast
+
+from odoo.addons.account.models.exceptions import TaxClosingNonPostedDependingMovesError
+from odoo import api, models, fields, _
+from odoo.exceptions import UserError
+from odoo.tools.misc import format_date
+from odoo.tools import date_utils
+from odoo.addons.web.controllers.utils import clean_action
+
+from dateutil.relativedelta import relativedelta
+from markupsafe import Markup
+
+
+class AccountMove(models.Model):
+ _inherit = "account.move"
+
+ # used for VAT closing, containing the end date of the period this entry closes
+ tax_closing_report_id = fields.Many2one(comodel_name='account.report')
+ # technical field used to know whether to show the tax closing alert or not
+ tax_closing_alert = fields.Boolean(compute='_compute_tax_closing_alert')
+
+ def _post(self, soft=True):
+ # Overridden to create carryover external values and join the pdf of the report when posting the tax closing
+ for move in self.filtered(lambda m: m.tax_closing_report_id):
+ report = move.tax_closing_report_id
+ options = move._get_tax_closing_report_options(move.company_id, move.fiscal_position_id, report, move.date)
+ move._close_tax_period(report, options)
+
+ return super()._post(soft)
+
+ def action_post(self):
+
+ try:
+ res = super().action_post()
+ except TaxClosingNonPostedDependingMovesError as exception:
+ return {
+ "type": "ir.actions.client",
+ "tag": "odex30_account_reports.redirect_action",
+ "target": "new",
+ "name": "Depending Action",
+ "params": {
+ "depending_action": exception.args[0],
+ "message": _("It seems there is some depending closing move to be posted"),
+ "button_text": _("Depending moves"),
+ },
+ 'context': {
+ 'dialog_size': 'medium',
+ }
+ }
+ return res
+
+ def button_draft(self):
+ super().button_draft()
+ for closing_move in self.filtered(lambda m: m.tax_closing_report_id):
+ report = closing_move.tax_closing_report_id
+ options = closing_move._get_tax_closing_report_options(closing_move.company_id, closing_move.fiscal_position_id, report, closing_move.date)
+ closing_months_delay = closing_move.company_id._get_tax_periodicity_months_delay(report)
+
+ carryover_values = self.env['account.report.external.value'].search([
+ ('carryover_origin_report_line_id', 'in', report.line_ids.ids),
+ ('date', '=', options['date']['date_to']),
+ ])
+
+ carryover_impacted_period_end = fields.Date.from_string(options['date']['date_to']) + relativedelta(months=closing_months_delay)
+ violated_lock_dates = closing_move.company_id._get_lock_date_violations(
+ carryover_impacted_period_end, fiscalyear=False, sale=False, purchase=False, tax=True, hard=True,
+ ) if carryover_values else None
+
+ if violated_lock_dates:
+ raise UserError(_("You cannot reset this closing entry to draft, as it would delete carryover values impacting the tax report of a locked period. "
+ "Please change the following lock dates to proceed: %(lock_date_info)s.",
+ lock_date_info=self.env['res.company']._format_lock_dates(violated_lock_dates)))
+
+ if self._has_subsequent_posted_closing_moves():
+ raise UserError(_("You cannot reset this closing entry to draft, as another closing entry has been posted at a later date."))
+
+ carryover_values.unlink()
+
+ def _has_subsequent_posted_closing_moves(self):
+ self.ensure_one()
+ closing_domains = [
+ ('company_id', '=', self.company_id.id),
+ ('tax_closing_report_id', '!=', False),
+ ('state', '=', 'posted'),
+ ('date', '>', self.date),
+ ('fiscal_position_id', '=', self.fiscal_position_id.id)
+ ]
+ return bool(self.env['account.move'].search_count(closing_domains, limit=1))
+
+ def _get_tax_to_pay_on_closing(self):
+ self.ensure_one()
+ tax_payable_accounts = self.env['account.tax.group'].search([
+ ('company_id', '=', self.company_id.id),
+ ]).tax_payable_account_id
+ payable_lines = self.line_ids.filtered(lambda line: line.account_id in tax_payable_accounts)
+ return self.currency_id.round(-sum(payable_lines.mapped('balance')))
+
+ def _action_tax_to_pay_wizard(self):
+ # hook for l10n tax payment wizard
+ return self.action_open_tax_report()
+
+ def _action_tax_to_send(self):
+ return self.action_open_tax_report()
+
+ def _action_tax_report_error(self):
+ # hook for l10n tax report errors
+ return self.action_open_tax_report()
+
+ def action_open_tax_report(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("odex30_account_reports.action_account_report_gt")
+ if not self.tax_closing_report_id:
+ raise UserError(_("You can't open a tax report from a move without a VAT closing date."))
+ options = self._get_tax_closing_report_options(self.company_id, self.fiscal_position_id, self.tax_closing_report_id, self.date)
+ # Pass options in context and set ignore_session: true to prevent using session options
+ action.update({'params': {'options': options, 'ignore_session': True}})
+ return action
+
+ def _close_tax_period(self, report, options):
+
+ self.ensure_one()
+ if not self.env.user.has_group('account.group_account_manager'):
+ raise UserError(_('Only Billing Administrators are allowed to change lock dates!'))
+ report = self.tax_closing_report_id
+ options = self._get_tax_closing_report_options(self.company_id, self.fiscal_position_id, report, self.date)
+
+ sender_company = report._get_sender_company_for_export(options)
+ company_ids = report.get_report_company_ids(options)
+ if sender_company == self.company_id:
+ depending_closings = self.env['account.tax.report.handler']._get_tax_closing_entries_for_closed_period(report, options, self.env['res.company'].browse(company_ids), posted_only=False) - self
+ depending_closings_to_post = depending_closings.filtered(lambda x: x.state == 'draft')
+ if depending_closings_to_post:
+ depending_action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_journal_line")
+ depending_action = clean_action(depending_action, env=self.env)
+
+ if len(depending_closings_to_post) == 1:
+ depending_action['views'] = [(self.env.ref('account.view_move_form').id, 'form')]
+ depending_action['res_id'] = depending_closings_to_post.id
+ else:
+ depending_action['domain'] = [('id', 'in', depending_closings_to_post.ids)]
+ depending_action['context'] = dict(ast.literal_eval(depending_action['context']))
+ depending_action['context'].pop('search_default_posted', None)
+
+
+ raise TaxClosingNonPostedDependingMovesError(depending_action)
+
+ report.with_context(allowed_company_ids=company_ids)._generate_carryover_external_values(options)
+
+ attachments = self._get_vat_report_attachments(report, options)
+ subject = _(
+ "Vat closing from %(date_from)s to %(date_to)s",
+ date_from=format_date(self.env, options['date']['date_from']),
+ date_to=format_date(self.env, options['date']['date_to']),
+ )
+ self.with_context(no_new_invoice=True).message_post(body=self.ref, subject=subject, attachments=attachments)
+
+ # Log a note on depending closings, redirecting to the main one
+ for closing_move in depending_closings:
+ closing_move.message_post(body=_(
+ "The attachments of the tax report can be found on the %(link_start)sclosing entry%(link_end)s of the representative company.",
+ link_start=Markup('') % self.id,
+ link_end=Markup(""),
+ ))
+
+ # End activity
+ activity = self.company_id._get_tax_closing_reminder_activity(report.id, self.date, self.fiscal_position_id.id)
+ if activity:
+ activity.action_done()
+
+ # Generate next activity
+ self.company_id._generate_tax_closing_reminder_activity(self.tax_closing_report_id, self.date + relativedelta(days=1), self.fiscal_position_id if self.fiscal_position_id.foreign_vat else None)
+
+ if not self.fiscal_position_id and (not self.company_id.tax_lock_date or self.date > self.company_id.tax_lock_date):
+ self.env['account.report']._generate_default_external_values(options['date']['date_from'], options['date']['date_to'], True)
+ self.company_id.sudo().tax_lock_date = self.date
+
+ self._close_tax_period_create_activities()
+
+ def _get_tax_period_description(self):
+ self.ensure_one()
+ period_start, period_end = self.company_id._get_tax_closing_period_boundaries(self.date, self.tax_closing_report_id)
+ return self.company_id._get_tax_closing_move_description(self.company_id._get_tax_periodicity(self.tax_closing_report_id), period_start, period_end, self.fiscal_position_id, self.tax_closing_report_id)
+
+ def _close_tax_period_create_activities(self):
+ mat_to_send_xml_id = 'odex30_account_reports.mail_activity_type_tax_report_to_be_sent'
+ mat_to_send = self.env.ref(mat_to_send_xml_id, raise_if_not_found=False)
+ if not mat_to_send:
+ # As this is introduced in stable, we ensure data exists by creating them on the fly if needed
+ mat_to_send = self.env['mail.activity.type'].sudo()._load_records([{
+ 'xml_id': mat_to_send_xml_id,
+ 'noupdate': False,
+ 'values': {
+ 'name': 'Tax Report Ready',
+ 'summary': 'Tax report is ready to be sent to the administration',
+ 'category': 'tax_report',
+ 'delay_count': '0',
+ 'delay_unit': 'days',
+ 'delay_from': 'current_date',
+ 'res_model': 'account.move',
+ 'chaining_type': 'suggest',
+ }
+ }])
+ mat_to_pay_xml_id = 'odex30_account_reports.mail_activity_type_tax_report_to_pay'
+ mat_to_pay = self.env.ref(mat_to_pay_xml_id, raise_if_not_found=False)
+
+ act_user = mat_to_send.default_user_id
+ if act_user and not (self.company_id in act_user.company_ids and act_user.has_group('account.group_account_manager')):
+ act_user = self.env['res.users']
+
+ moves_without_send_activity = self.filtered_domain([
+ '|',
+ ('activity_ids', '=', False),
+ ('activity_ids', 'not any', [('activity_type_id.id', '=', mat_to_send.id)]),
+ ])
+
+ for move in moves_without_send_activity:
+ period_desc = move._get_tax_period_description()
+ move.with_context(mail_activity_quick_update=True).activity_schedule(
+ act_type_xmlid=mat_to_send_xml_id,
+ summary=_("Send tax report: %s", period_desc),
+ date_deadline=fields.Date.context_today(move),
+ user_id=act_user.id or self.env.user.id,
+ )
+
+ if mat_to_pay and mat_to_pay not in move.activity_ids.activity_type_id and move._get_tax_to_pay_on_closing() > 0:
+ move.with_context(mail_activity_quick_update=True).activity_schedule(
+ act_type_xmlid=mat_to_pay_xml_id,
+ summary=_("Pay tax: %s", period_desc),
+ date_deadline=fields.Date.context_today(move),
+ user_id=act_user.id or self.env.user.id,
+ )
+
+ def refresh_tax_entry(self):
+ for move in self.filtered(lambda m: m.tax_closing_report_id and m.state == 'draft'):
+ report = move.tax_closing_report_id
+ options = move._get_tax_closing_report_options(move.company_id, move.fiscal_position_id, report, move.date)
+ self.env[report.custom_handler_model_name or 'account.generic.tax.report.handler']._generate_tax_closing_entries(report, options, closing_moves=move)
+
+ @api.model
+ def _get_tax_closing_report_options(self, company, fiscal_position, report, date_inside_period):
+ _dummy, date_to = company._get_tax_closing_period_boundaries(date_inside_period, report)
+
+ # In case the company submits its report in different regions, a closing entry
+ # is made for each fiscal position defining a foreign VAT.
+ # We hence need to make sure to select a tax report in the right country when opening
+ # the report (in case there are many, we pick the first one available; it doesn't impact the closing)
+ if fiscal_position and fiscal_position.foreign_vat:
+ fpos_option = fiscal_position.id
+ report_country = fiscal_position.country_id
+ else:
+ fpos_option = 'domestic'
+ report_country = company.account_fiscal_country_id
+
+ options = {
+ 'date': {
+ 'date_to': fields.Date.to_string(date_to),
+ 'filter': 'custom_tax_period',
+ 'mode': 'range',
+ },
+ 'selected_variant_id': report.id,
+ 'sections_source_id': report.id,
+ 'fiscal_position': fpos_option,
+ 'tax_unit': 'company_only',
+ }
+
+ if report.filter_multi_company == 'tax_units':
+ # Enforce multicompany if the closing is done for a tax unit
+ candidate_tax_unit = company.account_tax_unit_ids.filtered(lambda x: x.country_id == report_country)
+ if candidate_tax_unit:
+ options['tax_unit'] = candidate_tax_unit.id
+ company_ids = candidate_tax_unit.company_ids.ids
+ else:
+ same_vat_branches = self.env.company._get_branches_with_same_vat()
+ # Consider the one with the least number of parents (highest in hierarchy) as the active company, coming first
+ company_ids = same_vat_branches.sorted(lambda x: len(x.parent_ids)).ids
+ else:
+ company_ids = self.env.company.ids
+
+ return report.with_context(allowed_company_ids=company_ids).get_options(previous_options=options)
+
+ def _get_vat_report_attachments(self, report, options):
+ # Fetch pdf
+ pdf_data = report.export_to_pdf(options)
+ return [(pdf_data['file_name'], pdf_data['file_content'])]
+
+ def _compute_tax_closing_alert(self):
+ for move in self:
+ move.tax_closing_alert = (
+ move.state == 'posted'
+ and move.tax_closing_report_id
+ and move.company_id.tax_lock_date
+ and move.company_id.tax_lock_date < move.date
+ )
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_move_line.py b/dev_odex30_accounting/odex30_account_reports/models/account_move_line.py
new file mode 100644
index 0000000..b1a4d9f
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_move_line.py
@@ -0,0 +1,75 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, models, fields, _
+
+from odoo.exceptions import UserError
+from odoo.tools import SQL
+
+class AccountMoveLine(models.Model):
+ _inherit = "account.move.line"
+
+ exclude_bank_lines = fields.Boolean(compute='_compute_exclude_bank_lines', store=True)
+
+ @api.depends('journal_id')
+ def _compute_exclude_bank_lines(self):
+ for move_line in self:
+ move_line.exclude_bank_lines = move_line.account_id != move_line.journal_id.default_account_id
+
+ @api.constrains('tax_ids', 'tax_tag_ids')
+ def _check_taxes_on_closing_entries(self):
+ for aml in self:
+ if aml.move_id.tax_closing_report_id and (aml.tax_ids or aml.tax_tag_ids):
+ raise UserError(_("You cannot add taxes on a tax closing move line."))
+
+ @api.depends('product_id', 'product_uom_id', 'move_id.tax_closing_report_id')
+ def _compute_tax_ids(self):
+ """ Some special cases may see accounts used in tax closing having default taxes.
+ They would trigger the constrains above, which we don't want. Instead, we don't trigger
+ the tax computation in this case.
+ """
+ # EXTEND account
+ lines_to_compute = self.filtered(lambda line: not line.move_id.tax_closing_report_id)
+ (self - lines_to_compute).tax_ids = False
+ super(AccountMoveLine, lines_to_compute)._compute_tax_ids()
+
+ @api.model
+ def _prepare_aml_shadowing_for_report(self, change_equivalence_dict):
+ """ Prepares the fields lists for creating a temporary table shadowing the account_move_line one.
+ This is used to switch the computation mode of the reports, with analytics or financial budgets, for example.
+
+ :param change_equivalence_dict: A dict, in the form {aml_field: sql_equivalence}, where:
+ - aml_field: is a string containing the name of field of account.move.line
+ - sql_equivalence: is the value to use to shadow aml_field. It can be an SQL object; if
+ it's not, it'll be escaped in the query.
+
+ :return: A tuple of 2 SQL objects, so that:
+ - The first one is the fields list to pass into the INSERT TO part of the query filling up the temporary table
+ - The second one contains the field values to insert into the SELECT clause of the same query, in the same order
+ as in the first element of the returned tuple.
+ """
+ line_fields = self.env['account.move.line'].fields_get()
+ self.env.cr.execute("SELECT column_name FROM information_schema.columns WHERE table_name='account_move_line'")
+ stored_fields = {f[0] for f in self.env.cr.fetchall() if f[0] in line_fields}
+
+ fields_to_insert = []
+ for fname in stored_fields:
+ if fname in change_equivalence_dict:
+ fields_to_insert.append(SQL(
+ "%(original)s AS %(asname)s",
+ original=change_equivalence_dict[fname],
+ asname=SQL('"account_move_line.%s"', SQL(fname)),
+ ))
+ else:
+ line_field = line_fields[fname]
+ if line_field.get("translate"):
+ typecast = SQL('jsonb')
+ else:
+ typecast = SQL(self.env['account.move.line']._fields[fname].column_type[0])
+
+ fields_to_insert.append(SQL(
+ "CAST(NULL AS %(typecast)s) AS %(fname)s",
+ typecast=typecast,
+ fname=SQL('"account_move_line.%s"', SQL(fname)),
+ ))
+
+ return SQL(', ').join(SQL.identifier(fname) for fname in stored_fields), SQL(', ').join(fields_to_insert)
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_multicurrency_revaluation_report.py b/dev_odex30_accounting/odex30_account_reports/models/account_multicurrency_revaluation_report.py
new file mode 100644
index 0000000..5670b89
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_multicurrency_revaluation_report.py
@@ -0,0 +1,386 @@
+# -*- coding: utf-8 -*-
+
+from odoo import models, fields, api, _
+from odoo.tools import float_is_zero, SQL
+from odoo.exceptions import UserError
+
+from itertools import chain
+
+
+class MulticurrencyRevaluationReportCustomHandler(models.AbstractModel):
+ """Manage Unrealized Gains/Losses.
+
+ In multi-currencies environments, we need a way to control the risk related
+ to currencies (in case some are higthly fluctuating) and, in some countries,
+ some laws also require to create journal entries to record the provisionning
+ of a probable future expense related to currencies. Hence, people need to
+ create a journal entry at the beginning of a period, to make visible the
+ probable expense in reports (and revert it at the end of the period, to
+ recon the real gain/loss.
+ """
+ _name = 'account.multicurrency.revaluation.report.handler'
+ _inherit = 'account.report.custom.handler'
+ _description = 'Multicurrency Revaluation Report Custom Handler'
+
+ def _get_custom_display_config(self):
+ return {
+ 'components': {
+ 'AccountReportFilters': 'odex30_account_reports.MulticurrencyRevaluationReportFilters',
+ },
+ 'templates': {
+ 'AccountReportLineName': 'odex30_account_reports.MulticurrencyRevaluationReportLineName',
+ },
+ }
+
+ def _custom_options_initializer(self, report, options, previous_options):
+ super()._custom_options_initializer(report, options, previous_options=previous_options)
+ active_currencies = self.env['res.currency'].search([('active', '=', True)])
+ if len(active_currencies) < 2:
+ raise UserError(_("You need to activate more than one currency to access this report."))
+ rates = active_currencies._get_rates(self.env.company, options.get('date').get('date_to'))
+ # Normalize the rates to the company's currency
+ company_rate = rates[self.env.company.currency_id.id]
+ for key in rates.keys():
+ rates[key] /= company_rate
+
+ options['currency_rates'] = {
+ str(currency_id.id): {
+ 'currency_id': currency_id.id,
+ 'currency_name': currency_id.name,
+ 'currency_main': self.env.company.currency_id.name,
+ 'rate': (rates[currency_id.id]
+ if not previous_options.get('currency_rates', {}).get(str(currency_id.id), {}).get('rate') else
+ float(previous_options['currency_rates'][str(currency_id.id)]['rate'])),
+ } for currency_id in active_currencies
+ }
+
+ for currency_rates in options['currency_rates'].values():
+ if currency_rates['rate'] == 0:
+ raise UserError(_("The currency rate cannot be equal to zero"))
+
+ options['company_currency'] = options['currency_rates'].pop(str(self.env.company.currency_id.id))
+ options['custom_rate'] = any(
+ not float_is_zero(cr['rate'] - rates[cr['currency_id']], 20)
+ for cr in options['currency_rates'].values()
+ )
+
+ options['multi_currency'] = True
+ options['buttons'].append({'name': _('Adjustment Entry'), 'sequence': 30, 'action': 'action_multi_currency_revaluation_open_revaluation_wizard', 'always_show': True})
+
+ def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings):
+ if len(self.env.companies) > 1:
+ warnings['odex30_account_reports.multi_currency_revaluation_report_warning_multicompany'] = {'alert_type': 'warning'}
+ if options['custom_rate']:
+ warnings['odex30_account_reports.multi_currency_revaluation_report_warning_custom_rate'] = {'alert_type': 'warning'}
+
+ def _custom_line_postprocessor(self, report, options, lines):
+ line_to_adjust_id = self.env.ref('odex30_account_reports.multicurrency_revaluation_to_adjust').id
+ line_excluded_id = self.env.ref('odex30_account_reports.multicurrency_revaluation_excluded').id
+
+ rslt = []
+ for index, line in enumerate(lines):
+ res_model_name, res_id = report._get_model_info_from_id(line['id'])
+
+ if res_model_name == 'account.report.line' and (
+ (res_id == line_to_adjust_id and report._get_model_info_from_id(lines[index + 1]['id']) == ('account.report.line', line_excluded_id)) or
+ (res_id == line_excluded_id and index == len(lines) - 1)
+ ):
+ # 'To Adjust' and 'Excluded' lines need to be hidden if they have no child
+ continue
+
+ elif res_model_name == 'res.currency':
+ # Include the rate in the currency_id group lines
+ line['name'] = '{for_cur} (1 {comp_cur} = {rate:.6} {for_cur})'.format(
+ for_cur=line['name'],
+ comp_cur=self.env.company.currency_id.display_name,
+ rate=float(options['currency_rates'][str(res_id)]['rate']),
+ )
+
+ elif res_model_name == 'account.account':
+ # Mark the included/excluded lines, so that the custom component templates knows what label to put on them
+ line['is_included_line'] = report._get_res_id_from_line_id(line['id'], 'account.account') == line_to_adjust_id
+
+ # Inject the related model into the line dict in order to use it on the custom component template on js side to display buttons
+ line['cur_revaluation_line_model'] = res_model_name
+
+ rslt.append(line)
+
+ return rslt
+
+ def _custom_groupby_line_completer(self, report, options, line_dict):
+ model_info_from_id = report._get_model_info_from_id(line_dict['id'])
+ if model_info_from_id[0] == 'res.currency':
+ line_dict['unfolded'] = True
+ line_dict['unfoldable'] = False
+
+ def action_multi_currency_revaluation_open_revaluation_wizard(self, options):
+ """Open the revaluation wizard."""
+ form = self.env.ref('odex30_account_reports.view_account_multicurrency_revaluation_wizard', False)
+ return {
+ 'name': _("Make Adjustment Entry"),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.multicurrency.revaluation.wizard',
+ 'view_mode': 'form',
+ 'view_id': form.id,
+ 'views': [(form.id, 'form')],
+ 'multi': 'True',
+ 'target': 'new',
+ 'context': {
+ **self._context,
+ 'multicurrency_revaluation_report_options': options,
+ },
+ }
+
+ # ACTIONS
+ def action_multi_currency_revaluation_open_general_ledger(self, options, params):
+ report = self.env['account.report'].browse(options['report_id'])
+ account_id = report._get_res_id_from_line_id(params['line_id'], 'account.account')
+ account_line_id = report._get_generic_line_id('account.account', account_id)
+ general_ledger_options = self.env.ref('odex30_account_reports.general_ledger_report').get_options(options)
+ general_ledger_options['unfolded_lines'] = [account_line_id]
+
+ general_ledger_action = self.env['ir.actions.actions']._for_xml_id('odex30_account_reports.action_account_report_general_ledger')
+ general_ledger_action['params'] = {
+ 'options': general_ledger_options,
+ 'ignore_session': True,
+ }
+
+ return general_ledger_action
+
+ def action_multi_currency_revaluation_toggle_provision(self, options, params):
+ """ Include/exclude an account from the provision. """
+ res_ids_map = self.env['account.report']._get_res_ids_from_line_id(params['line_id'], ['res.currency', 'account.account'])
+ account = self.env['account.account'].browse(res_ids_map['account.account'])
+ currency = self.env['res.currency'].browse(res_ids_map['res.currency'])
+ if currency in account.exclude_provision_currency_ids:
+ account.exclude_provision_currency_ids -= currency
+ else:
+ account.exclude_provision_currency_ids += currency
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'reload',
+ }
+
+ def action_multi_currency_revaluation_open_currency_rates(self, options, params=None):
+ """ Open the currency rate list. """
+ currency_id = self.env['account.report']._get_res_id_from_line_id(params['line_id'], 'res.currency')
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Currency Rates (%s)', self.env['res.currency'].browse(currency_id).display_name),
+ 'views': [(False, 'list')],
+ 'res_model': 'res.currency.rate',
+ 'context': {**self.env.context, **{'default_currency_id': currency_id, 'active_id': currency_id}},
+ 'domain': [('currency_id', '=', currency_id)],
+ }
+
+ def _report_custom_engine_multi_currency_revaluation_to_adjust(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ return self._multi_currency_revaluation_get_custom_lines(options, 'to_adjust', current_groupby, next_groupby, offset=offset, limit=limit)
+
+ def _report_custom_engine_multi_currency_revaluation_excluded(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ return self._multi_currency_revaluation_get_custom_lines(options, 'excluded', current_groupby, next_groupby, offset=offset, limit=limit)
+
+ def _multi_currency_revaluation_get_custom_lines(self, options, line_code, current_groupby, next_groupby, offset=0, limit=None):
+ def build_result_dict(report, query_res):
+ return {
+ 'balance_currency': query_res['balance_currency'] if len(query_res['currency_id']) == 1 else None,
+ 'currency_id': query_res['currency_id'][0] if len(query_res['currency_id']) == 1 else None,
+ 'balance_operation': query_res['balance_operation'],
+ 'balance_current': query_res['balance_current'],
+ 'adjustment': query_res['adjustment'],
+ 'has_sublines': query_res['aml_count'] > 0,
+ }
+
+ report = self.env['account.report'].browse(options['report_id'])
+ report._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else []))
+
+ # No need to run any SQL if we're computing the main line: it does not display any total
+ if not current_groupby:
+ return {
+ 'balance_currency': None,
+ 'currency_id': None,
+ 'balance_operation': None,
+ 'balance_current': None,
+ 'adjustment': None,
+ 'has_sublines': False,
+ }
+
+ query = "(VALUES {})".format(', '.join("(%s, %s)" for rate in options['currency_rates']))
+ params = list(chain.from_iterable((cur['currency_id'], cur['rate']) for cur in options['currency_rates'].values()))
+ custom_currency_table_query = SQL(query, *params)
+ date_to = options['date']['date_to']
+ select_part_not_an_exchange_move_id = SQL(
+ """
+ NOT EXISTS (
+ SELECT 1
+ FROM account_partial_reconcile part_exch
+ WHERE part_exch.exchange_move_id = account_move_line.move_id
+ AND part_exch.max_date <= %s
+ )
+ """,
+ date_to
+ )
+
+ query = report._get_report_query(options, 'strict_range')
+ groupby_field_sql = self.env['account.move.line']._field_to_sql("account_move_line", current_groupby, query)
+ tail_query = report._get_engine_query_tail(offset, limit)
+ full_query = SQL(
+ """
+ WITH custom_currency_table(currency_id, rate) AS (%(custom_currency_table_query)s)
+
+ -- Final select that gets the following lines:
+ -- (where there is a change in the rates of currency between the creation of the move and the full payments)
+ -- - Moves that don't have a payment yet at a certain date
+ -- - Moves that have a partial but are not fully paid at a certain date
+ SELECT
+ subquery.grouping_key,
+ ARRAY_AGG(DISTINCT(subquery.currency_id)) AS currency_id,
+ SUM(subquery.balance_currency) AS balance_currency,
+ SUM(subquery.balance_operation) AS balance_operation,
+ SUM(subquery.balance_current) AS balance_current,
+ SUM(subquery.adjustment) AS adjustment,
+ COUNT(subquery.aml_id) AS aml_count
+ FROM (
+ -- Get moves that have at least one partial at a certain date and are not fully paid at that date
+ SELECT
+ %(groupby_field_sql)s AS grouping_key,
+ ROUND(account_move_line.balance - SUM(ara.amount_debit) + SUM(ara.amount_credit), aml_comp_currency.decimal_places) AS balance_operation,
+ ROUND(account_move_line.amount_currency - SUM(ara.amount_debit_currency) + SUM(ara.amount_credit_currency), aml_currency.decimal_places) AS balance_currency,
+ ROUND(account_move_line.amount_currency - SUM(ara.amount_debit_currency) + SUM(ara.amount_credit_currency), aml_currency.decimal_places) / custom_currency_table.rate AS balance_current,
+ (
+ -- adjustment is computed as: balance_current - balance_operation
+ ROUND( account_move_line.amount_currency - SUM(ara.amount_debit_currency) + SUM(ara.amount_credit_currency), aml_currency.decimal_places) / custom_currency_table.rate
+ - ROUND(account_move_line.balance - SUM(ara.amount_debit) + SUM(ara.amount_credit), aml_comp_currency.decimal_places)
+ ) AS adjustment,
+ account_move_line.currency_id AS currency_id,
+ account_move_line.id AS aml_id
+ FROM %(table_references)s,
+ account_account AS account,
+ res_currency AS aml_currency,
+ res_currency AS aml_comp_currency,
+ custom_currency_table,
+
+ -- Get for each move line the amount residual and amount_residual currency
+ -- both for matched "debit" and matched "credit" the same way as account.move.line
+ -- '_compute_amount_residual()' method does
+ -- (using LATERAL greatly reduce the number of lines for which we have to compute it)
+ LATERAL (
+ -- Get sum of matched "debit" amount and amount in currency for related move line at date
+ SELECT COALESCE(SUM(part.amount), 0.0) AS amount_debit,
+ ROUND(
+ SUM(part.debit_amount_currency),
+ curr.decimal_places
+ ) AS amount_debit_currency,
+ 0.0 AS amount_credit,
+ 0.0 AS amount_credit_currency,
+ account_move_line.currency_id AS currency_id,
+ account_move_line.id AS aml_id
+ FROM account_partial_reconcile part
+ JOIN res_currency curr ON curr.id = part.debit_currency_id
+ WHERE account_move_line.id = part.debit_move_id
+ AND part.max_date <= %(date_to)s
+ GROUP BY aml_id,
+ curr.decimal_places
+ UNION
+ -- Get sum of matched "credit" amount and amount in currency for related move line at date
+ SELECT 0.0 AS amount_debit,
+ 0.0 AS amount_debit_currency,
+ COALESCE(SUM(part.amount), 0.0) AS amount_credit,
+ ROUND(
+ SUM(part.credit_amount_currency),
+ curr.decimal_places
+ ) AS amount_credit_currency,
+ account_move_line.currency_id AS currency_id,
+ account_move_line.id AS aml_id
+ FROM account_partial_reconcile part
+ JOIN res_currency curr ON curr.id = part.credit_currency_id
+ WHERE account_move_line.id = part.credit_move_id
+ AND part.max_date <= %(date_to)s
+ GROUP BY aml_id,
+ curr.decimal_places
+ ) AS ara
+ WHERE %(search_condition)s
+ AND account_move_line.account_id = account.id
+ AND account_move_line.currency_id = aml_currency.id
+ AND account_move_line.company_currency_id = aml_comp_currency.id
+ AND account_move_line.currency_id = custom_currency_table.currency_id
+ AND account.account_type NOT IN ('income', 'income_other', 'expense', 'expense_depreciation', 'expense_direct_cost', 'off_balance')
+ AND (
+ account.currency_id != account_move_line.company_currency_id
+ OR (
+ account.account_type IN ('asset_receivable', 'liability_payable')
+ AND (account_move_line.currency_id != account_move_line.company_currency_id)
+ )
+ )
+ AND %(exist_condition)s (
+ SELECT 1
+ FROM account_account_exclude_res_currency_provision
+ WHERE account_account_id = account_move_line.account_id
+ AND res_currency_id = account_move_line.currency_id
+ )
+ AND (%(select_part_not_an_exchange_move_id)s)
+ GROUP BY account_move_line.id, grouping_key, aml_comp_currency.decimal_places, aml_currency.decimal_places, custom_currency_table.rate
+ HAVING ROUND(account_move_line.balance - SUM(ara.amount_debit) + SUM(ara.amount_credit), aml_comp_currency.decimal_places) != 0
+ OR ROUND(account_move_line.amount_currency - SUM(ara.amount_debit_currency) + SUM(ara.amount_credit_currency), aml_currency.decimal_places) != 0.0
+
+ UNION
+ -- Moves that don't have a payment yet at a certain date
+ SELECT
+ %(groupby_field_sql)s AS grouping_key,
+ account_move_line.balance AS balance_operation,
+ account_move_line.amount_currency AS balance_currency,
+ account_move_line.amount_currency / custom_currency_table.rate AS balance_current,
+ account_move_line.amount_currency / custom_currency_table.rate - account_move_line.balance AS adjustment,
+ account_move_line.currency_id AS currency_id,
+ account_move_line.id AS aml_id
+ FROM %(table_references)s
+ JOIN account_account account ON account_move_line.account_id = account.id
+ JOIN custom_currency_table ON custom_currency_table.currency_id = account_move_line.currency_id
+ WHERE %(search_condition)s
+ AND account.account_type NOT IN ('income', 'income_other', 'expense', 'expense_depreciation', 'expense_direct_cost', 'off_balance')
+ AND (
+ account.currency_id != account_move_line.company_currency_id
+ OR (
+ account.account_type IN ('asset_receivable', 'liability_payable')
+ AND (account_move_line.currency_id != account_move_line.company_currency_id)
+ )
+ )
+ AND %(exist_condition)s (
+ SELECT 1
+ FROM account_account_exclude_res_currency_provision
+ WHERE account_account_id = account_id
+ AND res_currency_id = account_move_line.currency_id
+ )
+ AND (%(select_part_not_an_exchange_move_id)s)
+ AND NOT EXISTS (
+ SELECT 1 FROM account_partial_reconcile part
+ WHERE (part.debit_move_id = account_move_line.id OR part.credit_move_id = account_move_line.id)
+ AND part.max_date <= %(date_to)s
+ )
+ AND (account_move_line.balance != 0.0 OR account_move_line.amount_currency != 0.0)
+
+ ) subquery
+
+ GROUP BY grouping_key
+ ORDER BY grouping_key
+ %(tail_query)s
+ """,
+ groupby_field_sql=groupby_field_sql,
+ custom_currency_table_query=custom_currency_table_query,
+ exist_condition=SQL('NOT EXISTS') if line_code == 'to_adjust' else SQL('EXISTS'),
+ table_references=query.from_clause,
+ date_to=date_to,
+ tail_query=tail_query,
+ search_condition=query.where_clause,
+ select_part_not_an_exchange_move_id=select_part_not_an_exchange_move_id,
+ )
+ self._cr.execute(full_query)
+ query_res_lines = self._cr.dictfetchall()
+
+ if not current_groupby:
+ return build_result_dict(report, query_res_lines and query_res_lines[0] or {})
+ else:
+ rslt = []
+ for query_res in query_res_lines:
+ grouping_key = query_res['grouping_key']
+ rslt.append((grouping_key, build_result_dict(report, query_res)))
+ return rslt
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_partner_ledger.py b/dev_odex30_accounting/odex30_account_reports/models/account_partner_ledger.py
new file mode 100644
index 0000000..d6ea0db
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_partner_ledger.py
@@ -0,0 +1,815 @@
+
+from odoo import api, models, _, fields
+from odoo.exceptions import UserError
+from odoo.osv import expression
+from odoo.tools import SQL
+
+from datetime import timedelta
+from collections import defaultdict
+from copy import deepcopy
+
+
+class PartnerLedgerCustomHandler(models.AbstractModel):
+ _name = 'account.partner.ledger.report.handler'
+ _inherit = 'account.report.custom.handler'
+ _description = 'Partner Ledger Custom Handler'
+
+ def _get_custom_display_config(self):
+ return {
+ 'css_custom_class': 'partner_ledger',
+ 'components': {
+ 'AccountReportLineCell': 'odex30_account_reports.PartnerLedgerLineCell',
+ },
+ 'templates': {
+ 'AccountReportFilters': 'odex30_account_reports.PartnerLedgerFilters',
+ 'AccountReportLineName': 'odex30_account_reports.PartnerLedgerLineName',
+ },
+ }
+
+ def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None):
+ partner_lines, totals_by_column_group = self._build_partner_lines(report, options)
+ lines = report._regroup_lines_by_name_prefix(options, partner_lines, '_report_expand_unfoldable_line_partner_ledger_prefix_group', 0)
+
+ # Inject sequence on dynamic lines
+ lines = [(0, line) for line in lines]
+
+ # Report total line.
+ lines.append((0, self._get_report_line_total(options, totals_by_column_group)))
+
+ return lines
+
+ def _build_partner_lines(self, report, options, level_shift=0):
+ lines = []
+
+ totals_by_column_group = {
+ column_group_key: {
+ total: 0.0
+ for total in ['debit', 'credit', 'amount', 'balance']
+ }
+ for column_group_key in options['column_groups']
+ }
+
+ partners_results = self._query_partners(report, options)
+
+ search_filter = options.get('filter_search_bar', '')
+ accept_unknown_in_filter = search_filter.lower() in self._get_no_partner_line_label().lower()
+ for partner, results in partners_results:
+ if options['export_mode'] == 'print' and search_filter and not partner and not accept_unknown_in_filter:
+ # When printing and searching for a specific partner, make it so we only show its lines, not the 'Unknown Partner' one, that would be
+ # shown in case a misc entry with no partner was reconciled with one of the target partner's entries.
+ continue
+
+ partner_values = defaultdict(dict)
+ for column_group_key in options['column_groups']:
+ partner_sum = results.get(column_group_key, {})
+
+ partner_values[column_group_key]['debit'] = partner_sum.get('debit', 0.0)
+ partner_values[column_group_key]['credit'] = partner_sum.get('credit', 0.0)
+ partner_values[column_group_key]['amount'] = partner_sum.get('amount', 0.0)
+ partner_values[column_group_key]['balance'] = partner_sum.get('balance', 0.0)
+
+ totals_by_column_group[column_group_key]['debit'] += partner_values[column_group_key]['debit']
+ totals_by_column_group[column_group_key]['credit'] += partner_values[column_group_key]['credit']
+ totals_by_column_group[column_group_key]['amount'] += partner_values[column_group_key]['amount']
+ totals_by_column_group[column_group_key]['balance'] += partner_values[column_group_key]['balance']
+
+ lines.append(self._get_report_line_partners(options, partner, partner_values, level_shift=level_shift))
+
+ return lines, totals_by_column_group
+
+ def _report_expand_unfoldable_line_partner_ledger_prefix_group(self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None):
+ report = self.env['account.report'].browse(options['report_id'])
+ matched_prefix = report._get_prefix_groups_matched_prefix_from_line_id(line_dict_id)
+
+ prefix_domain = [('partner_id.name', '=ilike', f'{matched_prefix}%')]
+ if self._get_no_partner_line_label().upper().startswith(matched_prefix):
+ prefix_domain = expression.OR([prefix_domain, [('partner_id', '=', None)]])
+
+ expand_options = {
+ **options,
+ 'forced_domain': options.get('forced_domain', []) + prefix_domain
+ }
+ parent_level = len(matched_prefix) * 2
+ partner_lines, dummy = self._build_partner_lines(report, expand_options, level_shift=parent_level)
+
+ for partner_line in partner_lines:
+ partner_line['id'] = report._build_subline_id(line_dict_id, partner_line['id'])
+ partner_line['parent_id'] = line_dict_id
+
+ lines = report._regroup_lines_by_name_prefix(
+ options,
+ partner_lines,
+ '_report_expand_unfoldable_line_partner_ledger_prefix_group',
+ parent_level,
+ matched_prefix=matched_prefix,
+ parent_line_dict_id=line_dict_id,
+ )
+
+ return {
+ 'lines': lines,
+ 'offset_increment': len(lines),
+ 'has_more': False,
+ }
+
+ def _custom_options_initializer(self, report, options, previous_options):
+ super()._custom_options_initializer(report, options, previous_options=previous_options)
+ domain = []
+
+ company_ids = report.get_report_company_ids(options)
+ exch_code = self.env['res.company'].browse(company_ids).mapped('currency_exchange_journal_id')
+ if exch_code:
+ domain += ['!', '&', '&', '&', ('credit', '=', 0.0), ('debit', '=', 0.0), ('amount_currency', '!=', 0.0), ('journal_id', 'in', exch_code.ids)]
+
+ if options['export_mode'] == 'print' and options.get('filter_search_bar'):
+ domain += [
+ '|', ('matched_debit_ids.debit_move_id.partner_id.name', 'ilike', options['filter_search_bar']),
+ '|', ('matched_credit_ids.credit_move_id.partner_id.name', 'ilike', options['filter_search_bar']),
+ '|', ('partner_id.name', 'ilike', options['filter_search_bar']),
+ ('partner_id', '=', False),
+ ]
+
+ options['forced_domain'] = options.get('forced_domain', []) + domain
+
+ if self.env.user.has_group('base.group_multi_currency'):
+ options['multi_currency'] = True
+ else:
+ options['columns'] = [col for col in options['columns'] if col['expression_label'] != 'amount_currency']
+
+ if not self.env.ref('odex30_account_reports.customer_statement_report', raise_if_not_found=False):
+ # Deprecated, will be removed in master
+ columns_to_hide = []
+ options['hide_account'] = (previous_options or {}).get('hide_account', False)
+ columns_to_hide += ['journal_code', 'account_code', 'matching_number'] if options['hide_account'] else []
+
+ options['hide_debit_credit'] = (previous_options or {}).get('hide_debit_credit', False)
+ columns_to_hide += ['debit', 'credit'] if options['hide_debit_credit'] else ['amount']
+
+ options['columns'] = [col for col in options['columns'] if col['expression_label'] not in columns_to_hide]
+
+ options['buttons'].append({
+ 'name': _('Send'),
+ 'action': 'action_send_statements',
+ 'sequence': 90,
+ 'always_show': True,
+ })
+
+ def _custom_unfold_all_batch_data_generator(self, report, options, lines_to_expand_by_function):
+ partner_ids_to_expand = []
+
+ # Regular case
+ for line_dict in lines_to_expand_by_function.get('_report_expand_unfoldable_line_partner_ledger', []):
+ markup, model, model_id = self.env['account.report']._parse_line_id(line_dict['id'])[-1]
+ if model == 'res.partner':
+ partner_ids_to_expand.append(model_id)
+ elif markup == 'no_partner':
+ partner_ids_to_expand.append(None)
+
+ # In case prefix groups are used
+ no_partner_line_label = self._get_no_partner_line_label().upper()
+ partner_prefix_domains = []
+ for line_dict in lines_to_expand_by_function.get('_report_expand_unfoldable_line_partner_ledger_prefix_group', []):
+ prefix = report._get_prefix_groups_matched_prefix_from_line_id(line_dict['id'])
+ partner_prefix_domains.append([('name', '=ilike', f'{prefix}%')])
+
+ # amls without partners are regrouped "Unknown Partner", which is also used to create prefix groups
+ if no_partner_line_label.startswith(prefix):
+ partner_ids_to_expand.append(None)
+
+ if partner_prefix_domains:
+ partner_ids_to_expand += self.env['res.partner'].with_context(active_test=False).search(expression.OR(partner_prefix_domains)).ids
+
+ return {
+ 'initial_balances': self._get_initial_balance_values(partner_ids_to_expand, options) if partner_ids_to_expand else {},
+
+ # load_more_limit cannot be passed to this call, otherwise it won't be applied per partner but on the whole result.
+ # We gain perf from batching, but load every result, even if the limit restricts them later.
+ 'aml_values': self._get_aml_values(options, partner_ids_to_expand) if partner_ids_to_expand else {},
+ }
+
+ def _get_report_send_recipients(self, options):
+ # Deprecated, to be moved to customer statement handler in master
+ partners = options.get('partner_ids', [])
+ if not partners:
+ report = self.env['account.report'].browse(options['report_id'])
+ self._cr.execute(self._get_query_sums(report, options))
+ partners = [row['groupby'] for row in self._cr.dictfetchall() if row['groupby']]
+ return self.env['res.partner'].browse(partners)
+
+ def action_send_statements(self, options):
+ # Deprecated, to be moved to customer statement handler in master
+ template = self.env.ref('odex30_account_reports.email_template_customer_statement', False)
+ partners = self.env['res.partner'].browse(options.get('partner_ids', []))
+ return {
+ 'name': _("Send %s Statement", partners.name) if len(partners) == 1 else _("Send Partner Ledgers"),
+ 'type': 'ir.actions.act_window',
+ 'views': [[False, 'form']],
+ 'res_model': 'account.report.send',
+ 'target': 'new',
+ 'context': {
+ 'default_mail_template_id': template.id if template else False,
+ 'default_report_options': options,
+ },
+ }
+
+ @api.model
+ def action_open_partner(self, options, params):
+ dummy, record_id = self.env['account.report']._get_model_info_from_id(params['id'])
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'res.partner',
+ 'res_id': record_id,
+ 'views': [[False, 'form']],
+ 'view_mode': 'form',
+ 'target': 'current',
+ }
+
+ def _query_partners(self, report, options):
+ """ Executes the queries and performs all the computation.
+ :return: A list of tuple (partner, column_group_values) sorted by the table's model _order:
+ - partner is a res.parter record.
+ - column_group_values is a dict(column_group_key, fetched_values), where
+ - column_group_key is a string identifying a column group, like in options['column_groups']
+ - fetched_values is a dictionary containing:
+ - sum: {'debit': float, 'credit': float, 'balance': float}
+ - (optional) initial_balance: {'debit': float, 'credit': float, 'balance': float}
+ - (optional) lines: [line_vals_1, line_vals_2, ...]
+ """
+ def assign_sum(row):
+ fields_to_assign = ['balance', 'debit', 'credit', 'amount']
+ if any(not company_currency.is_zero(row[field]) for field in fields_to_assign):
+ groupby_partners.setdefault(row['groupby'], defaultdict(lambda: defaultdict(float)))
+ for field in fields_to_assign:
+ groupby_partners[row['groupby']][row['column_group_key']][field] += row[field]
+
+ company_currency = self.env.company.currency_id
+
+ # Execute the queries and dispatch the results.
+ query = self._get_query_sums(report, options)
+
+ groupby_partners = {}
+
+ self._cr.execute(query)
+ for res in self._cr.dictfetchall():
+ assign_sum(res)
+
+ # Correct the sums per partner, for the lines without partner reconciled with a line having a partner
+ self._add_sums_of_lines_without_partners(options, groupby_partners)
+
+ # Retrieve the partners to browse.
+ # groupby_partners.keys() contains all account ids affected by:
+ # - the amls in the current period.
+ # - the amls affecting the initial balance.
+ if groupby_partners:
+ # Note a search is done instead of a browse to preserve the table ordering.
+ partners = self.env['res.partner'].with_context(active_test=False).search_fetch([('id', 'in', list(groupby_partners.keys()))], ["id", "name", "trust", "company_registry", "vat"])
+ else:
+ partners = []
+
+ # Add 'Partner Unknown' if needed
+ if None in groupby_partners.keys():
+ partners = [p for p in partners] + [None]
+
+ return [(partner, groupby_partners[partner.id if partner else None]) for partner in partners]
+
+ def _get_query_sums(self, report, options) -> SQL:
+ """ Construct a query retrieving all the aggregated sums to build the report. It includes:
+ - sums for all partners.
+ - sums for the initial balances.
+ :param options: The report options.
+ :return: query as SQL object
+ """
+ queries = []
+
+ # Create the currency table.
+ for column_group_key, column_group_options in report._split_options_per_column_group(options).items():
+ query = report._get_report_query(column_group_options, 'from_beginning')
+ date_from = options['date']['date_from']
+ queries.append(SQL(
+ """
+ (WITH partner_sums AS (
+ SELECT
+ account_move_line.partner_id AS groupby,
+ %(column_group_key)s AS column_group_key,
+ SUM(%(debit_select)s) AS debit,
+ SUM(%(credit_select)s) AS credit,
+ SUM(%(balance_select)s) AS amount,
+ SUM(%(balance_select)s) AS balance,
+ BOOL_AND(account_move_line.reconciled) AS all_reconciled,
+ MAX(account_move_line.date) AS latest_date
+ FROM %(table_references)s
+ %(currency_table_join)s
+ WHERE %(search_condition)s
+ GROUP BY account_move_line.partner_id
+ )
+ SELECT *
+ FROM partner_sums
+ WHERE partner_sums.balance != 0
+ OR partner_sums.all_reconciled = FALSE
+ OR partner_sums.latest_date >= %(date_from)s
+ )""",
+ column_group_key=column_group_key,
+ debit_select=report._currency_table_apply_rate(SQL("account_move_line.debit")),
+ credit_select=report._currency_table_apply_rate(SQL("account_move_line.credit")),
+ balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")),
+ table_references=query.from_clause,
+ currency_table_join=report._currency_table_aml_join(column_group_options),
+ search_condition=query.where_clause,
+ date_from=date_from,
+ ))
+
+ return SQL(' UNION ALL ').join(queries)
+
+ def _get_initial_balance_values(self, partner_ids, options):
+ queries = []
+ report = self.env.ref('odex30_account_reports.partner_ledger_report')
+ for column_group_key, column_group_options in report._split_options_per_column_group(options).items():
+ # Get sums for the initial balance.
+ # period: [('date' <= options['date_from'] - 1)]
+ new_options = self._get_options_initial_balance(column_group_options)
+ query = report._get_report_query(new_options, 'from_beginning', domain=[('partner_id', 'in', partner_ids)])
+ queries.append(SQL(
+ """
+ SELECT
+ account_move_line.partner_id,
+ %(column_group_key)s AS column_group_key,
+ SUM(%(debit_select)s) AS debit,
+ SUM(%(credit_select)s) AS credit,
+ SUM(%(balance_select)s) AS amount,
+ SUM(%(balance_select)s) AS balance
+ FROM %(table_references)s
+ %(currency_table_join)s
+ WHERE %(search_condition)s
+ GROUP BY account_move_line.partner_id
+ """,
+ column_group_key=column_group_key,
+ debit_select=report._currency_table_apply_rate(SQL("account_move_line.debit")),
+ credit_select=report._currency_table_apply_rate(SQL("account_move_line.credit")),
+ balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")),
+ table_references=query.from_clause,
+ currency_table_join=report._currency_table_aml_join(column_group_options),
+ search_condition=query.where_clause,
+ ))
+
+ self._cr.execute(SQL(" UNION ALL ").join(queries))
+
+ init_balance_by_col_group = {
+ partner_id: {column_group_key: defaultdict(float) for column_group_key in options['column_groups']}
+ for partner_id in partner_ids
+ }
+ for result in self._cr.dictfetchall():
+ init_balance_by_col_group[result['partner_id']][result['column_group_key']] = result
+
+ # Correct the sums per partner, for the lines without partner reconciled with a line having a partner
+ new_options = self._get_options_initial_balance(options)
+ self._add_sums_of_lines_without_partners(new_options, init_balance_by_col_group)
+
+ return init_balance_by_col_group
+
+ def _get_options_initial_balance(self, options):
+ """ Create options used to compute the initial balances for each partner.
+ The resulting dates domain will be:
+ [('date' <= options['date_from'] - 1)]
+ :param options: The report options.
+ :return: A copy of the options, modified to match the dates to use to get the initial balances.
+ """
+ new_date_to = fields.Date.from_string(options['date']['date_from']) - timedelta(days=1)
+ new_options = deepcopy(options)
+ new_options['date']['date_from'] = False
+ new_options['date']['date_to'] = fields.Date.to_string(new_date_to)
+ for column_group in new_options['column_groups'].values():
+ column_group['forced_options']['date'] = new_options['date']
+ return new_options
+
+ def _add_sums_of_lines_without_partners(self, options, result_dict):
+ fields2inverse = {
+ 'balance': ('balance', -1),
+ 'debit': ('credit', 1),
+ 'amount': ('amount', 1),
+ 'credit': ('debit', 1),
+ }
+ query = self._get_sums_without_partner(options)
+ self._cr.execute(query)
+ rows = self._cr.dictfetchall()
+ for row in rows:
+ for field, (inverse_field, inverse_sign) in fields2inverse.items():
+ if partner_vals := result_dict.get(row['groupby']):
+ partner_vals[row['column_group_key']][field] += row[field]
+ if no_partner_vals := result_dict.get(None):
+ # Debit/credit are inverted for the unknown partner as the computation is made regarding the balance of the known partner
+ no_partner_vals[row['column_group_key']][inverse_field] += inverse_sign * row[field]
+
+ def _get_sums_without_partner(self, options):
+ """ Get the sum of lines without partner reconciled with a line with a partner, grouped by partner. Those lines
+ should be considered as belonging to the partner for the reconciled amount as it may clear some of the partner
+ invoice/bill and they have to be accounted in the partner balance."""
+ queries = []
+ report = self.env.ref('odex30_account_reports.partner_ledger_report')
+ for column_group_key, column_group_options in report._split_options_per_column_group(options).items():
+ query = report._get_report_query(column_group_options, 'from_beginning')
+ queries.append(SQL(
+ """
+ SELECT
+ %(column_group_key)s AS column_group_key,
+ aml_with_partner.partner_id AS groupby,
+ SUM(%(debit_select)s) AS debit,
+ SUM(%(credit_select)s) AS credit,
+ SUM(%(balance_select)s) AS amount,
+ SUM(%(balance_select)s) AS balance
+ FROM %(table_references)s
+ JOIN account_partial_reconcile partial
+ ON account_move_line.id = partial.debit_move_id OR account_move_line.id = partial.credit_move_id
+ JOIN account_move_line aml_with_partner ON
+ (aml_with_partner.id = partial.debit_move_id OR aml_with_partner.id = partial.credit_move_id)
+ AND aml_with_partner.partner_id IS NOT NULL
+ %(currency_table_join)s
+ WHERE partial.max_date <= %(date_to)s AND %(search_condition)s
+ AND account_move_line.partner_id IS NULL
+ GROUP BY aml_with_partner.partner_id
+ """,
+ column_group_key=column_group_key,
+ debit_select=report._currency_table_apply_rate(SQL("CASE WHEN aml_with_partner.balance > 0 THEN 0 ELSE partial.amount END")),
+ credit_select=report._currency_table_apply_rate(SQL("CASE WHEN aml_with_partner.balance < 0 THEN 0 ELSE partial.amount END")),
+ balance_select=report._currency_table_apply_rate(SQL("-SIGN(aml_with_partner.balance) * partial.amount")),
+ table_references=query.from_clause,
+ currency_table_join=report._currency_table_aml_join(column_group_options, aml_alias=SQL("aml_with_partner")),
+ date_to=column_group_options['date']['date_to'],
+ search_condition=query.where_clause,
+ ))
+
+ return SQL(" UNION ALL ").join(queries)
+
+ def _report_expand_unfoldable_line_partner_ledger(self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None):
+ report = self.env['account.report'].browse(options['report_id'])
+ markup, model, record_id = report._parse_line_id(line_dict_id)[-1]
+
+ if model != 'res.partner':
+ raise UserError(_("Wrong ID for partner ledger line to expand: %s", line_dict_id))
+
+ prefix_groups_count = 0
+ for markup, dummy1, dummy2 in report._parse_line_id(line_dict_id):
+ if isinstance(markup, dict) and 'groupby_prefix_group' in markup:
+ prefix_groups_count += 1
+ level_shift = prefix_groups_count * 2
+
+ lines = []
+
+ # Get initial balance
+ if offset == 0 and not options.get('hide_initial_balance'):
+ if unfold_all_batch_data:
+ init_balance_by_col_group = unfold_all_batch_data['initial_balances'][record_id]
+ else:
+ init_balance_by_col_group = self._get_initial_balance_values([record_id], options)[record_id]
+ initial_balance_line = report._get_partner_and_general_ledger_initial_balance_line(options, line_dict_id, init_balance_by_col_group, level_shift=level_shift)
+ if initial_balance_line:
+ lines.append(initial_balance_line)
+
+ # For the first expansion of the line, the initial balance line gives the progress
+ progress = self._init_load_more_progress(options, initial_balance_line)
+
+ limit_to_load = report.load_more_limit + 1 if report.load_more_limit and options['export_mode'] != 'print' else None
+
+ if unfold_all_batch_data:
+ aml_results = unfold_all_batch_data['aml_values'][record_id]
+ else:
+ aml_results = self._get_aml_values(options, [record_id], offset=offset, limit=limit_to_load)[record_id]
+
+ aml_report_lines, next_progress, treated_results_count, has_more = self._get_partner_aml_report_lines(report, options, line_dict_id, aml_results, progress, offset, level_shift=level_shift)
+ lines.extend(aml_report_lines)
+
+ return {
+ 'lines': lines,
+ 'offset_increment': treated_results_count,
+ 'has_more': has_more,
+ 'progress': next_progress
+ }
+
+ def _init_load_more_progress(self, options, line_dict):
+ return {
+ column['column_group_key']: line_col.get('no_format', 0)
+ for column, line_col in zip(options['columns'], line_dict['columns'])
+ if column['expression_label'] == 'balance'
+ }
+
+ def _get_partner_aml_report_lines(self, report, options, partner_line_id, aml_results, progress, offset=0, level_shift=0):
+ lines = []
+ has_more = False
+ treated_results_count = 0
+ next_progress = progress
+ for result in aml_results:
+ if self._is_report_limit_reached(report, options, treated_results_count):
+ # We loaded one more than the limit on purpose: this way we know we need a "load more" line
+ has_more = True
+ break
+
+ new_line = self._get_report_line_move_line(options, result, partner_line_id, next_progress, level_shift=level_shift)
+ lines.append(new_line)
+ next_progress = self._init_load_more_progress(options, new_line)
+ treated_results_count += 1
+ return lines, next_progress, treated_results_count, has_more
+
+ def _is_report_limit_reached(self, report, options, results_count):
+ return options['export_mode'] != 'print' and report.load_more_limit and results_count == report.load_more_limit
+
+ def _get_additional_column_aml_values(self):
+ """
+ Allows customization of additional fields in the partner ledger query.
+
+ This method is intended to be overridden by other modules to add custom fields
+ to the partner ledger query, e.g. SQL("account_move_line.date AS date,").
+
+ By default, it returns an empty SQL object.
+ """
+ return SQL()
+
+ def _get_order_by_aml_values(self):
+ return SQL('account_move_line.date, account_move_line.id')
+
+ def _get_aml_values(self, options, partner_ids, offset=0, limit=None):
+ rslt = {partner_id: [] for partner_id in partner_ids}
+
+ partner_ids_wo_none = [x for x in partner_ids if x]
+ directly_linked_aml_partner_clauses = []
+ indirectly_linked_aml_partner_clause = SQL('aml_with_partner.partner_id IS NOT NULL')
+ if None in partner_ids:
+ directly_linked_aml_partner_clauses.append(SQL('account_move_line.partner_id IS NULL'))
+ if partner_ids_wo_none:
+ directly_linked_aml_partner_clauses.append(SQL('account_move_line.partner_id IN %s', tuple(partner_ids_wo_none)))
+ indirectly_linked_aml_partner_clause = SQL('aml_with_partner.partner_id IN %s', tuple(partner_ids_wo_none))
+ directly_linked_aml_partner_clause = SQL('(%s)', SQL(' OR ').join(directly_linked_aml_partner_clauses))
+
+ queries = []
+ journal_name = self.env['account.journal']._field_to_sql('journal', 'name')
+ report = self.env.ref('odex30_account_reports.partner_ledger_report')
+ additional_columns = self._get_additional_column_aml_values()
+ order_by = self._get_order_by_aml_values()
+ for column_group_key, group_options in report._split_options_per_column_group(options).items():
+ query = report._get_report_query(group_options, 'strict_range')
+ account_alias = query.left_join(lhs_alias='account_move_line', lhs_column='account_id', rhs_table='account_account', rhs_column='id', link='account_id')
+ account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query)
+ account_name = self.env['account.account']._field_to_sql(account_alias, 'name')
+
+ # For the move lines directly linked to this partner
+ # ruff: noqa: FURB113
+ queries.append(SQL(
+ '''
+ SELECT
+ account_move_line.id,
+ COALESCE(account_move_line.date_maturity, account_move_line.date) AS date_maturity,
+ account_move_line.name,
+ account_move_line.ref,
+ account_move_line.company_id,
+ account_move_line.account_id,
+ account_move_line.payment_id,
+ account_move_line.partner_id,
+ account_move_line.currency_id,
+ account_move_line.amount_currency,
+ account_move_line.matching_number,
+ %(additional_columns)s
+ COALESCE(account_move_line.invoice_date, account_move_line.date) AS invoice_date,
+ %(debit_select)s AS debit,
+ %(credit_select)s AS credit,
+ %(balance_select)s AS amount,
+ %(balance_select)s AS balance,
+ account_move.name AS move_name,
+ account_move.move_type AS move_type,
+ %(account_code)s AS account_code,
+ %(account_name)s AS account_name,
+ journal.code AS journal_code,
+ %(journal_name)s AS journal_name,
+ %(column_group_key)s AS column_group_key,
+ 'directly_linked_aml' AS key,
+ 0 AS partial_id
+ %(extra_select)s
+ FROM %(table_references)s
+ JOIN account_move ON account_move.id = account_move_line.move_id
+ %(currency_table_join)s
+ LEFT JOIN res_company company ON company.id = account_move_line.company_id
+ LEFT JOIN res_partner partner ON partner.id = account_move_line.partner_id
+ LEFT JOIN account_journal journal ON journal.id = account_move_line.journal_id
+ WHERE %(search_condition)s AND %(directly_linked_aml_partner_clause)s
+ ORDER BY %(order_by)s
+ ''',
+ additional_columns=additional_columns,
+ debit_select=report._currency_table_apply_rate(SQL("account_move_line.debit")),
+ credit_select=report._currency_table_apply_rate(SQL("account_move_line.credit")),
+ balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")),
+ account_code=account_code,
+ account_name=account_name,
+ journal_name=journal_name,
+ column_group_key=column_group_key,
+ table_references=query.from_clause,
+ currency_table_join=report._currency_table_aml_join(group_options),
+ search_condition=query.where_clause,
+ directly_linked_aml_partner_clause=directly_linked_aml_partner_clause,
+ order_by=order_by,
+ extra_select=SQL(' ').join(self._get_aml_value_extra_select()),
+ ))
+
+ # For the move lines linked to no partner, but reconciled with this partner. They will appear in grey in the report
+ queries.append(SQL(
+ '''
+ SELECT
+ account_move_line.id,
+ COALESCE(account_move_line.date_maturity, account_move_line.date) AS date_maturity,
+ account_move_line.name,
+ account_move_line.ref,
+ account_move_line.company_id,
+ account_move_line.account_id,
+ account_move_line.payment_id,
+ aml_with_partner.partner_id,
+ account_move_line.currency_id,
+ account_move_line.amount_currency,
+ account_move_line.matching_number,
+ %(additional_columns)s
+ COALESCE(account_move_line.invoice_date, account_move_line.date) AS invoice_date,
+ %(debit_select)s AS debit,
+ %(credit_select)s AS credit,
+ %(balance_select)s AS amount,
+ %(balance_select)s AS balance,
+ account_move.name AS move_name,
+ account_move.move_type AS move_type,
+ %(account_code)s AS account_code,
+ %(account_name)s AS account_name,
+ journal.code AS journal_code,
+ %(journal_name)s AS journal_name,
+ %(column_group_key)s AS column_group_key,
+ 'indirectly_linked_aml' AS key,
+ partial.id AS partial_id
+ %(extra_select)s
+ FROM %(table_references)s
+ %(currency_table_join)s,
+ account_partial_reconcile partial,
+ account_move,
+ account_move_line aml_with_partner,
+ account_journal journal
+ WHERE
+ (account_move_line.id = partial.debit_move_id OR account_move_line.id = partial.credit_move_id)
+ AND account_move_line.partner_id IS NULL
+ AND account_move.id = account_move_line.move_id
+ AND (aml_with_partner.id = partial.debit_move_id OR aml_with_partner.id = partial.credit_move_id)
+ AND %(indirectly_linked_aml_partner_clause)s
+ AND journal.id = account_move_line.journal_id
+ AND %(account_alias)s.id = account_move_line.account_id
+ AND %(search_condition)s
+ AND partial.max_date BETWEEN %(date_from)s AND %(date_to)s
+ ORDER BY %(order_by)s
+ ''',
+ additional_columns=additional_columns,
+ debit_select=report._currency_table_apply_rate(SQL("CASE WHEN aml_with_partner.balance > 0 THEN 0 ELSE partial.amount END")),
+ credit_select=report._currency_table_apply_rate(SQL("CASE WHEN aml_with_partner.balance < 0 THEN 0 ELSE partial.amount END")),
+ balance_select=report._currency_table_apply_rate(SQL("-SIGN(aml_with_partner.balance) * partial.amount")),
+ account_code=account_code,
+ account_name=account_name,
+ journal_name=journal_name,
+ column_group_key=column_group_key,
+ table_references=query.from_clause,
+ currency_table_join=report._currency_table_aml_join(group_options),
+ indirectly_linked_aml_partner_clause=indirectly_linked_aml_partner_clause,
+ account_alias=SQL.identifier(account_alias),
+ search_condition=query.where_clause,
+ date_from=group_options['date']['date_from'],
+ date_to=group_options['date']['date_to'],
+ order_by=order_by,
+ extra_select=SQL(' ').join(self._get_aml_value_extra_select()),
+ ))
+
+ query = SQL(" UNION ALL ").join(SQL("(%s)", query) for query in queries)
+
+ if offset:
+ query = SQL('%s OFFSET %s ', query, offset)
+
+ if limit:
+ query = SQL('%s LIMIT %s ', query, limit)
+
+ self._cr.execute(query)
+ for aml_result in self._cr.dictfetchall():
+ if aml_result['key'] == 'indirectly_linked_aml':
+
+ # Append the line to the partner found through the reconciliation.
+ if aml_result['partner_id'] in rslt:
+ rslt[aml_result['partner_id']].append(aml_result)
+
+ # Balance it with an additional line in the Unknown Partner section but having reversed amounts.
+ if None in rslt:
+ rslt[None].append({
+ **aml_result,
+ 'debit': aml_result['credit'],
+ 'credit': aml_result['debit'],
+ 'amount': aml_result['credit'] - aml_result['debit'],
+ 'balance': -aml_result['balance'],
+ })
+ else:
+ rslt[aml_result['partner_id']].append(aml_result)
+
+ return rslt
+
+ def _get_aml_value_extra_select(self):
+ """ Hook method to add extra select fields to the aml queries. """
+ return []
+
+ ####################################################
+ # COLUMNS/LINES
+ ####################################################
+ def _get_report_line_partners(self, options, partner, partner_values, level_shift=0):
+ company_currency = self.env.company.currency_id
+
+ partner_data = next(iter(partner_values.values()))
+ unfoldable = not company_currency.is_zero(partner_data.get('debit', 0) or partner_data.get('credit', 0))
+ column_values = []
+ report = self.env['account.report'].browse(options['report_id'])
+ for column in options['columns']:
+ col_expr_label = column['expression_label']
+ value = None if options.get('hide_partner_totals') else partner_values[column['column_group_key']].get(col_expr_label)
+ unfoldable = unfoldable or (col_expr_label in ('debit', 'credit', 'amount') and not company_currency.is_zero(value))
+ column_values.append(report._build_column_dict(value, column, options=options))
+
+
+ line_id = report._get_generic_line_id('res.partner', partner.id) if partner else report._get_generic_line_id('res.partner', None, markup='no_partner')
+
+ return {
+ 'id': line_id,
+ 'name': partner is not None and (partner.name or '')[:128] or self._get_no_partner_line_label(),
+ 'columns': column_values,
+ 'level': 1 + level_shift,
+ 'trust': partner.trust if partner else None,
+ 'unfoldable': unfoldable,
+ 'unfolded': line_id in options['unfolded_lines'] or options['unfold_all'],
+ 'expand_function': '_report_expand_unfoldable_line_partner_ledger',
+ }
+
+ def _get_no_partner_line_label(self):
+ return _('Unknown Partner')
+
+ @api.model
+ def _format_aml_name(self, line_name, move_ref, move_name=None):
+ ''' Format the display of an account.move.line record. As its very costly to fetch the account.move.line
+ records, only line_name, move_ref, move_name are passed as parameters to deal with sql-queries more easily.
+
+ :param line_name: The name of the account.move.line record.
+ :param move_ref: The reference of the account.move record.
+ :param move_name: The name of the account.move record.
+ :return: The formatted name of the account.move.line record.
+ '''
+ return self.env['account.move.line']._format_aml_name(line_name, move_ref, move_name=move_name)
+
+ def _get_report_line_move_line(self, options, aml_query_result, partner_line_id, init_bal_by_col_group, level_shift=0):
+ if aml_query_result['payment_id']:
+ caret_type = 'account.payment'
+ else:
+ caret_type = 'account.move.line'
+
+ columns = []
+ report = self.env['account.report'].browse(options['report_id'])
+ for column in options['columns']:
+ col_expr_label = column['expression_label']
+
+ if col_expr_label not in aml_query_result:
+ raise UserError(_("The column '%s' is not available for this report.", col_expr_label))
+
+ col_value = aml_query_result[col_expr_label] if column['column_group_key'] == aml_query_result['column_group_key'] else None
+
+ if col_value is None:
+ columns.append(report._build_column_dict(None, None))
+ else:
+ currency = False
+
+ if col_expr_label == 'balance':
+ col_value += init_bal_by_col_group[column['column_group_key']]
+
+ if col_expr_label == 'amount_currency':
+ currency = self.env['res.currency'].browse(aml_query_result['currency_id'])
+
+ if currency == self.env.company.currency_id:
+ col_value = ''
+
+ columns.append(report._build_column_dict(col_value, column, options=options, currency=currency))
+
+ return {
+ 'id': report._get_generic_line_id('account.move.line', aml_query_result['id'], parent_line_id=partner_line_id, markup=aml_query_result['partial_id']),
+ 'parent_id': partner_line_id,
+ 'name': self._format_aml_name(aml_query_result['name'], aml_query_result['ref'], aml_query_result['move_name']),
+ 'columns': columns,
+ 'caret_options': caret_type,
+ 'level': 3 + level_shift,
+ }
+
+ def _get_report_line_total(self, options, totals_by_column_group):
+ column_values = []
+ report = self.env['account.report'].browse(options['report_id'])
+ for column in options['columns']:
+ col_value = totals_by_column_group[column['column_group_key']].get(column['expression_label'])
+ column_values.append(report._build_column_dict(col_value, column, options=options))
+
+ return {
+ 'id': report._get_generic_line_id(None, None, markup='total'),
+ 'name': _('Total'),
+ 'level': 1,
+ 'columns': column_values,
+ }
+
+ def open_journal_items(self, options, params):
+ params['view_ref'] = 'account.view_move_line_tree_grouped_partner'
+ report = self.env['account.report'].browse(options['report_id'])
+ action = report.open_journal_items(options=options, params=params)
+ action.get('context', {}).update({'search_default_group_by_account': 0})
+ return action
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_report.py b/dev_odex30_accounting/odex30_account_reports/models/account_report.py
new file mode 100644
index 0000000..d743356
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_report.py
@@ -0,0 +1,7553 @@
+
+import ast
+import base64
+import datetime
+import io
+import json
+import logging
+import re
+from ast import literal_eval
+from collections import defaultdict
+from functools import cmp_to_key
+from itertools import groupby
+
+import markupsafe
+from dateutil.relativedelta import relativedelta
+from PIL import ImageFont
+
+from odoo import models, fields, api, _, osv
+from odoo.addons.web.controllers.utils import clean_action
+from odoo.exceptions import RedirectWarning, UserError, ValidationError
+from odoo.service.model import get_public_method
+from odoo.tools import date_utils, get_lang, float_is_zero, float_repr, SQL, parse_version, Query
+from odoo.tools.float_utils import float_round, float_compare
+from odoo.tools.misc import file_path, format_date, formatLang, split_every, xlsxwriter
+from odoo.tools.safe_eval import expr_eval, safe_eval
+
+_logger = logging.getLogger(__name__)
+
+ACCOUNT_CODES_ENGINE_SPLIT_REGEX = re.compile(r"(?=[+-])")
+
+ACCOUNT_CODES_ENGINE_TERM_REGEX = re.compile(
+ r"^(?P[+-]?)"\
+ r"(?P([A-Za-z\d.]*|tag\([\w.]+\))((?=\\)|(?<=[^CD])))"\
+ r"(\\\((?P([A-Za-z\d.]+,)*[A-Za-z\d.]*)\))?"\
+ r"(?P[DC]?)$"
+)
+
+ACCOUNT_CODES_ENGINE_TAG_ID_PREFIX_REGEX = re.compile(r"tag\(((?P\d+)|(?P\w+\.\w+))\)")
+
+# Performance optimisation: those engines always will receive None as their next_groupby, allowing more efficient batching.
+NO_NEXT_GROUPBY_ENGINES = {'tax_tags', 'account_codes'}
+
+NUMBER_FIGURE_TYPES = ('float', 'integer', 'monetary', 'percentage')
+
+LINE_ID_HIERARCHY_DELIMITER = '|'
+
+CURRENCIES_USING_LAKH = {'AFN', 'BDT', 'INR', 'MMK', 'NPR', 'PKR', 'LKR'}
+
+
+class AccountReportAnnotation(models.Model):
+ _name = 'account.report.annotation'
+ _description = 'Account Report Annotation'
+
+ report_id = fields.Many2one('account.report', help="The id of the annotated report.")
+ line_id = fields.Char(index=True, help="The id of the annotated line.")
+ text = fields.Char(string="The annotation's content.")
+ date = fields.Date(help="Date considered as annotated by the annotation.")
+ fiscal_position_id = fields.Many2one('account.fiscal.position', help="The fiscal position used while annotating.")
+
+ @api.model_create_multi
+ def create(self, values):
+ fiscal_positions_with_foreign_vat = self.env['account.fiscal.position'].search([('foreign_vat', '!=', False)], limit=1)
+ for annotation in values:
+ if 'line_id' in annotation:
+ annotation['line_id'] = self._remove_tax_grouping_from_line_id(annotation['line_id'])
+ if 'fiscal_position_id' in annotation:
+ if annotation['fiscal_position_id'] == 'domestic':
+ del annotation['fiscal_position_id']
+ elif annotation['fiscal_position_id'] == 'all':
+ annotation['fiscal_position_id'] = fiscal_positions_with_foreign_vat.id
+ else:
+ annotation['fiscal_position_id'] = int(annotation['fiscal_position_id'])
+
+ return super().create(values)
+
+ def _remove_tax_grouping_from_line_id(self, line_id):
+ """
+ Remove the tax grouping from the line_id. This is needed because the tax grouping is not relevant for the annotation.
+ Tax grouping are any group using 'account.group' in the line_id.
+ """
+ return self.env['account.report']._build_line_id([
+ (markup, model, res_id)
+ for markup, model, res_id in self.env['account.report']._parse_line_id(line_id, markup_as_string=True)
+ if model != 'account.group'
+ ])
+
+class AccountReport(models.Model):
+ _inherit = 'account.report'
+
+ horizontal_group_ids = fields.Many2many(string="Horizontal Groups", comodel_name='account.report.horizontal.group')
+ annotations_ids = fields.One2many(string="Annotations", comodel_name='account.report.annotation', inverse_name='report_id')
+
+ # Those fields allow case-by-case fine-tuning of the engine, for custom reports.
+ custom_handler_model_id = fields.Many2one(string='Custom Handler Model', comodel_name='ir.model')
+ custom_handler_model_name = fields.Char(string='Custom Handler Model Name', related='custom_handler_model_id.model')
+
+ # Account Coverage Report
+ is_account_coverage_report_available = fields.Boolean(compute='_compute_is_account_coverage_report_available')
+
+ tax_closing_start_date = fields.Date( # the default value is set in _auto_init
+ string="Start Date",
+ company_dependent=True
+ )
+
+ # Fields used for send reports by cron
+ send_and_print_values = fields.Json(copy=False)
+
+ def _auto_init(self):
+ super()._auto_init()
+
+ def precommit():
+ self.env['ir.default'].set(
+ 'account.report',
+ 'tax_closing_start_date',
+ fields.Date.context_today(self).replace(month=1, day=1),
+ )
+ self.env.cr.precommit.add(precommit)
+
+ @api.constrains('custom_handler_model_id')
+ def _validate_custom_handler_model(self):
+ for report in self:
+ if report.custom_handler_model_id:
+ custom_handler_model = self.env.registry['account.report.custom.handler']
+ current_model = self.env[report.custom_handler_model_name]
+ if not isinstance(current_model, custom_handler_model):
+ raise ValidationError(_(
+ "Field 'Custom Handler Model' can only reference records inheriting from [%s].",
+ custom_handler_model._name
+ ))
+
+ def unlink(self):
+ for report in self:
+ action, menuitem = report._get_existing_menuitem()
+ menuitem.unlink()
+ action.unlink()
+ return super().unlink()
+
+ def write(self, vals):
+ if 'active' in vals:
+ reports = {r.id: r.name for r in self}
+ actions = self.env['ir.actions.client'] \
+ .search([('name', 'in', list(reports.values())), ('tag', '=', 'account_report')]) \
+ .filtered(lambda act: (ast.literal_eval(act.context).get('report_id'), act.name) in reports.items())
+ self.env['ir.ui.menu'] \
+ .search([
+ ('active', '=', not vals['active']),
+ ('action', 'in', [f'ir.actions.client,{action.id}' for action in actions]),
+ ])\
+ .active = vals['active']
+ return super().write(vals)
+
+ ####################################################
+ # CRON
+ ####################################################
+
+ @api.model
+ def _cron_account_report_send(self, job_count=10):
+ """ Handle Send & Print async processing.
+ :param job_count: maximum number of jobs to process if specified.
+ """
+ to_process = self.env['account.report'].search(
+ [('send_and_print_values', '!=', False)],
+ )
+ if not to_process:
+ return
+
+ processed_count = 0
+ need_retrigger = False
+
+ for report in to_process:
+ if need_retrigger:
+ break
+ send_and_print_vals = report.send_and_print_values
+ report_partner_ids = send_and_print_vals.get('report_options', {}).get('partner_ids', [])
+ need_retrigger = processed_count + len(report_partner_ids) > job_count
+ partner_ids = report_partner_ids[:job_count - processed_count]
+ company = self.env['res.company'].browse(send_and_print_vals['report_options']['companies'][0]['id'])
+ existing_partner_ids = set(self.env['res.partner'].browse(partner_ids).exists().ids)
+ for partner_id in partner_ids:
+ if partner_id in existing_partner_ids:
+ options = {
+ **send_and_print_vals['report_options'],
+ 'partner_ids': [partner_id],
+ }
+ self.env['account.report.send']._process_send_and_print(report=report.with_company(company), options=options)
+ processed_count += 1
+ report_partner_ids.remove(partner_id)
+ if report_partner_ids:
+ send_and_print_vals['report_options']['partner_ids'] = report_partner_ids
+ report.send_and_print_values = send_and_print_vals
+ else:
+ report.send_and_print_values = False
+
+ if need_retrigger:
+ self.env.ref('odex30_account_reports.ir_cron_account_report_send')._trigger()
+
+ ####################################################
+ # MENU MANAGEMENT
+ ####################################################
+
+ def _get_existing_menuitem(self):
+ self.ensure_one()
+ action = self.env['ir.actions.client']\
+ .search([('name', '=', self.name), ('tag', '=', 'account_report')])\
+ .filtered(lambda act: ast.literal_eval(act.context).get('report_id') == self.id)
+ menuitem = self.env['ir.ui.menu']\
+ .with_context({'active_test': False, 'ir.ui.menu.full_list': True})\
+ .search([('action', '=', f'ir.actions.client,{action.id}')])
+ return action, menuitem
+
+ def _create_menu_item_for_report(self):
+ """ Adds a default menu item for this report. This is called by an action on the report, for reports created manually by the user.
+ """
+ self.ensure_one()
+
+ action, menuitem = self._get_existing_menuitem()
+
+ if menuitem:
+ raise UserError(_("This report already has a menuitem."))
+
+ if not action:
+ action = self.env['ir.actions.client'].create({
+ 'name': self.name,
+ 'tag': 'account_report',
+ 'context': {'report_id': self.id},
+ })
+
+ self.env['ir.ui.menu'].create({
+ 'name': self.name,
+ 'parent_id': self.env['ir.model.data']._xmlid_to_res_id('account.menu_finance_reports'),
+ 'action': f'ir.actions.client,{action.id}',
+ })
+
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'reload',
+ }
+
+ ####################################################
+ # OPTIONS: journals
+ ####################################################
+
+ def _get_filter_journals(self, options, additional_domain=None):
+ return self.env['account.journal'].with_context(active_test=False).search([
+ *self.env['account.journal']._check_company_domain(self.get_report_company_ids(options)),
+ *(additional_domain or []),
+ ], order="company_id, name")
+
+ def _get_filter_journal_groups(self, options):
+ return self.env['account.journal.group'].search([
+ *self.env['account.journal.group']._check_company_domain(self.get_report_company_ids(options)),
+ ], order='sequence')
+
+ def _init_options_journals(self, options, previous_options, additional_journals_domain=None):
+ # The additional additional_journals_domain optional parameter allows calling this with an additional restriction on journals,
+ # to regenerate the journal options accordingly.
+ def option_value(value, selected=False, group_journals=None):
+ result = {
+ 'id': value.id,
+ 'model': value._name,
+ 'name': value.display_name,
+ 'selected': selected,
+ }
+
+ if value._name == 'account.journal.group':
+ result.update({
+ 'title': value.display_name,
+ 'journals': group_journals.ids,
+ 'journal_types': list(set(group_journals.mapped('type'))),
+ })
+ elif value._name == 'account.journal':
+ result.update({
+ 'title': f"{value.name} - {value.code}",
+ 'type': value.type,
+ 'visible': True,
+ })
+
+ return result
+
+ if not self.filter_journals:
+ return
+
+ previous_journals = previous_options.get('journals', [])
+ previous_journal_group_action = previous_options.get('__journal_group_action', {})
+
+ all_journals = self._get_filter_journals(options, additional_domain=additional_journals_domain)
+ all_journal_groups = self._get_filter_journal_groups(options)
+
+ options['journals'] = []
+ options['selected_journal_groups'] = {}
+
+ groups_journals_selected = set()
+ options_journal_groups = []
+
+ # First time opening the report, and make sure it's not specifically stated that we should not reset the filter
+ is_opening_report = previous_options.get('is_opening_report') # key from JS controller when report is being opened
+ # a key to prevent the reset of the journals filter even when is_opening_report is True
+ can_reset_journals_filter = not previous_options.get('not_reset_journals_filter')
+
+ # 1. Handle journal group selection
+ for group in all_journal_groups:
+ group_journals = all_journals - group.excluded_journal_ids
+ selected = False
+ first_group_already_selected = bool(options['selected_journal_groups']) # only one group should be selected at most
+
+ # select the first group by default when opening the report
+ if is_opening_report and not first_group_already_selected and can_reset_journals_filter:
+ selected = True
+ # Otherwise, select the previous selected group (if any)
+ elif group.id == previous_journal_group_action.get('id'):
+ selected = previous_journal_group_action.get('action') == 'add'
+
+ group_option = option_value(group, selected=selected, group_journals=group_journals)
+ options_journal_groups.append(group_option)
+
+ # Select all the group journals
+ if selected:
+ options['selected_journal_groups'] = group_option
+ groups_journals_selected |= set(group_journals.ids)
+
+ # 2. Handle journals selection
+ previous_selected_journals_ids = {
+ journal['id']
+ for journal in previous_journals
+ if journal.get('model') == 'account.journal' and journal.get('selected')
+ }
+
+ company_journals_map = defaultdict(list)
+ journals_selected = set()
+
+ for journal in all_journals:
+ selected = False
+
+ if journal.id in groups_journals_selected:
+ selected = True
+
+ elif not options['selected_journal_groups'] and previous_journal_group_action.get('action') != 'remove':
+ if journal.id in previous_selected_journals_ids:
+ selected = True
+
+ if selected:
+ journals_selected.add(journal.id)
+
+ company_journals_map[journal.company_id].append(option_value(journal, selected=journal.id in journals_selected))
+
+ # 3. Recompute selected groups in case the set of selected journals is equal to a group's accepted journals
+ for group in options_journal_groups:
+ if journals_selected == set(group['journals']):
+ group['selected'] = True
+ options['selected_journal_groups'] = group
+
+ # 4. Unselect all journals if all are selected and no group is specifically selected
+ if journals_selected == set(all_journals.ids) and not options['selected_journal_groups']:
+ for company, journals in company_journals_map.items():
+ for journal in journals:
+ journal['selected'] = False
+
+ # 5. Build group options
+ if all_journal_groups:
+ options['journals'] = [{
+ 'id': 'divider',
+ 'name': _("Multi-ledger"),
+ 'model': 'account.journal.group',
+ }] + options_journal_groups
+
+ if not company_journals_map:
+ options['name_journal_group'] = _("No Journal")
+ return
+
+ # 6. Build journals options
+ if len(company_journals_map) > 1 or all_journal_groups:
+ for company, journals in company_journals_map.items():
+ # users may not have full access to the parent company in case they are in a branch, yet they have to see the company name
+ company_name = company.sudo().display_name
+
+ # if not is_opening_report, then gets the unfolded attribute of the company from the previous options
+ unfolded = False if is_opening_report else next(
+ (entry.get('unfolded') for entry in previous_journals
+ if entry['model'] == 'res.company' and entry['name'] == company_name), False)
+
+ for journal in journals:
+ journal['visible'] = unfolded
+
+ options['journals'].append({
+ 'id': 'divider',
+ 'model': 'res.company',
+ 'name': company_name,
+ 'unfolded': unfolded,
+ })
+
+ options['journals'] += journals
+
+ else:
+ options['journals'].extend(next(iter(company_journals_map.values()), []))
+
+
+ def _init_options_journals_names(self, options, previous_options, additional_journals_domain=None):
+ all_journals = [
+ journal for journal in options.get('journals', [])
+ if journal['model'] == 'account.journal'
+ ]
+ journals_selected = [j for j in all_journals if j.get('selected')]
+ # 1. Compute the name to display on the widget
+ if options.get('selected_journal_groups'):
+ names_to_display = [options['selected_journal_groups']['name']]
+ elif len(all_journals) == len(journals_selected) or not journals_selected:
+ names_to_display = [_("All Journals")]
+ else:
+ names_to_display = []
+ for journal in options['journals']:
+ if journal.get('model') == 'account.journal' and journal['selected']:
+ names_to_display += [journal['name']]
+
+ # 2. Abbreviate the name
+ max_nb_journals_displayed = 5
+ nb_remaining = len(names_to_display) - max_nb_journals_displayed
+ displayed_names = ', '.join(names_to_display[:max_nb_journals_displayed])
+ if nb_remaining == 1:
+ options['name_journal_group'] = _("%(names)s and one other", names=displayed_names)
+ elif nb_remaining > 1:
+ options['name_journal_group'] = _("%(names)s and %(remaining)s others", names=displayed_names, remaining=nb_remaining)
+ else:
+ options['name_journal_group'] = displayed_names
+
+ @api.model
+ def _get_options_journals(self, options):
+ selected_journals = [
+ journal for journal in options.get('journals', [])
+ if journal['model'] == 'account.journal' and journal['selected']
+ ]
+ if not selected_journals:
+ # If no journal is specifically selected, we actually want to select them all.
+ # This is needed, because some reports will not use ALL available journals and filter by type.
+ # Without getting them from the options, we will use them all, which is wrong.
+ selected_journals = [
+ journal for journal in options.get('journals', [])
+ if journal['model'] == 'account.journal'
+ ]
+ return selected_journals
+
+ @api.model
+ def _get_options_journals_domain(self, options):
+ # Make sure to return an empty array when nothing selected to handle archived journals.
+ selected_journals = self._get_options_journals(options)
+ return selected_journals and [('journal_id', 'in', [j['id'] for j in selected_journals])] or []
+
+ # ####################################################
+ # OPTIONS: USER DEFINED FILTERS ON AML
+ ####################################################
+ def _init_options_aml_ir_filters(self, options, previous_options):
+ options['aml_ir_filters'] = []
+ if not self.filter_aml_ir_filters:
+ return
+
+ ir_filters = self.env['ir.filters'].search([('model_id', '=', 'account.move.line')])
+ if not ir_filters:
+ return
+
+ aml_ir_filters = [{'id': x.id, 'name': x.name, 'selected': False} for x in ir_filters]
+ previous_options_aml_ir_filters = previous_options.get('aml_ir_filters', [])
+ previous_options_filters_map = {filter_item['id']: filter_item for filter_item in previous_options_aml_ir_filters}
+
+ for filter_item in aml_ir_filters:
+ if filter_item['id'] in previous_options_filters_map:
+ filter_item['selected'] = previous_options_filters_map[filter_item['id']]['selected']
+
+ options['aml_ir_filters'] = aml_ir_filters
+
+ @api.model
+ def _get_options_aml_ir_filters(self, options):
+ selected_filters_ids = [
+ filter_item['id']
+ for filter_item in options.get('aml_ir_filters', [])
+ if filter_item['selected']
+ ]
+
+ if not selected_filters_ids:
+ return []
+
+ selected_ir_filters = self.env['ir.filters'].browse(selected_filters_ids)
+ return osv.expression.OR([filter_record._get_eval_domain() for filter_record in selected_ir_filters])
+
+ ####################################################
+ # OPTIONS: date + comparison
+ ####################################################
+
+ @api.model
+ def _get_dates_period(self, date_from, date_to, mode, period_type=None):
+ '''Compute some information about the period:
+ * The name to display on the report.
+ * The period type (e.g. quarter) if not specified explicitly.
+ :param date_from: The starting date of the period.
+ :param date_to: The ending date of the period.
+ :param period_type: The type of the interval date_from -> date_to.
+ :return: A dictionary containing:
+ * date_from * date_to * string * period_type * mode *
+ '''
+ def match(dt_from, dt_to):
+ return (dt_from, dt_to) == (date_from, date_to)
+
+ def get_quarter_name(date_to, date_from):
+ date_to_quarter_string = format_date(self.env, fields.Date.to_string(date_to), date_format='MMM yyyy')
+ date_from_quarter_string = format_date(self.env, fields.Date.to_string(date_from), date_format='MMM')
+ return f"{date_from_quarter_string} - {date_to_quarter_string}"
+
+ string = None
+ # If no date_from or not date_to, we are unable to determine a period
+ if not period_type or period_type == 'custom':
+ date = date_to or date_from
+ company_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(date)
+ if match(company_fiscalyear_dates['date_from'], company_fiscalyear_dates['date_to']):
+ period_type = 'fiscalyear'
+ if company_fiscalyear_dates.get('record'):
+ string = company_fiscalyear_dates['record'].name
+ elif match(*date_utils.get_month(date)):
+ period_type = 'month'
+ elif match(*date_utils.get_quarter(date)):
+ period_type = 'quarter'
+ elif match(*date_utils.get_fiscal_year(date)):
+ period_type = 'year'
+ elif match(date_utils.get_month(date)[0], fields.Date.today()):
+ period_type = 'today'
+ else:
+ period_type = 'custom'
+ elif period_type == 'fiscalyear':
+ date = date_to or date_from
+ company_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(date)
+ record = company_fiscalyear_dates.get('record')
+ string = record and record.name
+ elif period_type == 'tax_period':
+ day, month = self.env.company._get_tax_closing_start_date_attributes(self)
+ months_per_period = self.env.company._get_tax_periodicity_months_delay(self)
+ # We need to format ourselves the date and not switch the period type to the actual period because we do not want to write the actual period in the options but keep tax_period
+ if day == 1 and month == 1 and months_per_period in (1, 3, 12):
+ match months_per_period:
+ case 1:
+ string = format_date(self.env, fields.Date.to_string(date_to), date_format='MMM yyyy')
+ case 3:
+ string = get_quarter_name(date_to, date_from)
+ case 12:
+ string = date_to.strftime('%Y')
+ else:
+ dt_from_str = format_date(self.env, fields.Date.to_string(date_from))
+ dt_to_str = format_date(self.env, fields.Date.to_string(date_to))
+ string = '%s - %s' % (dt_from_str, dt_to_str)
+
+ if not string:
+ fy_day = self.env.company.fiscalyear_last_day
+ fy_month = int(self.env.company.fiscalyear_last_month)
+ if mode == 'single':
+ string = _('As of %s', format_date(self.env, date_to))
+ elif period_type == 'year' or (
+ period_type == 'fiscalyear' and (date_from, date_to) == date_utils.get_fiscal_year(date_to)):
+ string = date_to.strftime('%Y')
+ elif period_type == 'fiscalyear' and (date_from, date_to) == date_utils.get_fiscal_year(date_to, day=fy_day, month=fy_month):
+ string = '%s - %s' % (date_to.year - 1, date_to.year)
+ elif period_type == 'month':
+ string = format_date(self.env, fields.Date.to_string(date_to), date_format='MMM yyyy')
+ elif period_type == 'quarter':
+ string = get_quarter_name(date_to, date_from)
+ else:
+ dt_from_str = format_date(self.env, fields.Date.to_string(date_from))
+ dt_to_str = format_date(self.env, fields.Date.to_string(date_to))
+ string = _('From %(date_from)s\nto %(date_to)s', date_from=dt_from_str, date_to=dt_to_str)
+
+ return {
+ 'string': string,
+ 'period_type': period_type,
+ 'currency_table_period_key': f"{date_from if mode == 'range' else 'None'}_{date_to}",
+ 'mode': mode,
+ 'date_from': date_from and fields.Date.to_string(date_from) or False,
+ 'date_to': fields.Date.to_string(date_to),
+ }
+
+ @api.model
+ def _get_shifted_dates_period(self, options, period_vals, periods, tax_period=False):
+ '''Shift the period.
+ :param period_vals: A dictionary generated by the _get_dates_period method.
+ :param periods: The number of periods we want to move either in the future or the past
+ :return: A dictionary containing:
+ * date_from * date_to * string * period_type *
+ '''
+ period_type = period_vals['period_type']
+ mode = period_vals['mode']
+ date_from = fields.Date.from_string(period_vals['date_from'])
+ date_to = fields.Date.from_string(period_vals['date_to'])
+ if period_type == 'month':
+ date_to = date_from + relativedelta(months=periods)
+ elif period_type == 'quarter':
+ date_to = date_from + relativedelta(months=3 * periods)
+ elif period_type == 'year':
+ date_to = date_from + relativedelta(years=periods)
+ elif period_type in {'custom', 'today'}:
+ date_to = date_from + relativedelta(days=periods)
+
+ if tax_period or 'tax_period' in period_type:
+ month_per_period = self.env.company._get_tax_periodicity_months_delay(self)
+ date_from, date_to = self.env.company._get_tax_closing_period_boundaries(date_from + relativedelta(months=month_per_period * periods), self)
+ return self._get_dates_period(date_from, date_to, mode, period_type='tax_period')
+ if period_type in ('fiscalyear', 'today'):
+ # Don't pass the period_type to _get_dates_period to be able to retrieve the account.fiscal.year record if
+ # necessary.
+ company_fiscalyear_dates = {}
+ # This loop is needed because a fiscal year can be a month, quarter, etc
+ for _ in range(abs(periods)):
+ date_to = (date_from if periods < 0 else date_to) + relativedelta(days=periods / abs(periods))
+ company_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(date_to)
+ if periods < 0:
+ date_from = company_fiscalyear_dates['date_from']
+ else:
+ date_to = company_fiscalyear_dates['date_to']
+
+ return self._get_dates_period(company_fiscalyear_dates['date_from'], company_fiscalyear_dates['date_to'], mode)
+ if period_type in ('month', 'custom'):
+ return self._get_dates_period(*date_utils.get_month(date_to), mode, period_type='month')
+ if period_type == 'quarter':
+ return self._get_dates_period(*date_utils.get_quarter(date_to), mode, period_type='quarter')
+ if period_type == 'year':
+ return self._get_dates_period(*date_utils.get_fiscal_year(date_to), mode, period_type='year')
+ return None
+
+ @api.model
+ def _get_dates_previous_year(self, options, period_vals):
+ '''Shift the period to the previous year.
+ :param options: The report options.
+ :param period_vals: A dictionary generated by the _get_dates_period method.
+ :return: A dictionary containing:
+ * date_from * date_to * string * period_type *
+ '''
+ period_type = period_vals['period_type']
+ mode = period_vals['mode']
+ date_from = fields.Date.from_string(period_vals['date_from'])
+ date_from = date_from - relativedelta(years=1)
+ date_to = fields.Date.from_string(period_vals['date_to'])
+ date_to = date_to - relativedelta(years=1)
+
+ if period_type == 'month':
+ date_from, date_to = date_utils.get_month(date_to)
+
+ return self._get_dates_period(date_from, date_to, mode, period_type=period_type)
+
+ def _init_options_date(self, options, previous_options):
+ """ Initialize the 'date' options key.
+
+ :param options: The current report options to build.
+ :param previous_options: The previous options coming from another report.
+ """
+ date = previous_options.get('date', {})
+ period_date_to = date.get('date_to')
+ period_date_from = date.get('date_from')
+ mode = date.get('mode')
+ date_filter = date.get('filter', 'custom')
+
+ default_filter = self.default_opening_date_filter
+ options_mode = 'range' if self.filter_date_range else 'single'
+ date_from = date_to = period_type = False
+
+ if mode == 'single' and options_mode == 'range':
+ # 'single' date mode to 'range'.
+ if date_filter:
+ date_to = fields.Date.from_string(period_date_to or period_date_from)
+ date_from = self.env.company.compute_fiscalyear_dates(date_to)['date_from']
+ options_filter = 'custom'
+ else:
+ options_filter = default_filter
+ elif mode == 'range' and options_mode == 'single':
+ # 'range' date mode to 'single'.
+ if date_filter == 'custom':
+ date_to = fields.Date.from_string(period_date_to or period_date_from)
+ date_from = date_utils.get_month(date_to)[0]
+ options_filter = 'custom'
+ elif date_filter:
+ options_filter = date_filter
+ else:
+ options_filter = default_filter
+ elif (mode is None or mode == options_mode) and date:
+ # Same date mode.
+ if date_filter == 'custom':
+ if options_mode == 'range':
+ date_from = fields.Date.from_string(period_date_from)
+ date_to = fields.Date.from_string(period_date_to)
+ else:
+ date_to = fields.Date.from_string(period_date_to or period_date_from)
+ date_from = date_utils.get_month(date_to)[0]
+
+ options_filter = 'custom'
+ else:
+ options_filter = date_filter
+ else:
+ # Default.
+ options_filter = default_filter
+
+ # Compute 'date_from' / 'date_to'.
+ if not date_from or not date_to:
+ if options_filter == 'today':
+ date_to = fields.Date.context_today(self)
+ date_from = self.env.company.compute_fiscalyear_dates(date_to)['date_from']
+ period_type = 'today'
+ elif 'month' in options_filter:
+ date_from, date_to = date_utils.get_month(fields.Date.context_today(self))
+ period_type = 'month'
+ elif 'quarter' in options_filter:
+ date_from, date_to = date_utils.get_quarter(fields.Date.context_today(self))
+ period_type = 'quarter'
+ elif 'year' in options_filter:
+ company_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(fields.Date.context_today(self))
+ curr_year = fields.Date.context_today(self).year
+ if company_fiscalyear_dates['date_from'].year < curr_year:
+ company_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(company_fiscalyear_dates['date_to'] + relativedelta(days=1))
+ date_from = company_fiscalyear_dates['date_from']
+ date_to = company_fiscalyear_dates['date_to']
+ elif 'tax_period' in options_filter:
+ if 'custom' in options_filter:
+ base_date = fields.Date.from_string(period_date_to)
+ else:
+ base_date = fields.Date.context_today(self)
+
+ date_from, date_to = self.env.company._get_tax_closing_period_boundaries(base_date, self)
+ period_type = 'tax_period'
+ start_day, start_month = self.env.company._get_tax_closing_start_date_attributes(self)
+ if start_day == 1 and start_month == 1:
+ periods = self.env.company._get_tax_periodicity_months_delay(self)
+ period_type_map = {
+ 1: 'month',
+ 3: 'quarter',
+ 12: 'year',
+ }
+ period_type = period_type_map.get(periods, 'tax_period')
+
+ options['date'] = self._get_dates_period(
+ date_from,
+ date_to,
+ options_mode,
+ period_type=period_type,
+ )
+
+ if any(option in options_filter for option in ['previous', 'next']):
+ new_period = date.get('period', -1 if 'previous' in options_filter else 1)
+ options['date'] = self._get_shifted_dates_period(options, options['date'], new_period, tax_period='tax_period' in options_filter)
+ # This line is useful for the export and tax closing so that the period is set in the options.
+ options['date']['period'] = new_period
+
+ options['date']['filter'] = options_filter if options_filter != 'custom_tax_period' else 'custom'
+
+ def _init_options_comparison(self, options, previous_options):
+ """ Initialize the 'comparison' options key.
+
+ This filter must be loaded after the 'date' filter.
+
+ :param options: The current report options to build.
+ :param previous_options: The previous options coming from another report.
+ """
+ if not self.filter_period_comparison:
+ return
+
+ previous_comparison = previous_options.get('comparison', {})
+ previous_filter = previous_comparison.get('filter')
+
+ period_order = previous_comparison.get('period_order') or 'descending'
+ if previous_filter == 'custom':
+ # Try to adapt the previous 'custom' filter.
+ date_from = previous_comparison.get('date_from')
+ date_to = previous_comparison.get('date_to')
+ number_period = 1
+ options_filter = 'custom'
+ else:
+ # Use the 'date' options.
+ date_from = options['date']['date_from']
+ date_to = options['date']['date_to']
+ number_period = max(previous_comparison.get('number_period', 1) or 0, 0)
+ options_filter = number_period and previous_filter or 'no_comparison'
+
+ options['comparison'] = {
+ 'filter': options_filter,
+ 'number_period': number_period,
+ 'date_from': date_from,
+ 'date_to': date_to,
+ 'periods': [],
+ 'period_order': period_order,
+ }
+
+ date_from_obj = fields.Date.from_string(date_from)
+ date_to_obj = fields.Date.from_string(date_to)
+
+ if options_filter == 'custom':
+ options['comparison']['periods'].append(self._get_dates_period(
+ date_from_obj,
+ date_to_obj,
+ options['date']['mode'],
+ ))
+ elif options_filter in ('previous_period', 'same_last_year'):
+ previous_period = options['date']
+ for dummy in range(0, number_period):
+ if options_filter == 'previous_period':
+ period_vals = self._get_shifted_dates_period(options, previous_period, -1)
+ elif options_filter == 'same_last_year':
+ period_vals = self._get_dates_previous_year(options, previous_period)
+ else:
+ date_from_obj = fields.Date.from_string(date_from)
+ date_to_obj = fields.Date.from_string(date_to)
+ period_vals = self._get_dates_period(date_from_obj, date_to_obj, previous_period['mode'])
+ options['comparison']['periods'].append(period_vals)
+ previous_period = period_vals
+
+ if len(options['comparison']['periods']) > 0:
+ options['comparison'].update(options['comparison']['periods'][0])
+
+ def _init_options_column_percent_comparison(self, options, previous_options):
+ if options['selected_horizontal_group_id'] is None:
+ if self.filter_growth_comparison and len(options['columns']) == 2 and len(options.get('comparison', {}).get('periods', [])) == 1:
+ options['column_percent_comparison'] = 'growth'
+
+ if self.filter_budgets and any(budget['selected'] for budget in options.get('budgets', [])):
+ options['column_percent_comparison'] = 'budget'
+
+ def _get_options_date_domain(self, options, date_scope):
+ date_from, date_to = self._get_date_bounds_info(options, date_scope)
+
+ scope_domain = [('date', '<=', date_to)]
+ if date_from:
+ scope_domain += [('date', '>=', date_from)]
+
+ return scope_domain
+
+ def _get_date_bounds_info(self, options, date_scope):
+ # Default values (the ones from 'strict_range')
+ date_to = options['date']['date_to']
+ date_from = options['date']['date_from'] if options['date']['mode'] == 'range' else None
+
+ if date_scope == 'from_beginning':
+ date_from = None
+
+ elif date_scope == 'to_beginning_of_period':
+ date_tmp = fields.Date.from_string(date_from or date_to) - relativedelta(days=1)
+ date_to = date_tmp.strftime('%Y-%m-%d')
+ date_from = None
+
+ elif date_scope == 'from_fiscalyear':
+ date_tmp = fields.Date.from_string(date_to)
+ date_tmp = self.env.company.compute_fiscalyear_dates(date_tmp)['date_from']
+ date_from = date_tmp.strftime('%Y-%m-%d')
+
+ elif date_scope == 'to_beginning_of_fiscalyear':
+ date_tmp = fields.Date.from_string(date_to)
+ date_tmp = self.env.company.compute_fiscalyear_dates(date_tmp)['date_from'] - relativedelta(days=1)
+ date_to = date_tmp.strftime('%Y-%m-%d')
+ date_from = None
+
+ elif date_scope == 'previous_tax_period':
+ eve_of_date_from = fields.Date.from_string(options['date']['date_from']) - relativedelta(days=1)
+ date_from, date_to = self.env.company._get_tax_closing_period_boundaries(eve_of_date_from, self)
+
+ return date_from, date_to
+
+
+ ####################################################
+ # OPTIONS: analytic filter
+ ####################################################
+
+ def _init_options_analytic(self, options, previous_options):
+ if not self.filter_analytic:
+ return
+
+
+ if self.env.user.has_group('analytic.group_analytic_accounting'):
+ previous_analytic_accounts = previous_options.get('analytic_accounts', [])
+ analytic_account_ids = [int(x) for x in previous_analytic_accounts]
+ selected_analytic_accounts = self.env['account.analytic.account'].with_context(active_test=False).search([('id', 'in', analytic_account_ids)])
+
+ options['display_analytic'] = True
+ options['analytic_accounts'] = selected_analytic_accounts.ids
+ options['selected_analytic_account_names'] = selected_analytic_accounts.mapped('name')
+
+ ####################################################
+ # OPTIONS: partners
+ ####################################################
+
+ def _init_options_partner(self, options, previous_options):
+ if not self.filter_partner:
+ return
+
+ options['partner'] = True
+ previous_partner_ids = previous_options.get('partner_ids') or []
+ options['partner_categories'] = previous_options.get('partner_categories') or []
+
+ selected_partner_ids = [int(partner) for partner in previous_partner_ids]
+ # search instead of browse so that record rules apply and filter out the ones the user does not have access to
+ selected_partners = selected_partner_ids and self.env['res.partner'].with_context(active_test=False).search([('id', 'in', selected_partner_ids)]) or self.env['res.partner']
+ options['selected_partner_ids'] = selected_partners.filtered('name').mapped('name')
+ options['partner_ids'] = selected_partners.ids
+
+ selected_partner_category_ids = [int(category) for category in options['partner_categories']]
+ selected_partner_categories = selected_partner_category_ids and self.env['res.partner.category'].browse(selected_partner_category_ids) or self.env['res.partner.category']
+ options['selected_partner_categories'] = selected_partner_categories.mapped('name')
+
+ @api.model
+ def _get_options_partner_domain(self, options):
+ domain = []
+ if options.get('partner_ids'):
+ partner_ids = [int(partner) for partner in options['partner_ids']]
+ domain.append(('partner_id', 'in', partner_ids))
+ if options.get('partner_categories'):
+ partner_category_ids = [int(category) for category in options['partner_categories']]
+ domain.append(('partner_id.category_id', 'in', partner_category_ids))
+ return domain
+
+ ####################################################
+ # OPTIONS: all_entries
+ ####################################################
+
+ @api.model
+ def _get_options_all_entries_domain(self, options):
+ if not options.get('all_entries'):
+ return [('parent_state', '=', 'posted')]
+ else:
+ return [('parent_state', '!=', 'cancel')]
+
+ ####################################################
+ # OPTIONS: not reconciled entries
+ ####################################################
+ def _init_options_reconciled(self, options, previous_options):
+ if self.filter_unreconciled:
+ options['unreconciled'] = previous_options.get('unreconciled', False)
+ else:
+ options['unreconciled'] = False
+
+ @api.model
+ def _get_options_unreconciled_domain(self, options):
+ if options.get('unreconciled'):
+ return ['&', ('full_reconcile_id', '=', False), ('balance', '!=', '0')]
+ return []
+
+ ####################################################
+ # OPTIONS: account_type
+ ####################################################
+
+ def _init_options_account_type(self, options, previous_options):
+ '''
+ Initialize a filter based on the account_type of the line (trade/non trade, payable/receivable).
+ Selects a name to display according to the selections.
+ The group display name is selected according to the display name of the options selected.
+ '''
+ if self.filter_account_type in ('disabled', False):
+ return
+
+ account_type_list = [
+ {'id': 'trade_receivable', 'name': _("Receivable"), 'selected': True},
+ {'id': 'non_trade_receivable', 'name': _("Non Trade Receivable"), 'selected': False},
+ {'id': 'trade_payable', 'name': _("Payable"), 'selected': True},
+ {'id': 'non_trade_payable', 'name': _("Non Trade Payable"), 'selected': False},
+ ]
+
+ if self.filter_account_type == 'receivable':
+ options['account_type'] = account_type_list[:2]
+ elif self.filter_account_type == 'payable':
+ options['account_type'] = account_type_list[2:]
+ else:
+ options['account_type'] = account_type_list
+
+ if previous_options.get('account_type'):
+ previously_selected_ids = {x['id'] for x in previous_options['account_type'] if x.get('selected')}
+ for opt in options['account_type']:
+ opt['selected'] = opt['id'] in previously_selected_ids
+
+
+ @api.model
+ def _get_options_account_type_domain(self, options):
+ all_domains = []
+ selected_domains = []
+ if not options.get('account_type') or len(options.get('account_type')) == 0:
+ return []
+ for opt in options.get('account_type', []):
+ if opt['id'] == 'trade_receivable':
+ domain = [('account_id.non_trade', '=', False), ('account_id.account_type', '=', 'asset_receivable')]
+ elif opt['id'] == 'trade_payable':
+ domain = [('account_id.non_trade', '=', False), ('account_id.account_type', '=', 'liability_payable')]
+ elif opt['id'] == 'non_trade_receivable':
+ domain = [('account_id.non_trade', '=', True), ('account_id.account_type', '=', 'asset_receivable')]
+ elif opt['id'] == 'non_trade_payable':
+ domain = [('account_id.non_trade', '=', True), ('account_id.account_type', '=', 'liability_payable')]
+ if opt['selected']:
+ selected_domains.append(domain)
+ all_domains.append(domain)
+ return osv.expression.OR(selected_domains or all_domains)
+
+ ####################################################
+ # OPTIONS: order column
+ ####################################################
+
+ @api.model
+ def _init_options_order_column(self, options, previous_options):
+ # options['order_column'] is in the form {'expression_label': expression label of the column to order, 'direction': the direction order ('ASC' or 'DESC')}
+ options['order_column'] = None
+
+ previous_value = previous_options and previous_options.get('order_column')
+ if previous_value:
+ for col in options['columns']:
+ if col['sortable'] and col['expression_label'] == previous_value['expression_label']:
+ options['order_column'] = previous_value
+ break
+
+ ####################################################
+ # OPTIONS: hierarchy
+ ####################################################
+
+ def _init_options_hierarchy(self, options, previous_options):
+ company_ids = self.get_report_company_ids(options)
+ if self.filter_hierarchy != 'never' and self.env['account.group'].search_count(self.env['account.group']._check_company_domain(company_ids), limit=1):
+ options['display_hierarchy_filter'] = True
+ if 'hierarchy' in previous_options:
+ options['hierarchy'] = previous_options['hierarchy']
+ else:
+ options['hierarchy'] = self.filter_hierarchy == 'by_default'
+ else:
+ options['hierarchy'] = False
+ options['display_hierarchy_filter'] = False
+
+ @api.model
+ def _create_hierarchy(self, lines, options):
+ """Compute the hierarchy based on account groups when the option is activated.
+
+ The option is available only when there are account.group for the company.
+ It should be called when before returning the lines to the client/templater.
+ The lines are the result of _get_lines(). If there is a hierarchy, it is left
+ untouched, only the lines related to an account.account are put in a hierarchy
+ according to the account.group's and their prefixes.
+ """
+ if not lines:
+ return lines
+
+ def get_account_group_hierarchy(account):
+ # Create codes path in the hierarchy based on account.
+ groups = self.env['account.group']
+ if account.group_id:
+ group = account.group_id
+ while group:
+ groups += group
+ group = group.parent_id
+ return list(groups.sorted(reverse=True))
+
+ def create_hierarchy_line(account_group, column_totals, level, parent_id):
+ line_id = self._get_generic_line_id('account.group', account_group.id if account_group else None, parent_line_id=parent_id)
+ unfolded = line_id in options.get('unfolded_lines') or options['unfold_all']
+ name = account_group.display_name if account_group else _('(No Group)')
+ columns = []
+ for column_total, column in zip(column_totals, options['columns']):
+ columns.append(self._build_column_dict(column_total, column, options=options))
+ return {
+ 'id': line_id,
+ 'name': name,
+ 'title_hover': name,
+ 'unfoldable': True,
+ 'unfolded': unfolded,
+ 'level': level,
+ 'parent_id': parent_id,
+ 'columns': columns,
+ }
+
+ def compute_group_totals(line, group=None):
+ result = []
+ for total, column in zip(hierarchy[group]['totals'], line['columns']):
+ value = column.get('no_format')
+ if isinstance(total, float) and isinstance(value, (int, float)):
+ result.append(total + value)
+ else:
+ result.append('')
+ return result
+
+ def render_lines(account_groups, current_level, parent_line_id, skip_no_group=True):
+ to_treat = [(current_level, parent_line_id, group) for group in account_groups.sorted()]
+
+ if None in hierarchy and not skip_no_group:
+ to_treat.append((current_level, parent_line_id, None))
+
+ while to_treat:
+ level_to_apply, parent_id, group = to_treat.pop(0)
+ group_data = hierarchy[group]
+ hierarchy_line = create_hierarchy_line(group, group_data['totals'], level_to_apply, parent_id)
+ new_lines.append(hierarchy_line)
+ treated_child_groups = self.env['account.group']
+
+ for account_line in group_data['lines']:
+ for child_group in group_data['child_groups']:
+ if child_group not in treated_child_groups and child_group['code_prefix_end'] < account_line['name']:
+ render_lines(child_group, hierarchy_line['level'] + 1, hierarchy_line['id'])
+ treated_child_groups += child_group
+
+ markup, model, account_id = self._parse_line_id(account_line['id'])[-1]
+ account_line_id = self._get_generic_line_id(model, account_id, markup=markup, parent_line_id=hierarchy_line['id'])
+ account_line.update({
+ 'id': account_line_id,
+ 'parent_id': hierarchy_line['id'],
+ 'level': hierarchy_line['level'] + 1,
+ })
+ new_lines.append(account_line)
+
+ for child_line in account_line_children_map[account_id]:
+ markup, model, res_id = self._parse_line_id(child_line['id'])[-1]
+ child_line.update({
+ 'id': self._get_generic_line_id(model, res_id, markup=markup, parent_line_id=account_line_id),
+ 'parent_id': account_line_id,
+ 'level': account_line['level'] + 1,
+ })
+ new_lines.append(child_line)
+
+ to_treat = [
+ (level_to_apply + 1, hierarchy_line['id'], child_group)
+ for child_group
+ in group_data['child_groups'].sorted()
+ if child_group not in treated_child_groups
+ ] + to_treat
+
+ def create_hierarchy_dict():
+ return defaultdict(lambda: {
+ 'lines': [],
+ 'totals': [('' if column.get('figure_type') == 'string' else 0.0) for column in options['columns']],
+ 'child_groups': self.env['account.group'],
+ })
+
+ # Precompute the account groups of the accounts in the report
+ account_ids = []
+ for line in lines:
+ markup, res_model, model_id = self._parse_line_id(line['id'])[-1]
+ if res_model == 'account.account':
+ account_ids.append(model_id)
+ self.env['account.account'].browse(account_ids).group_id
+
+ new_lines, total_lines = [], []
+
+ # root_line_id is the id of the parent line of the lines we want to render
+ root_line_id = self._build_parent_line_id(self._parse_line_id(lines[0]['id'])) or None
+ last_account_line_id = account_id = None
+ current_level = 0
+ account_line_children_map = defaultdict(list)
+ account_groups = self.env['account.group']
+ root_account_groups = self.env['account.group']
+ hierarchy = create_hierarchy_dict()
+
+ for line in lines:
+ markup, res_model, model_id = self._parse_line_id(line['id'])[-1]
+
+ # Account lines are used as the basis for the computation of the hierarchy.
+ if res_model == 'account.account':
+ last_account_line_id = line['id']
+ current_level = line['level']
+ account_id = model_id
+ account = self.env[res_model].browse(account_id)
+ account_groups = get_account_group_hierarchy(account)
+
+ if not account_groups:
+ hierarchy[None]['lines'].append(line)
+ hierarchy[None]['totals'] = compute_group_totals(line)
+ else:
+ for i, group in enumerate(account_groups):
+ if i == 0:
+ hierarchy[group]['lines'].append(line)
+ if i == len(account_groups) - 1 and group not in root_account_groups:
+ root_account_groups += group
+ if group.parent_id and group not in hierarchy[group.parent_id]['child_groups']:
+ hierarchy[group.parent_id]['child_groups'] += group
+
+ hierarchy[group]['totals'] = compute_group_totals(line, group=group)
+
+ # This is not an account line, so we check to see if it is a descendant of the last account line.
+ # If so, it is added to the mapping of the lines that are related to this account.
+ elif last_account_line_id and line.get('parent_id', '').startswith(last_account_line_id):
+ account_line_children_map[account_id].append(line)
+
+ # This is a total line that is not linked to an account. It is saved in order to be added at the end.
+ elif markup == 'total':
+ total_lines.append(line)
+
+ # This line ends the scope of the current hierarchy and is (possibly) the root of a new hierarchy.
+ # We render the current hierarchy and set up to build a new hierarchy
+ else:
+ render_lines(root_account_groups, current_level, root_line_id, skip_no_group=False)
+
+ new_lines.append(line)
+
+ # Reset the hierarchy-related variables for a new hierarchy
+ root_line_id = line['id']
+ last_account_line_id = account_id = None
+ current_level = 0
+ account_line_children_map = defaultdict(list)
+ root_account_groups = self.env['account.group']
+ account_groups = self.env['account.group']
+ hierarchy = create_hierarchy_dict()
+
+ render_lines(root_account_groups, current_level, root_line_id, skip_no_group=False)
+
+ return new_lines + total_lines
+
+ ####################################################
+ # OPTIONS: prefix groups threshold
+ ####################################################
+
+ def _init_options_prefix_groups_threshold(self, options, previous_options):
+ previous_threshold = previous_options.get('prefix_groups_threshold')
+ options['prefix_groups_threshold'] = self.prefix_groups_threshold
+
+ ####################################################
+ # OPTIONS: fiscal position (multi vat)
+ ####################################################
+
+ def _init_options_fiscal_position(self, options, previous_options):
+ if self.filter_fiscal_position and self.country_id and len(options['companies']) == 1:
+ vat_fpos_domain = [
+ *self.env['account.fiscal.position']._check_company_domain(next(comp_id for comp_id in self.get_report_company_ids(options))),
+ ('foreign_vat', '!=', False),
+ ]
+
+ vat_fiscal_positions = self.env['account.fiscal.position'].search([
+ *vat_fpos_domain,
+ ('country_id', '=', self.country_id.id),
+ ])
+
+ options['allow_domestic'] = self.env.company.account_fiscal_country_id == self.country_id
+
+ accepted_prev_vals = {*vat_fiscal_positions.ids}
+ if options['allow_domestic']:
+ accepted_prev_vals.add('domestic')
+ if len(vat_fiscal_positions) > (0 if options['allow_domestic'] else 1) or not accepted_prev_vals:
+ accepted_prev_vals.add('all')
+
+ if previous_options.get('fiscal_position') in accepted_prev_vals:
+ # Legit value from previous options; keep it
+ options['fiscal_position'] = previous_options['fiscal_position']
+ elif len(vat_fiscal_positions) == 1 and not options['allow_domestic']:
+ # Only one foreign fiscal position: always select it, menu will be hidden
+ options['fiscal_position'] = vat_fiscal_positions.id
+ else:
+ # Multiple possible values; by default, show the values of the company's area (if allowed), or everything
+ options['fiscal_position'] = options['allow_domestic'] and 'domestic' or 'all'
+ else:
+ # No country, or we're displaying data from several companies: disable fiscal position filtering
+ vat_fiscal_positions = []
+ options['allow_domestic'] = True
+ previous_fpos = previous_options.get('fiscal_position')
+ options['fiscal_position'] = previous_fpos if previous_fpos in ('all', 'domestic') else 'all'
+
+ options['available_vat_fiscal_positions'] = [{
+ 'id': fiscal_pos.id,
+ 'name': fiscal_pos.name,
+ 'company_id': fiscal_pos.company_id.id,
+ } for fiscal_pos in vat_fiscal_positions]
+
+ def _get_options_fiscal_position_domain(self, options):
+ def get_foreign_vat_tax_tag_extra_domain(fiscal_position=None):
+ # We want to gather any line wearing a tag, whatever its fiscal position.
+ # Nevertheless, if a country is using the same report for several regions (e.g. India) we need to exclude
+ # the lines from the other regions to avoid reporting numbers that don't belong to the current region.
+ fp_ids_to_exclude = self.env['account.fiscal.position'].search([
+ ('id', '!=', fiscal_position.id if fiscal_position else False),
+ ('foreign_vat', '!=', False),
+ ('country_id', '=', self.country_id.id),
+ ]).ids
+
+ if fiscal_position and fiscal_position.country_id == self.env.company.account_fiscal_country_id:
+ # We are looking for a fiscal position inside our country which means we need to exclude
+ # the local fiscal position which is represented by `False`.
+ fp_ids_to_exclude.append(False)
+
+ return [
+ ('tax_tag_ids.country_id', '=', self.country_id.id),
+ ('move_id.fiscal_position_id', 'not in', fp_ids_to_exclude),
+ ]
+
+ fiscal_position_opt = options.get('fiscal_position')
+
+ if fiscal_position_opt == 'domestic':
+ domain = [
+ '|',
+ ('move_id.fiscal_position_id', '=', False),
+ ('move_id.fiscal_position_id.foreign_vat', '=', False),
+ ]
+ tax_tag_domain = get_foreign_vat_tax_tag_extra_domain()
+ return osv.expression.OR([domain, tax_tag_domain])
+
+ if isinstance(fiscal_position_opt, int):
+ # It's a fiscal position id
+ domain = [('move_id.fiscal_position_id', '=', fiscal_position_opt)]
+ fiscal_position = self.env['account.fiscal.position'].browse(fiscal_position_opt)
+ tax_tag_domain = get_foreign_vat_tax_tag_extra_domain(fiscal_position)
+ return osv.expression.OR([domain, tax_tag_domain])
+
+ # 'all', or option isn't specified
+ return []
+
+ ####################################################
+ # OPTIONS: MULTI COMPANY
+ ####################################################
+
+ def _init_options_companies(self, options, previous_options):
+ if previous_options.get('forced_companies'):
+ options['forced_companies'] = previous_options['forced_companies']
+ companies = self.env.company.browse(previous_options['forced_companies'])
+ elif self.filter_multi_company == 'selector':
+ companies = self.env.companies
+ elif self.filter_multi_company == 'tax_units':
+ companies = self._multi_company_tax_units_init_options(options, previous_options=previous_options)
+ else:
+ # Multi-company is disabled for this report ; only accept the sub-branches of the current company from the selector
+ companies = self.env.company._accessible_branches()
+
+ options['companies'] = [{'name': c.name, 'id': c.id, 'currency_id': c.currency_id.id} for c in companies]
+
+ def _multi_company_tax_units_init_options(self, options, previous_options):
+ """ Initializes the companies option for reports configured to compute it from tax units.
+ """
+ tax_units_domain = [('company_ids', 'in', self.env.company.id)]
+
+ if self.country_id:
+ tax_units_domain.append(('country_id', '=', self.country_id.id))
+
+ available_tax_units = self.env['account.tax.unit'].search(tax_units_domain)
+
+ # Filter available units to only consider the ones whose companies are all accessible to the user
+ available_tax_units = available_tax_units.filtered(
+ lambda x: all(unit_company in self.env.user.company_ids for unit_company in x.sudo().company_ids)
+ # sudo() to avoid bypassing companies the current user does not have access to
+ )
+
+ options['available_tax_units'] = [{
+ 'id': tax_unit.id,
+ 'name': tax_unit.name,
+ 'company_ids': tax_unit.company_ids.ids
+ } for tax_unit in available_tax_units]
+
+ # Available tax_unit option values that are currently allowed by the company selector
+ # A js hack ensures the page is reloaded and the selected companies modified
+ # when clicking on a tax unit option in the UI, so we don't need to worry about that here.
+ companies_authorized_tax_unit_opt = {
+ *(available_tax_units.filtered(lambda x: set(self.env.companies) == set(x.company_ids)).ids),
+ 'company_only'
+ }
+
+ if previous_options.get('tax_unit') in companies_authorized_tax_unit_opt:
+ options['tax_unit'] = previous_options['tax_unit']
+
+ else:
+ # No tax_unit gotten from previous options; initialize it
+ # A tax_unit will be set by default if only one tax unit is available for the report
+ # (which should always be true for non-generic reports, which have a country), and the companies of
+ # the unit are the only ones currently selected.
+ if companies_authorized_tax_unit_opt == {'company_only'}:
+ options['tax_unit'] = 'company_only'
+ elif len(available_tax_units) == 1 and available_tax_units[0].id in companies_authorized_tax_unit_opt:
+ options['tax_unit'] = available_tax_units[0].id
+ else:
+ options['tax_unit'] = 'company_only'
+
+ # Finally initialize multi_company filter
+ if options['tax_unit'] == 'company_only':
+ companies = self.env.company._get_branches_with_same_vat(accessible_only=True)
+ else:
+ tax_unit = available_tax_units.filtered(lambda x: x.id == options['tax_unit'])
+ companies = tax_unit.company_ids
+
+ return companies
+
+ ####################################################
+ # OPTIONS: MULTI CURRENCY
+ ####################################################
+ def _init_options_multi_currency(self, options, previous_options):
+ options['multi_currency'] = (
+ any([company.get('currency_id') != options['companies'][0].get('currency_id') for company in options['companies']])
+ or any([column.figure_type != 'monetary' for column in self.column_ids])
+ or any(expression.figure_type and expression.figure_type != 'monetary' for expression in self.line_ids.expression_ids)
+ )
+
+ ####################################################
+ # OPTIONS: CURRENCY TABLE
+ ####################################################
+ def _init_options_currency_table(self, options, previous_options):
+ companies = self.env['res.company'].browse(self.get_report_company_ids(options))
+ table_type = 'monocurrency' if self.env['res.currency']._check_currency_table_monocurrency(companies) else self.currency_translation
+
+ periods = {}
+ for col_group in options['column_groups'].values():
+ if col_group['forced_options'].get('no_impact_on_currency_table'):
+ # This key is used to ignore the colum group in the creation of the periods list for
+ # the currency table. This way, its dates won't influence. It's useful for groups corresponding
+ # to an initial balance of some sorts, like on the Trial Balance.
+ continue
+
+ col_group_date = col_group['forced_options'].get('date', options['date'])
+
+ col_group_date_from = col_group_date['date_from'] if col_group_date['mode'] == 'range' else None
+ col_group_date_to = col_group_date['date_to']
+ period_key = col_group_date['currency_table_period_key']
+
+ already_present_period = periods.get(period_key)
+ if already_present_period:
+ # This can happen for custom reports, needing to enforce the same rates on multiple column groups with
+ # different dates (e.g. Trial Balance). In that case, the date_from and date_to of the currency table period must respectively
+ # be the lowest and highest among those groups.
+ if col_group_date_from and already_present_period['from'] > col_group_date_from:
+ already_present_period['from'] = col_group_date_from
+
+ if already_present_period['to'] < col_group_date_to:
+ already_present_period['to'] = col_group_date_to
+ else:
+ periods[period_key] = {
+ 'from': col_group_date_from,
+ 'to': col_group_date_to,
+ }
+
+ options['currency_table'] = {'type': table_type, 'periods': periods}
+
+ @api.model
+ def _currency_table_apply_rate(self, value: SQL) -> SQL:
+ """ Returns an SQL term to use in a SELECT statement converting the value passed as parameter into the current company's currency, using the
+ currency table (which must be joined in the query as well ; using _currency_table_aml_join for account.move.line, or _get_currency_table for
+ other more specific uses).
+ """
+ return SQL("(%(value)s) * COALESCE(account_currency_table.rate, 1)", value=value)
+
+ @api.model
+ def _currency_table_aml_join(self, options, aml_alias=SQL('account_move_line')) -> SQL:
+ """ Returns the JOIN condition to the currency table in a query needing to use it to convert aml balances from one currency to another.
+ """
+ if options['currency_table']['type'] == 'cta':
+ return SQL(
+ """
+ JOIN account_account aml_ct_account
+ ON aml_ct_account.id = %(aml_table)s.account_id
+ LEFT JOIN %(currency_table)s
+ ON %(aml_table)s.company_id = account_currency_table.company_id
+ AND (
+ account_currency_table.rate_type = CASE
+ WHEN aml_ct_account.account_type LIKE %(equity_prefix)s THEN 'historical'
+ WHEN aml_ct_account.account_type LIKE ANY (ARRAY[%(income_prefix)s, %(expense_prefix)s, 'equity_unaffected']) THEN 'average'
+ ELSE 'current'
+ END
+ )
+ AND (account_currency_table.date_from IS NULL OR account_currency_table.date_from <= %(aml_table)s.date)
+ AND (account_currency_table.date_next IS NULL OR account_currency_table.date_next > %(aml_table)s.date)
+ AND (account_currency_table.period_key = %(period_key)s OR account_currency_table.period_key IS NULL)
+ """,
+ aml_table=aml_alias,
+ equity_prefix='equity%',
+ income_prefix='income%',
+ expense_prefix='expense%',
+ currency_table=self._get_currency_table(options),
+ period_key=options['date']['currency_table_period_key'],
+ )
+
+ return SQL(
+ """
+ JOIN %(currency_table)s
+ ON %(aml_table)s.company_id = account_currency_table.company_id
+ AND (account_currency_table.period_key = %(period_key)s OR account_currency_table.period_key IS NULL)
+ """,
+ aml_table=aml_alias,
+ currency_table=self._get_currency_table(options),
+ period_key=options['date']['currency_table_period_key'],
+ )
+
+ @api.model
+ def _get_currency_table(self, options) -> SQL:
+ """ Returns the currency table table definition to be injected in the JOIN condition of an SQL query needing to use it.
+ """
+ if options['currency_table']['type'] == 'monocurrency':
+ companies = self.env['res.company'].browse(self.get_report_company_ids(options))
+ return self.env['res.currency']._get_monocurrency_currency_table_sql(companies, use_cta_rates=options['currency_table']['type'] == 'cta')
+
+ return SQL('account_currency_table')
+
+ def _init_currency_table(self, options):
+ """ Creates the currency table temporary table if necessary, using the provided options to compute its periods.
+ This function should always be called before any query invovlving the currency table is run.
+ """
+ if options['currency_table']['type'] != 'monocurrency':
+ companies = self.env['res.company'].browse(self.get_report_company_ids(options))
+
+ self.env['res.currency']._create_currency_table(
+ companies,
+ [(period_key, period['from'], period['to']) for period_key, period in options['currency_table']['periods'].items()],
+ use_cta_rates=options['currency_table']['type'] == 'cta',
+ )
+
+ ####################################################
+ # OPTIONS: ROUNDING UNIT
+ ####################################################
+ def _init_options_rounding_unit(self, options, previous_options):
+ default = 'decimals'
+ options['rounding_unit'] = previous_options.get('rounding_unit', default)
+ options['rounding_unit_names'] = self._get_rounding_unit_names()
+
+ def _get_rounding_unit_names(self):
+ currency_symbol = self.env.company.currency_id.symbol
+ currency_name = self.env.company.currency_id.name
+
+ rounding_unit_names = [
+ ('decimals', (f'.{currency_symbol}', '')),
+ ('units', (f'{currency_symbol}', '')),
+ ('thousands', (f'K{currency_symbol}', _('Amounts in Thousands'))),
+ ('millions', (f'M{currency_symbol}', _('Amounts in Millions'))),
+ ]
+
+ if currency_name in CURRENCIES_USING_LAKH:
+ rounding_unit_names.insert(3, ('lakhs', (f'L{currency_symbol}', _('Amounts in Lakhs'))))
+
+ return dict(rounding_unit_names)
+
+ # ####################################################
+ # OPTIONS: ALL ENTRIES
+ ####################################################
+ def _init_options_all_entries(self, options, previous_options):
+ if self.filter_show_draft:
+ options['all_entries'] = previous_options.get('all_entries', False)
+ else:
+ options['all_entries'] = False
+
+ ####################################################
+ # OPTIONS: UNFOLDED LINES
+ ####################################################
+ def _init_options_unfolded(self, options, previous_options):
+ options['unfold_all'] = self.filter_unfold_all and previous_options.get('unfold_all', False)
+
+ previous_section_source_id = previous_options.get('sections_source_id')
+ if not previous_section_source_id or previous_section_source_id == options['sections_source_id']:
+ # Only keep the unfolded lines if they belong to the same report or a section of the same report
+ options['unfolded_lines'] = previous_options.get('unfolded_lines', [])
+ else:
+ options['unfolded_lines'] = []
+
+ ####################################################
+ # OPTIONS: HIDE LINE AT 0
+ ####################################################
+ def _init_options_hide_0_lines(self, options, previous_options):
+ if self.filter_hide_0_lines != 'never':
+ previous_val = previous_options.get('hide_0_lines')
+ if previous_val is not None:
+ options['hide_0_lines'] = previous_val
+ else:
+ options['hide_0_lines'] = self.filter_hide_0_lines == 'by_default'
+ else:
+ options['hide_0_lines'] = False
+
+ def _filter_out_0_lines(self, lines):
+ """ Returns a list containing all lines that are not zero or that are parent to non-zero lines.
+ Can be used to ensure printed report does not include 0 lines, when hide_0_lines is toggled.
+ """
+ lines_to_hide = set() # contain line ids to remove from lines
+ has_visible_children = set() # contain parent line ids
+ # Traverse lines in reverse to keep track of visible parent lines required by children lines
+ for line in reversed(lines):
+ is_zero_line = all(col.get('figure_type') not in NUMBER_FIGURE_TYPES or col.get('is_zero', True) for col in line['columns'])
+ if is_zero_line and line['id'] not in has_visible_children:
+ lines_to_hide.add(line['id'])
+ if line.get('parent_id') and line['id'] not in lines_to_hide:
+ has_visible_children.add(line['parent_id'])
+ return list(filter(lambda x: x['id'] not in lines_to_hide, lines))
+
+ ####################################################
+ # OPTIONS: HORIZONTAL GROUP
+ ####################################################
+ def _init_options_horizontal_groups(self, options, previous_options):
+ options['available_horizontal_groups'] = [
+ {
+ 'id': horizontal_group.id,
+ 'name': horizontal_group.name,
+ }
+ for horizontal_group in self.horizontal_group_ids
+ ]
+ previous_selected = previous_options.get('selected_horizontal_group_id')
+ options['selected_horizontal_group_id'] = previous_selected if previous_selected in self.horizontal_group_ids.ids else None
+
+ ####################################################
+ # OPTIONS: SEARCH BAR
+ ####################################################
+ def _init_options_search_bar(self, options, previous_options):
+ if self.search_bar:
+ options['search_bar'] = True
+ if 'default_filter_accounts' not in self._context and 'filter_search_bar' in previous_options:
+ options['filter_search_bar'] = previous_options['filter_search_bar']
+
+ ####################################################
+ # OPTIONS: COLUMN HEADERS
+ ####################################################
+
+ def _init_options_column_headers(self, options, previous_options):
+ # Prepare column headers, in case the order of the comparison is ascending we reverse the order of the columns
+ all_comparison_date_vals = ([options['date']] + options.get('comparison', {}).get('periods', []))
+ if options.get('comparison') and options['comparison']['period_order'] == 'ascending':
+ all_comparison_date_vals = all_comparison_date_vals[::-1]
+
+ column_headers = [
+ [
+ {
+ 'name': comparison_date_vals['string'],
+ 'forced_options': {'date': comparison_date_vals},
+ }
+ for comparison_date_vals in all_comparison_date_vals
+ ], # First level always consists of date comparison. Horizontal groupby are done on following levels.
+ ]
+
+ # Handle horizontal groups
+ selected_horizontal_group_id = options.get('selected_horizontal_group_id')
+ if selected_horizontal_group_id:
+ horizontal_group = self.env['account.report.horizontal.group'].browse(selected_horizontal_group_id)
+
+ for field_name, records in horizontal_group._get_header_levels_data():
+ header_level = [
+ {
+ 'name': record.display_name,
+ 'horizontal_groupby_element': {field_name: record.id},
+ }
+ for record in records
+ ]
+ column_headers.append(header_level)
+ else:
+ # Insert budget column headers if needed
+ selected_budgets = [budget for budget in options.get('budgets', []) if budget['selected']]
+ if selected_budgets:
+ budget_headers = [{
+ 'name': '',
+ 'forced_options': {
+ 'budget_base': True,
+ }
+ }]
+
+ for budget in selected_budgets:
+ # Add budget amount column
+ budget_headers.append({
+ 'name': budget['name'],
+ 'forced_options': {
+ 'compute_budget': budget['id'],
+ },
+ 'colspan': 1,
+ })
+ if len(self.column_ids.filtered(lambda column: column.figure_type == 'monetary')) == 1:
+ # Add budget percentage column (only if one column in the report)
+ budget_headers.append({
+ 'name': "%",
+ 'forced_options': {
+ 'budget_percentage': budget['id'],
+ },
+ 'colspan': 1,
+ })
+
+ column_headers.append(budget_headers)
+
+ options['column_headers'] = column_headers
+
+ ####################################################
+ # OPTIONS: COLUMNS
+ ####################################################
+ def _init_options_columns(self, options, previous_options):
+ default_group_vals = {'horizontal_groupby_element': {}, 'forced_options': {}}
+ all_column_group_vals_in_order = self._generate_columns_group_vals_recursively(options['column_headers'], default_group_vals)
+
+ columns, column_groups = self._build_columns_from_column_group_vals(options, all_column_group_vals_in_order)
+
+ options['columns'] = columns
+ options['column_groups'] = column_groups
+
+ # Debug column is only shown when there is a single column group, so that we can display all the subtotals of the line in a clear way
+ options['show_debug_column'] = options['export_mode'] != 'print' \
+ and self.env.user.has_group('base.group_no_one') \
+ and len(options['column_groups']) == 1 \
+ and len(self.line_ids) > 0 # No debug column on fully dynamic reports by default (they can customize this)
+
+ # Show an additional column summing all the horizontal groups if there is no comparison and only one level of horizontal group
+ options['show_horizontal_group_total'] = options.get('selected_horizontal_group_id') \
+ and options.get('comparison', {}).get('filter') == 'no_comparison' \
+ and len(self.column_ids) == 1 \
+ and len(options['column_headers']) == 2
+
+ def _generate_columns_group_vals_recursively(self, next_levels_headers, previous_levels_group_vals):
+ if next_levels_headers:
+ rslt = []
+ for header_element in next_levels_headers[0]:
+ current_level_group_vals = {}
+ for key in previous_levels_group_vals:
+ current_level_group_vals[key] = {**previous_levels_group_vals.get(key, {}), **header_element.get(key, {})}
+
+ rslt += self._generate_columns_group_vals_recursively(next_levels_headers[1:], current_level_group_vals)
+ return rslt
+ else:
+ return [previous_levels_group_vals]
+
+ def _build_columns_from_column_group_vals(self, options, all_column_group_vals_in_order):
+ def _generate_domain_from_horizontal_group_hash_key_tuple(group_hash_key):
+ domain = []
+ for field_name, field_value in group_hash_key:
+ domain.append((field_name, '=', field_value))
+ return domain
+
+ columns = []
+ column_groups = {}
+ for column_group_val in all_column_group_vals_in_order:
+ horizontal_group_key_tuple = self._get_dict_hashable_key_tuple(column_group_val['horizontal_groupby_element']) # Empty tuple if no grouping
+ column_group_key = str(self._get_dict_hashable_key_tuple(column_group_val)) # Unique identifier for the column group
+
+ column_groups[column_group_key] = {
+ 'forced_options': column_group_val['forced_options'],
+ 'forced_domain': _generate_domain_from_horizontal_group_hash_key_tuple(horizontal_group_key_tuple),
+ }
+
+ # for budget, only one column in needed, regardless of the number of columns in the report
+ if any(budget_key in column_group_val['forced_options'] for budget_key in ('compute_budget', 'budget_percentage')):
+ columns.append({
+ 'name': "",
+ 'column_group_key': column_group_key,
+ 'expression_label': 'balance',
+ 'sortable': False,
+ 'figure_type': 'monetary',
+ 'blank_if_zero': False,
+ 'style': "text-align: center; white-space: nowrap;",
+ })
+
+ else:
+ for report_column in self.column_ids:
+ columns.append({
+ 'name': report_column.name,
+ 'column_group_key': column_group_key,
+ 'expression_label': report_column.expression_label,
+ 'sortable': report_column.sortable,
+ 'figure_type': report_column.figure_type,
+ 'blank_if_zero': report_column.blank_if_zero,
+ 'style': "text-align: center; white-space: nowrap;",
+ })
+
+ return columns, column_groups
+
+ def _get_dict_hashable_key_tuple(self, dict_to_convert):
+ rslt = []
+ for key, value in sorted(dict_to_convert.items()):
+ if isinstance(value, dict):
+ value = self._get_dict_hashable_key_tuple(value)
+ rslt.append((key, value))
+ return tuple(rslt)
+
+ ####################################################
+ # OPTIONS: BUTTONS
+ ####################################################
+
+ def action_open_report_form(self, options, params):
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.report',
+ 'view_mode': 'form',
+ 'views': [(False, 'form')],
+ 'res_id': self.id,
+ }
+
+ def _init_options_buttons(self, options, previous_options):
+ options['buttons'] = [
+ {'name': _('PDF'), 'sequence': 10, 'action': 'export_file', 'action_param': 'export_to_pdf', 'file_export_type': _('PDF'), 'branch_allowed': True, 'always_show': True},
+ {'name': _('XLSX'), 'sequence': 20, 'action': 'export_file', 'action_param': 'export_to_xlsx', 'file_export_type': _('XLSX'), 'branch_allowed': True, 'always_show': True},
+ ]
+
+ def open_account_report_file_download_error_wizard(self, errors, content):
+ self.ensure_one()
+
+ model = 'account.report.file.download.error.wizard'
+ vals = {'actionable_errors': errors}
+
+ if content:
+ vals['file_name'] = content['file_name']
+ vals['file_content'] = base64.b64encode(re.sub(r'\n\s*\n', '\n', content['file_content']).encode())
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': model,
+ 'res_id': self.env[model].create(vals).id,
+ 'target': 'new',
+ 'views': [(False, 'form')],
+ }
+
+ def get_export_mime_type(self, file_type):
+ """ Returns the MIME type associated with a report export file type,
+ for attachment generation.
+ """
+ type_mapping = {
+ 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'pdf': 'application/pdf',
+ 'xml': 'application/xml',
+ 'xaf': 'application/vnd.sun.xml.writer',
+ 'txt': 'text/plain',
+ 'csv': 'text/csv',
+ 'zip': 'application/zip',
+ }
+ return type_mapping.get(file_type, False)
+
+ def _init_options_section_buttons(self, options, previous_options):
+ """ In case we're displaying a section, we want to replace its buttons by its source report's. This needs to be done last, after calling the
+ custom handler, to avoid its _custom_options_initializer function to generate additional buttons.
+ """
+ if options['sections_source_id'] != self.id:
+ # We need to re-call a full get_options in case a custom options initializer adds new buttons depending on other options.
+ # This way, we're sure we always get all buttons that are needed.
+ sections_source = self.env['account.report'].browse(options['sections_source_id'])
+ options['buttons'] = sections_source.get_options(previous_options={**options, 'no_report_reroute': True})['buttons']
+
+ ####################################################
+ # OPTIONS: VARIANTS
+ ####################################################
+ def _init_options_variants(self, options, previous_options):
+ allowed_variant_ids = set()
+
+ previous_section_source_id = previous_options.get('sections_source_id')
+ if previous_section_source_id:
+ previous_section_source = self.env['account.report'].browse(previous_section_source_id)
+ if self in previous_section_source.section_report_ids:
+ options['variants_source_id'] = (previous_section_source.root_report_id or previous_section_source).id
+ allowed_variant_ids.add(previous_section_source_id)
+
+ if 'variants_source_id' not in options:
+ options['variants_source_id'] = (self.root_report_id or self).id
+
+ available_variants = self.env['account.report']
+ options['has_inactive_variants'] = False
+ allowed_country_variant_ids = {}
+ all_variants = self._get_variants(options['variants_source_id'])
+ for variant in all_variants.filtered(lambda x: x._is_available_for(options)):
+ if not self.root_report_id and variant != self and variant.active: # Non-route reports don't reroute the variant when computing their options
+ allowed_variant_ids.add(variant.id)
+ if variant.country_id:
+ allowed_country_variant_ids.setdefault(variant.country_id.id, []).append(variant.id)
+
+ if variant.active:
+ available_variants += variant
+ else:
+ options['has_inactive_variants'] = True
+
+ options['available_variants'] = [
+ {
+ 'id': variant.id,
+ 'name': variant.display_name,
+ 'country_id': variant.country_id.id, # To ease selection of default variant to open, without needing browsing again
+ }
+ for variant in sorted(available_variants, key=lambda x: (x.country_id and 1 or 0, x.sequence, x.id))
+ ]
+
+ previous_opt_report_id = previous_options.get('selected_variant_id')
+ if previous_opt_report_id in allowed_variant_ids or previous_opt_report_id == self.id:
+ options['selected_variant_id'] = previous_opt_report_id
+ elif allowed_country_variant_ids:
+ country_id = self.env.company.account_fiscal_country_id.id
+ report_id = (allowed_country_variant_ids.get(country_id) or next(iter(allowed_country_variant_ids.values())))[0]
+ options['selected_variant_id'] = report_id
+ else:
+ options['selected_variant_id'] = self.id
+
+ def _get_variants(self, report_id):
+ source_report = self.env['account.report'].browse(report_id)
+ if source_report.root_report_id:
+ # We need to get the root report in order to get all variants
+ source_report = source_report.root_report_id
+ return source_report + source_report.with_context(active_test=False).variant_report_ids
+
+ ####################################################
+ # OPTIONS: SECTIONS
+ ####################################################
+ def _init_options_sections(self, options, previous_options):
+ if options.get('selected_variant_id'):
+ options['sections_source_id'] = options['selected_variant_id']
+ else:
+ options['sections_source_id'] = self.id
+
+ source_report = self.env['account.report'].browse(options['sections_source_id'])
+
+ available_sections = source_report.section_report_ids if source_report.use_sections else self.env['account.report']
+ options['sections'] = [{'name': section.name, 'id': section.id} for section in available_sections]
+
+ if available_sections:
+ section_id = previous_options.get('selected_section_id')
+ if not section_id or section_id not in available_sections.ids:
+ section_id = available_sections[0].id
+
+ options['selected_section_id'] = section_id
+
+ options['has_inactive_sections'] = bool(self.env['account.report'].with_context(active_test=False).search_count([
+ ('section_main_report_ids', 'in', options['sections_source_id']),
+ ('active', '=', False)
+ ]))
+
+ ####################################################
+ # OPTIONS: REPORT_ID
+ ####################################################
+ def _init_options_report_id(self, options, previous_options):
+ if previous_options.get('no_report_reroute'):
+ # Used for exports
+ options['report_id'] = self.id
+ else:
+ options['report_id'] = options.get('selected_section_id') or options.get('selected_variant_id') or self.id
+
+ ####################################################
+ # OPTIONS: EXPORT MODE
+ ####################################################
+ def _init_options_export_mode(self, options, previous_options):
+ options['export_mode'] = previous_options.get('export_mode')
+
+ ####################################################
+ # OPTIONS: HORIZONTAL SPLIT
+ ####################################################
+ def _init_options_horizontal_split(self, options, previous_options):
+ if any(line.horizontal_split_side for line in self.line_ids):
+ options['horizontal_split'] = previous_options.get('horizontal_split', False)
+
+ ####################################################
+ # OPTIONS: CUSTOM
+ ####################################################
+ def _init_options_custom(self, options, previous_options):
+ custom_handler_model = self._get_custom_handler_model()
+ if custom_handler_model:
+ self.env[custom_handler_model]._custom_options_initializer(self, options, previous_options)
+
+ ####################################################
+ # OPTIONS: INTEGER ROUNDING
+ ####################################################
+ def _init_options_integer_rounding(self, options, previous_options):
+ if self.integer_rounding:
+ options['integer_rounding'] = self.integer_rounding
+ if options.get('export_mode') == 'file':
+ options['integer_rounding_enabled'] = True
+ else:
+ options['integer_rounding_enabled'] = previous_options.get('integer_rounding_enabled', True)
+ return options
+
+ ####################################################
+ # OPTIONS: BUDGETS
+ ####################################################
+ def _init_options_budgets(self, options, previous_options):
+ if self.filter_budgets:
+ previous_selection = {budget_option['id'] for budget_option in previous_options.get('budgets', []) if budget_option.get('selected')}
+
+ options['budgets'] = [
+ {
+ 'id': budget.id,
+ 'name': budget.name,
+ 'selected': budget.id in previous_selection,
+ 'company_id': budget.company_id.id,
+ }
+ for budget in self.env['account.report.budget'].search([('company_id', '=', self.env.company.id)])
+ ]
+ options['show_all_accounts'] = previous_options.get('show_all_accounts') or False
+
+ ####################################################
+ # OPTIONS: LOADING CALL
+ ####################################################
+ def _init_options_loading_call(self, options, previous_options):
+ """ Used by the js to know if it needs to reload the options (to not overwrite new options from the js) """
+ options['loading_call_number'] = previous_options.get('loading_call_number') or 0
+ return options
+
+ ####################################################
+ # OPTIONS: READONLY QUERY
+ ####################################################
+ def _init_options_readonly_query(self, options, previous_options):
+ options['readonly_query'] = (
+ options['currency_table']['type'] == 'monocurrency'
+ and not any(budget_opt['selected'] for budget_opt in options.get('budgets', []))
+ )
+
+ ####################################################
+ # OPTIONS: CORE
+ ####################################################
+
+ @api.readonly
+ def get_options(self, previous_options):
+ self.ensure_one()
+
+ initializers_in_sequence = self._get_options_initializers_in_sequence()
+
+ options = {}
+
+ if previous_options.get('_running_export_test'):
+ options['_running_export_test'] = True
+
+ # We need report_id to be initialized. Compute the necessary options to check for reroute.
+ for reroute_initializer_index, initializer in enumerate(initializers_in_sequence):
+ initializer(options, previous_options=previous_options)
+
+ # pylint: disable=W0143
+ if initializer == self._init_options_report_id:
+ break
+
+ # Stop the computation to check for reroute once we have computed the necessary information
+ if (not self.root_report_id or (self.use_sections and self.section_report_ids)) and options['report_id'] != self.id:
+ # Load the variant/section instead of the root report
+ variant_options = {**previous_options}
+ for reroute_opt_key in ('selected_variant_id', 'selected_section_id', 'variants_source_id', 'sections_source_id'):
+ opt_val = options.get(reroute_opt_key)
+ if opt_val:
+ variant_options[reroute_opt_key] = opt_val
+
+ return self.env['account.report'].browse(options['report_id']).get_options(variant_options)
+
+ # No reroute; keep on and compute the other options
+ for initializer_index in range(reroute_initializer_index + 1, len(initializers_in_sequence)):
+ initializer = initializers_in_sequence[initializer_index]
+ initializer(options, previous_options=previous_options)
+
+ options_companies = self.env['res.company'].browse(self.get_report_company_ids(options))
+ # Set export buttons to 'branch_allowed' if the currently selected company branches all share the same VAT
+ # number and no unselected sub-branch of the active company has the same VAT number. Companies with an empty VAT
+ # field will be considered as having the same VAT number as their closest parent with a non-empty VAT.
+ if options.get('enable_export_buttons_for_common_vat_in_branches'):
+ report_accepted_company_ids = set(options_companies.ids)
+ same_vat_branch_ids = set(self.env.company._get_branches_with_same_vat().ids)
+ if report_accepted_company_ids == same_vat_branch_ids:
+ options['buttons'] = [{**button, 'branch_allowed': button.get('branch_allowed', True)} for button in options['buttons']]
+
+ # Disable buttons without branch_allowed = True if not all branches are selected
+ if not options_companies._all_branches_selected():
+ for button in filter(lambda x: not x.get('branch_allowed'), options['buttons']):
+ button['error_action'] = 'show_error_branch_allowed'
+
+ # Sort the buttons list by sequence, for rendering
+ options['buttons'] = sorted(options['buttons'], key=lambda x: x.get('sequence', 90))
+
+ # Sanitizing date_from and date_to since they need to be JSON-serializable when exporting the report
+ # on the server side, since the ORM converts them to strings automatically when sending them to the client.
+ for date_dict in (
+ [options.get('date', {})] +
+ [group_data['forced_options']['date'] for group_data in options['column_groups'].values() if group_data.get('forced_options', {}).get('date')]
+ ):
+ if (date_from := date_dict.get('date_from')) and not isinstance(date_from, str):
+ date_dict['date_from'] = fields.Date.to_string(date_from)
+
+ if (date_to := date_dict.get('date_to')) and not isinstance(date_to, str):
+ date_dict['date_to'] = fields.Date.to_string(date_to)
+
+ return options
+
+ def _get_options_initializers_in_sequence(self):
+ """ Gets all filters in the right order to initialize them, so that each filter is
+ guaranteed to be after all of its dependencies in the resulting list.
+
+ :return: a list of initializer functions, each accepting two parameters:
+ - options (mandatory): The options dictionary to be modified by this initializer to include its related option's data
+
+ - previous_options (optional, defaults to None): A dict with default options values, coming from a previous call to the report.
+ These values can be considered or ignored on a case-by-case basis by the initializer,
+ depending on functional needs.
+ """
+ initializer_prefix = '_init_options_'
+ initializers = [
+ getattr(self, attr) for attr in dir(self)
+ if attr.startswith(initializer_prefix)
+ ]
+
+ # Order them in a dependency-compliant way
+ forced_sequence_map = self._get_options_initializers_forced_sequence_map()
+ initializers.sort(key=lambda x: forced_sequence_map.get(x, forced_sequence_map.get('default')))
+
+ return initializers
+
+ def _get_options_initializers_forced_sequence_map(self):
+ """ By default, not specific order is ensured for the filters when calling _get_options_initializers_in_sequence.
+ This function allows giving them a sequence number. It can be overridden
+ to make filters depend on each other.
+
+ :return: dict(str, int): str is the filter name, int is its sequence (lowest = first).
+ Multiple filters may share the same sequence, their relative order is then not guaranteed.
+ """
+ return {
+ self._init_options_companies: 10,
+ self._init_options_variants: 15,
+ self._init_options_sections: 16,
+ self._init_options_report_id: 17,
+ self._init_options_fiscal_position: 20,
+ self._init_options_date: 30,
+ self._init_options_horizontal_groups: 40,
+ self._init_options_comparison: 50,
+ self._init_options_export_mode: 60,
+ self._init_options_integer_rounding: 70,
+ self._init_options_journals: 80,
+ self._init_options_journals_names: 90,
+
+ 'default': 200,
+
+ self._init_options_column_headers: 990,
+ self._init_options_columns: 1000,
+ self._init_options_column_percent_comparison: 1010,
+ self._init_options_order_column: 1020,
+ self._init_options_hierarchy: 1030,
+ self._init_options_prefix_groups_threshold: 1040,
+ self._init_options_custom: 1050,
+ self._init_options_currency_table: 1055,
+ self._init_options_section_buttons: 1060,
+ self._init_options_readonly_query: 1070,
+ }
+
+ def _get_options_domain(self, options, date_scope):
+ self.ensure_one()
+
+ available_scopes = dict(self.env['account.report.expression']._fields['date_scope'].selection)
+ if date_scope and date_scope not in available_scopes: # date_scope can be passed to None explicitly to ignore the dates
+ raise UserError(_("Unknown date scope: %s", date_scope))
+
+ domain = [
+ ('display_type', 'not in', ('line_section', 'line_note')),
+ ('company_id', 'in', self.get_report_company_ids(options)),
+ ]
+ if not options.get('compute_budget'):
+ domain += self._get_options_journals_domain(options)
+ if date_scope:
+ domain += self._get_options_date_domain(options, date_scope)
+ domain += self._get_options_partner_domain(options)
+ domain += self._get_options_all_entries_domain(options)
+ domain += self._get_options_unreconciled_domain(options)
+ domain += self._get_options_fiscal_position_domain(options)
+ domain += self._get_options_account_type_domain(options)
+ domain += self._get_options_aml_ir_filters(options)
+
+ if self.only_tax_exigible:
+ domain += self.env['account.move.line']._get_tax_exigible_domain()
+
+ if options.get('forced_domain'):
+ # That option key is set when splitting options between column groups
+ domain += options['forced_domain']
+
+ return domain
+
+ ####################################################
+ # QUERIES
+ ####################################################
+
+ def _get_report_query(self, options, date_scope, domain=None) -> Query:
+ """ Get a Query object that references the records needed for this report. """
+ domain = self._get_options_domain(options, date_scope) + (domain or [])
+
+ self.env['account.move.line'].check_access('read')
+
+ query = self.env['account.move.line']._where_calc(domain)
+
+ if options.get('compute_budget'):
+ self._create_report_budget_temp_table(options)
+ query._tables['account_move_line'] = SQL.identifier('account_report_budget_temp_aml')
+ query.add_where(SQL(
+ "%s AND budget_id = %s",
+ query.where_clause,
+ options['compute_budget'],
+ ))
+
+ # Wrap the query with 'company_id IN (...)' to avoid bypassing company access rights.
+ self.env['account.move.line']._apply_ir_rules(query)
+
+ return query
+
+ def _create_report_budget_temp_table(self, options):
+ self._cr.execute("SELECT 1 FROM information_schema.tables WHERE table_name='account_report_budget_temp_aml'")
+ if self._cr.fetchone():
+ return
+
+ stored_aml_fields, fields_to_insert = self.env['account.move.line']._prepare_aml_shadowing_for_report({
+ 'id': SQL.identifier("id"),
+ 'balance': SQL.identifier('amount'),
+ 'company_id': self.env.company.id,
+ 'parent_state': 'posted',
+ 'date': SQL.identifier('date'),
+ 'account_id': SQL.identifier("account_id"),
+ 'debit': SQL("CASE WHEN (amount > 0) THEN amount else 0 END"),
+ 'credit': SQL("CASE WHEN (amount < 0) THEN -amount else 0 END"),
+ })
+
+ self._cr.execute(SQL(
+ """
+ -- Create a temporary table, dropping not null constraints because we're not filling those columns
+ CREATE TEMPORARY TABLE IF NOT EXISTS account_report_budget_temp_aml () inherits (account_move_line) ON COMMIT DROP;
+ ALTER TABLE account_report_budget_temp_aml NO INHERIT account_move_line;
+ ALTER TABLE account_report_budget_temp_aml ALTER COLUMN move_id DROP NOT NULL;
+ ALTER TABLE account_report_budget_temp_aml ALTER COLUMN currency_id DROP NOT NULL;
+ ALTER TABLE account_report_budget_temp_aml ALTER COLUMN journal_id DROP NOT NULL;
+ ALTER TABLE account_report_budget_temp_aml ALTER COLUMN display_type DROP NOT NULL;
+ ALTER TABLE account_report_budget_temp_aml ADD budget_id INTEGER NOT NULL;
+
+ INSERT INTO account_report_budget_temp_aml (%(stored_aml_fields)s, budget_id)
+ SELECT %(fields_to_insert)s, budget_id
+ FROM account_report_budget_item
+ WHERE budget_id IN %(available_budget_ids)s;
+
+ -- Create a supporting index to avoid seq.scans
+ CREATE INDEX IF NOT EXISTS account_report_budget_temp_aml__composite_idx ON account_report_budget_temp_aml (account_id, journal_id, date, company_id);
+ -- Update statistics for correct planning
+ ANALYZE account_report_budget_temp_aml
+ """,
+ stored_aml_fields=stored_aml_fields,
+ fields_to_insert=fields_to_insert,
+ available_budget_ids=tuple(budget_option['id'] for budget_option in options['budgets']),
+ ))
+
+ if options.get('show_all_accounts'):
+ stored_aml_fields, fields_to_insert = self.env['account.move.line']._prepare_aml_shadowing_for_report({
+ # Using nextval will consume a sequence number, we decide to do it to avoid comparing apples and oranges
+ 'id': SQL("(SELECT nextval('account_report_budget_item_id_seq'))"),
+ 'balance': SQL("0"),
+ 'company_id': self.env.company.id,
+ 'parent_state': 'posted',
+ 'date': SQL("%s", options['date']['date_from']),
+ 'account_id': SQL.identifier("accounts", "id"),
+ 'debit': SQL("0"),
+ 'credit': SQL("0"),
+ })
+ accounts_subquery = self.env['account.account']._where_calc([
+ ('company_ids', 'in', self.get_report_company_ids(options)),
+ ('internal_group', 'in', ['income', 'expense']),
+ ])
+ self._cr.execute(SQL(
+ """
+ -- Insert dynamic combinations of account_id and budget_id into the temporary table
+ INSERT INTO account_report_budget_temp_aml (%(stored_aml_fields)s, budget_id)
+ SELECT %(fields_to_insert)s, budgets.id AS budget_id
+ FROM (%(accounts_subquery)s) AS accounts
+ CROSS JOIN (
+ SELECT id
+ FROM account_report_budget
+ WHERE id IN %(available_budget_ids)s
+ ) AS budgets
+ """,
+ stored_aml_fields=stored_aml_fields,
+ fields_to_insert=fields_to_insert,
+ accounts_subquery=accounts_subquery.select(),
+ available_budget_ids=tuple(budget_option['id'] for budget_option in options['budgets']),
+ income='income%',
+ expense='expense%',
+ company_ids=tuple(),
+ ))
+
+ ####################################################
+ # LINE IDS MANAGEMENT HELPERS
+ ####################################################
+ def _get_generic_line_id(self, model_name, value, markup=None, parent_line_id=None):
+ """ Generates a generic line id from the provided parameters.
+
+ Such a generic id consists of a string repeating 1 to n times the following pattern:
+ markup-model-value, each occurence separated by a LINE_ID_HIERARCHY_DELIMITER character from the previous one.
+
+ Each pattern corresponds to a level of hierarchy in the report, so that
+ the n-1 patterns starting the id of a line actually form the id of its generator line.
+ EX: a~b~c|d~e~f|g~h~i => This line is a subline generated by a~b~c|d~e~f where | is the LINE_ID_HIERARCHY_DELIMITER.
+
+ Each pattern consists of the three following elements:
+ - markup: a (possibly empty) free string or json-formatted dict allowing finer identification of the line
+ (like the name of the field for account.accounting.reports)
+
+ - model: the model this line has been generated for, or an empty string if there is none
+
+ - value: the groupby value for this line (typically the id of a record
+ or the value of a field), or an empty string if there isn't any.
+ """
+ self.ensure_one()
+
+ if parent_line_id:
+ parent_id_list = self._parse_line_id(parent_line_id, markup_as_string=True)
+ else:
+ parent_id_list = [(None, 'account.report', self.id)]
+
+ # In case the markup is a dict, it must be converted to a string, but in a way such that the keys are ordered alphabetically.
+ # This is useful, notably for annotations where the ids of the lines are stored, therefore requiring a consistent ordering
+ if isinstance(markup, dict):
+ markup = json.dumps(markup, sort_keys=True)
+
+ new_line = self._build_line_id(parent_id_list + [(markup, model_name, value)])
+ return new_line
+
+ @api.model
+ def _get_line_from_xml_id(self, lines, xml_id):
+ """ Helper function to get a specific account report line from the xmlid """
+ report_line = self.env.ref(xml_id, raise_if_not_found=False)
+ return next(
+ line for line in lines
+ if self._get_model_info_from_id(line['id']) == ('account.report.line', report_line.id)
+ )
+
+ @api.model
+ def _get_model_info_from_id(self, line_id):
+ """ Parse the provided generic report line id.
+
+ :param line_id: the report line id (i.e. markup~model~value|markup2~model2~value2 where | is the LINE_ID_HIERARCHY_DELIMITER)
+ :return: tuple(model, id) of the report line. Each of those values can be None if the id contains no information about them.
+ """
+ last_id_tuple = self._parse_line_id(line_id)[-1]
+ return last_id_tuple[-2:]
+
+ @api.model
+ def _build_line_id(self, current):
+ """ Build a generic line id string from its list representation, converting
+ the None values for model and value to empty strings.
+ :param current (list): list of tuple(markup, model, value)
+ """
+ def convert_none(x):
+ return x if x is not None and x is not False else ''
+ return LINE_ID_HIERARCHY_DELIMITER.join(f'{convert_none(markup)}~{convert_none(model)}~{convert_none(value)}' for markup, model, value in current)
+
+ @api.model
+ def _build_parent_line_id(self, current):
+ """Build the parent_line id based on the current position in the report.
+
+ For instance, if current is [('markup1', 'account.account', 5), ('markup2', 'res.partner', 8)], it will return
+ markup1~account.account~5
+ :param current (list): list of tuple(markup, model, value)
+ """
+ to_process = [(json.dumps(markup) if isinstance(markup, dict) else markup, model, value) for markup, model, value in current[:-1]]
+ return self._build_line_id(to_process)
+
+ @api.model
+ def _parse_markup(self, markup):
+ if not markup:
+ return markup
+ try:
+ result = json.loads(markup)
+ except json.JSONDecodeError: # the markup is not a JSON object
+ return markup
+ if isinstance(result, dict):
+ return result
+
+ return markup
+
+ @api.model
+ def _parse_line_id(self, line_id, markup_as_string=False):
+ """Parse the provided string line id and convert it to its list representation.
+ Empty strings for model and value will be converted to None.
+
+ For instance if line_id is markup1~account.account~5|markup2~res.partner~8 (where | is the LINE_ID_HIERARCHY_DELIMITER),
+ it will return [('markup1', 'account.account', 5), ('markup2', 'res.partner', 8)]
+ :param line_id (str): the generic line id to parse
+ """
+ return line_id and [
+ # When there is a model, value is an id, so we cast it to and int. Else, we keep the original value (for groupby lines on
+ # non-relational fields, for example).
+ (self._parse_markup(markup) if not markup_as_string else markup, model or None, int(value) if model and value else (value or None))
+ for markup, model, value in (key.rsplit('~', 2) for key in line_id.split(LINE_ID_HIERARCHY_DELIMITER))
+ ] or []
+
+ @api.model
+ def _get_unfolded_lines(self, lines, parent_line_id):
+ """ Return a list of all children lines for specified parent_line_id.
+ NB: It will return the parent_line itself!
+
+ For instance if parent_line_ids is '~account.report.line~84|{"groupby": "currency_id"}~res.currency~174'
+ (where | is the LINE_ID_HIERARCHY_DELIMITER), it will return every subline for this currency.
+ :param lines: list of report lines
+ :param parent_line_id: id of a specified line
+ :return: A list of all children lines for a specified parent_line_id
+ """
+ return [
+ line for line in lines
+ if line['id'].startswith(parent_line_id)
+ ]
+
+ @api.model
+ def _get_res_id_from_line_id(self, line_id, target_model_name):
+ """ Parses the provided generic line id and returns the most local (i.e. the furthest on the right) record id it contains which
+ corresponds to the provided model name. If line_id does not contain anything related to target_model_name, None will be returned.
+
+ For example, parsing ~account.move~1|~res.partner~2|~account.move~3 (where | is the LINE_ID_HIERARCHY_DELIMITER)
+ with target_model_name='account.move' will return 3.
+ """
+ dict_result = self._get_res_ids_from_line_id(line_id, [target_model_name])
+ return dict_result[target_model_name] if dict_result else None
+
+
+ @api.model
+ def _get_res_ids_from_line_id(self, line_id, target_model_names):
+ """ Parses the provided generic line id and returns the most local (i.e. the furthest on the right) record ids it contains which
+ correspond to the provided model names, in the form {model_name: res_id}. If a model is not present in line_id, its model will be absent
+ from the resulting dict.
+
+ For example, parsing ~account.move~1|~res.partner~2|~account.move~3 with target_model_names=['account.move', 'res.partner'] will return
+ {'account.move': 3, 'res.partner': 2}.
+ """
+ result = {}
+ models_to_find = set(target_model_names)
+ for dummy, model, value in reversed(self._parse_line_id(line_id)):
+ if model in models_to_find:
+ result[model] = value
+ models_to_find.remove(model)
+
+ return result
+
+ @api.model
+ def _get_markup(self, line_id):
+ """ Directly returns the markup associated with the provided line_id.
+ """
+ return self._parse_line_id(line_id)[-1][0] if line_id else None
+
+ def _build_subline_id(self, parent_line_id, subline_id_postfix):
+ """ Creates a new subline id by concatanating parent_line_id with the provided id postfix.
+ """
+ return f"{parent_line_id}{LINE_ID_HIERARCHY_DELIMITER}{subline_id_postfix}"
+
+ ####################################################
+ # CARET OPTIONS MANAGEMENT
+ ####################################################
+
+ def _get_caret_options(self):
+ return {
+ **self._caret_options_initializer_default(),
+ **(self.env[self.custom_handler_model_name]._caret_options_initializer() if self.custom_handler_model_id else {}),
+ }
+
+ def _caret_options_initializer_default(self):
+ return {
+ 'account.account': [
+ {'name': _("General Ledger"), 'action': 'caret_option_open_general_ledger'},
+ ],
+
+ 'account.move': [
+ {'name': _("View Journal Entry"), 'action': 'caret_option_open_record_form'},
+ ],
+
+ 'account.move.line': [
+ {'name': _("View Journal Entry"), 'action': 'caret_option_open_record_form', 'action_param': 'move_id'},
+ ],
+
+ 'account.payment': [
+ {'name': _("View Payment"), 'action': 'caret_option_open_record_form', 'action_param': 'payment_id'},
+ ],
+
+ 'account.bank.statement': [
+ {'name': _("View Bank Statement"), 'action': 'caret_option_open_statement_line_reco_widget'},
+ ],
+
+ 'res.partner': [
+ {'name': _("View Partner"), 'action': 'caret_option_open_record_form'},
+ ],
+ }
+
+ def caret_option_open_record_form(self, options, params):
+ model, record_id = self._get_model_info_from_id(params['line_id'])
+ record = self.env[model].browse(record_id)
+ target_record = record[params['action_param']] if 'action_param' in params else record
+
+ view_id = self._resolve_caret_option_view(target_record)
+
+ action = {
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'views': [(view_id, 'form')], # view_id will be False in case the default view is needed
+ 'res_model': target_record._name,
+ 'res_id': target_record.id,
+ 'context': self._context,
+ }
+
+ if view_id is not None:
+ action['view_id'] = view_id
+
+ return action
+
+ def _get_caret_option_view_map(self):
+ return {
+ 'account.payment': 'account.view_account_payment_form',
+ 'res.partner': 'base.view_partner_form',
+ 'account.move': 'account.view_move_form',
+ }
+
+ def _resolve_caret_option_view(self, target):
+ '''Retrieve the target view of the caret option.
+
+ :param target: The target record of the redirection.
+ :return: The id of the target view.
+ '''
+ view_map = self._get_caret_option_view_map()
+
+ view_xmlid = view_map.get(target._name)
+ if not view_xmlid:
+ return None
+
+ return self.env['ir.model.data']._xmlid_lookup(view_xmlid)[1]
+
+ def caret_option_open_general_ledger(self, options, params):
+ # When coming from a specific account, the unfold must only be retained
+ # on the specified account. Better performance and more ergonomic
+ # as it opens what client asked. And "Unfold All" is 1 clic away.
+ options["unfold_all"] = False
+
+ records_to_unfold = []
+ for _dummy, model, record_id in self._parse_line_id(params['line_id']):
+ if model in ('account.group', 'account.account'):
+ records_to_unfold.append((model, record_id))
+
+ if not records_to_unfold or records_to_unfold[-1][0] != 'account.account':
+ raise UserError(_("'Open General Ledger' caret option is only available form report lines targetting accounts."))
+
+ general_ledger = self.env.ref('odex30_account_reports.general_ledger_report')
+ lines_to_unfold = []
+ for model, record_id in records_to_unfold:
+ parent_line_id = lines_to_unfold[-1] if lines_to_unfold else None
+ # Re-create the hierarchy of account groups that should be unfolded in GL
+ generic_line_id = general_ledger._get_generic_line_id(model, record_id, parent_line_id=parent_line_id)
+ lines_to_unfold.append(generic_line_id)
+
+ options['not_reset_journals_filter'] = True # prevents resetting the default journal group
+ gl_options = general_ledger.get_options(options)
+ gl_options['not_reset_journals_filter'] = True # prevents resetting the default journal group
+ gl_options['unfolded_lines'] = lines_to_unfold
+
+ account_id = self.env['account.account'].browse(records_to_unfold[-1][1])
+ action_vals = self.env['ir.actions.actions']._for_xml_id('odex30_account_reports.action_account_report_general_ledger')
+ action_vals['params'] = {
+ 'options': gl_options,
+ 'ignore_session': True,
+ }
+ action_vals['context'] = dict(ast.literal_eval(action_vals['context']), default_filter_accounts=account_id.code)
+
+ return action_vals
+
+ def caret_option_open_statement_line_reco_widget(self, options, params):
+ model, record_id = self._get_model_info_from_id(params['line_id'])
+ record = self.env[model].browse(record_id)
+ if record._name == 'account.bank.statement.line':
+ return record.action_open_recon_st_line()
+ elif record._name == 'account.bank.statement':
+ return record.action_open_bank_reconcile_widget()
+ raise UserError(_("'View Bank Statement' caret option is only available for report lines targeting bank statements."))
+
+ ####################################################
+ # MISC
+ ####################################################
+
+ def _get_custom_handler_model(self):
+ """ Check whether the current report has a custom handler and if it does, return its name.
+ Otherwise, try to fall back on the root report.
+ """
+ return self.custom_handler_model_name or self.root_report_id.custom_handler_model_name or None
+
+ def dispatch_report_action(self, options, action, action_param=None, on_sections_source=False):
+ """ Dispatches calls made by the client to either the report itself, or its custom handler if it exists.
+ The action should be a public method, by definition, but a check is made to make sure
+ it is not trying to call a private method.
+ """
+ self.ensure_one()
+
+ if on_sections_source:
+ report_to_call = self.env['account.report'].browse(options['sections_source_id'])
+ options["report_id"] = report_to_call.id
+ return report_to_call.dispatch_report_action(options, action, action_param=action_param, on_sections_source=False)
+
+ if self.id not in (options['report_id'], options.get('sections_source_id')):
+ raise UserError(_("Trying to dispatch an action on a report not compatible with the provided options."))
+
+ args = [options, action_param] if action_param is not None else [options]
+ model = self
+ custom_handler_model = self._get_custom_handler_model()
+ if custom_handler_model and hasattr(self.env[custom_handler_model], action):
+ model = self.env[custom_handler_model]
+ report_method = get_public_method(model, action)
+ return report_method(model, *args)
+
+ def _get_custom_report_function(self, function_name, prefix):
+ """ Returns a report function from its name, first checking it to ensure it's private (and raising if it isn't).
+ This helper is used by custom report fields containing function names.
+ The function will be called on the report's custom handler if it exists, or on the report itself otherwise.
+ """
+ self.ensure_one()
+ function_name_prefix = f'_report_{prefix}_'
+ if not function_name.startswith(function_name_prefix):
+ raise UserError(_("Method '%(method_name)s' must start with the '%(prefix)s' prefix.", method_name=function_name, prefix=function_name_prefix))
+
+ if self.custom_handler_model_id:
+ handler = self.env[self.custom_handler_model_name]
+ if hasattr(handler, function_name):
+ return getattr(handler, function_name)
+
+ if not hasattr(self, function_name):
+ raise UserError(_("Invalid method “%s”", function_name))
+ # Call the check method without the private prefix to check for others security risks.
+ return getattr(self, function_name)
+
+ def _get_lines(self, options, all_column_groups_expression_totals=None, warnings=None):
+ self.ensure_one()
+
+ if options['report_id'] != self.id:
+ # Should never happen; just there to prevent BIG issues and directly spot them
+ raise UserError(_("Inconsistent report_id in options dictionary. Options says %(options_report)s; report is %(report)s.", options_report=options['report_id'], report=self.id))
+
+ # Necessary to ensure consistency of the data if some of them haven't been written in database yet
+ self.env.flush_all()
+
+ if warnings is not None:
+ self._generate_common_warnings(options, warnings)
+
+ # Merge static and dynamic lines in a common list
+ if all_column_groups_expression_totals is None:
+ self._init_currency_table(options)
+ all_column_groups_expression_totals = self._compute_expression_totals_for_each_column_group(
+ self.line_ids.expression_ids,
+ options,
+ warnings=warnings,
+ )
+
+ dynamic_lines = self._get_dynamic_lines(options, all_column_groups_expression_totals, warnings=warnings)
+
+ lines = []
+ line_cache = {} # {report_line: report line dict}
+ hide_if_zero_lines = self.env['account.report.line']
+
+ # There are two types of lines:
+ # - static lines: the ones generated from self.line_ids
+ # - dynamic lines: the ones generated from a call to the functions referred to by self.dynamic_lines_generator
+ # This loops combines both types of lines together within the lines list
+ for line in self.line_ids: # _order ensures the sequence of the lines
+ # Inject all the dynamic lines whose sequence is inferior to the next static line to add
+ while dynamic_lines and line.sequence > dynamic_lines[0][0]:
+ lines.append(dynamic_lines.pop(0)[1])
+
+ parent_generic_id = None
+
+ if line.parent_id:
+ # Normally, the parent line has necessarily been treated in a previous iteration
+ try:
+ parent_generic_id = line_cache[line.parent_id]['id']
+ except KeyError as e:
+ raise UserError(_(
+ "Line '%(child)s' is configured to appear before its parent '%(parent)s'. This is not allowed.",
+ child=line.name, parent=e.args[0].name
+ ))
+
+ line_dict = self._get_static_line_dict(options, line, all_column_groups_expression_totals, parent_id=parent_generic_id)
+ line_cache[line] = line_dict
+
+ if line.hide_if_zero:
+ hide_if_zero_lines += line
+
+ lines.append(line_dict)
+
+ for dummy, left_dynamic_line in dynamic_lines:
+ lines.append(left_dynamic_line)
+
+ # Manage growth comparison
+ if options.get('column_percent_comparison') == 'growth':
+ for line in lines:
+ first_value, second_value = line['columns'][0]['no_format'], line['columns'][1]['no_format']
+
+ green_on_positive = True
+ model, line_id = self._get_model_info_from_id(line['id'])
+
+ if model == 'account.report.line' and line_id:
+ report_line = self.env['account.report.line'].browse(line_id)
+ compared_expression = report_line.expression_ids.filtered(
+ lambda expr: expr.label == line['columns'][0]['expression_label']
+ )
+ green_on_positive = compared_expression.green_on_positive
+
+ line['column_percent_comparison_data'] = self._compute_column_percent_comparison_data(
+ options, first_value, second_value, green_on_positive=green_on_positive
+ )
+ # Manage budget comparison
+ elif options.get('column_percent_comparison') == 'budget':
+ for line in lines:
+ self._set_budget_column_comparisons(options, line)
+
+ # Manage hide_if_zero lines:
+ # - If they have column values: hide them if all those values are 0 (or empty)
+ # - If they don't: hide them if all their children's column values are 0 (or empty)
+ # Also, hide all the children of a hidden line.
+ hidden_lines_dict_ids = set()
+ for line in hide_if_zero_lines:
+ children_to_check = line
+ current = line
+ while current:
+ children_to_check |= current
+ current = current.children_ids
+
+ all_children_zero = True
+ hide_candidates = set()
+ for child in children_to_check:
+ child_line_dict_id = line_cache[child]['id']
+
+ if child_line_dict_id in hidden_lines_dict_ids:
+ continue
+ elif all(col.get('is_zero', True) for col in line_cache[child]['columns']):
+ hide_candidates.add(child_line_dict_id)
+ else:
+ all_children_zero = False
+ break
+
+ if all_children_zero:
+ hidden_lines_dict_ids |= hide_candidates
+
+ lines[:] = filter(lambda x: x['id'] not in hidden_lines_dict_ids and x.get('parent_id') not in hidden_lines_dict_ids, lines)
+
+ # Create the hierarchy of lines if necessary
+ if options.get('hierarchy'):
+ lines = self._create_hierarchy(lines, options)
+
+ # Handle totals below sections for static lines
+ lines = self._add_totals_below_sections(lines, options)
+
+ # Unfold lines (static or dynamic) if necessary and add totals below section to dynamic lines
+ lines = self._fully_unfold_lines_if_needed(lines, options)
+
+ self._inject_account_names_for_consolidation(lines)
+
+ if self.custom_handler_model_id:
+ lines = self.env[self.custom_handler_model_name]._custom_line_postprocessor(self, options, lines)
+
+ if warnings is not None:
+ custom_handler_name = self.custom_handler_model_name or self.root_report_id.custom_handler_model_name
+ if custom_handler_name:
+ self.env[custom_handler_name]._customize_warnings(self, options, all_column_groups_expression_totals, warnings)
+
+ # Format values in columns of lines that will be displayed
+ self._format_column_values(options, lines)
+
+ if options.get('export_mode') == 'print' and options.get('hide_0_lines'):
+ lines = self._filter_out_0_lines(lines)
+
+ return lines
+
+ # Deprecated, removed in master.
+ @api.model
+ def format_column_values(self, options, lines):
+ self._format_column_values(options, lines, force_format=True)
+
+ return lines
+
+ def format_column_values_from_client(self, options, lines):
+ """ Format column values for display. Called via dispatch_report_action when rounding unit changes on client side."""
+ self._format_column_values(options, lines, force_format=True)
+
+ return lines
+
+ def _format_column_values(self, options, line_dict_list, force_format=False):
+ for line_dict in line_dict_list:
+ for column_dict in line_dict['columns']:
+ if 'name' in column_dict and not force_format:
+ # Columns which have already received a name are assumed to be already formatted; nothing needs to be done for them.
+ # This gives additional flexibility to custom reports, if needed.
+ continue
+
+ if not column_dict:
+ continue
+ elif column_dict.get('is_zero') and column_dict.get('blank_if_zero'):
+ rslt = ''
+ elif options.get('export_mode') == 'file':
+ rslt = column_dict.get('no_format', '')
+ else:
+ rslt = self.format_value(
+ options,
+ column_dict.get('no_format'),
+ column_dict.get('figure_type'),
+ format_params=column_dict.get('format_params'),
+ )
+
+ column_dict['name'] = rslt
+
+ # Handle the total in case of an horizontal group when there is no comparison and only one level of horizontal group
+ if options.get('show_horizontal_group_total'):
+ # In case the line has no formula
+ if all(column['no_format'] is None for column in line_dict['columns']):
+ continue
+ # In case total below section, some line don't have the value displayed
+ if self.env.company.totals_below_sections and not options.get('ignore_totals_below_sections') and line_dict['unfolded']:
+ continue
+
+ figure_type_is_valid = all(column['figure_type'] in {'float', 'integer', 'monetary'} for column in line_dict['columns'])
+ total_value = sum(column["no_format"] for column in line_dict['columns']) if figure_type_is_valid else None
+ line_dict['horizontal_group_total_data'] = {
+ 'name': self.format_value(
+ options,
+ total_value,
+ line_dict['columns'][0]['figure_type'],
+ format_params=line_dict['columns'][0]['format_params'],
+ ),
+ 'no_format': total_value,
+ }
+
+ def _generate_common_warnings(self, options, warnings):
+ # Display a warning if we're displaying only the data of the current company, but it's also part of a tax unit
+ if options.get('available_tax_units') and options['tax_unit'] == 'company_only':
+ warnings['odex30_account_reports.common_warning_tax_unit'] = {}
+
+ report_company_ids = self.get_report_company_ids(options)
+ # The _accessible_branches function will return the accessible branches from the ones that are already selected,
+ # and the report_company_ids function will return the current company and its branches (that are selected) with the same VAT
+ # or tax unit. Therefore, we will display the warning only when the selected companies do not have the same VAT
+ # and in the context of branches.
+ if self.filter_multi_company == 'tax_units' and any(accessible_branch.id not in report_company_ids for accessible_branch in self.env.company._accessible_branches()):
+ warnings['odex30_account_reports.tax_report_warning_tax_id_selected_companies'] = {'alert_type': 'warning'}
+
+ # Check whether there are unposted entries for the selected period and partner or not (if the report allows it)
+ if options.get('date') and options.get('all_entries') is not None:
+ domain = osv.expression.AND([
+ self.env['account.move']._check_company_domain(report_company_ids),
+ [('state', '=', 'draft')],
+ [('date', '<=', options['date']['date_to'])],
+ ])
+ if options.get('partner_ids'):
+ domain = osv.expression.AND([
+ domain,
+ osv.expression.OR([
+ [('partner_id', 'in', options['partner_ids'])],
+ [('partner_shipping_id', 'in', options['partner_ids'])],
+ [('commercial_partner_id', 'in', options['partner_ids'])],
+ ])
+ ])
+ if self.env['account.move'].search_count(domain, limit=1):
+ warnings['odex30_account_reports.common_warning_draft_in_period'] = {}
+
+ def _fully_unfold_lines_if_needed(self, lines, options):
+ def line_need_expansion(line_dict):
+ return line_dict.get('unfolded') and line_dict.get('expand_function')
+
+ custom_unfold_all_batch_data = None
+
+ # If it's possible to batch unfold and we're unfolding all lines, compute the batch, so that individual expansions are more efficient
+ if options['unfold_all'] and self.custom_handler_model_id:
+ lines_to_expand_by_function = {}
+ for line_dict in lines:
+ if line_need_expansion(line_dict):
+ lines_to_expand_by_function.setdefault(line_dict['expand_function'], []).append(line_dict)
+
+ custom_unfold_all_batch_data = self.env[self.custom_handler_model_name]._custom_unfold_all_batch_data_generator(self, options, lines_to_expand_by_function)
+
+ i = 0
+ while i < len(lines):
+ # We iterate in such a way that if the lines added by an expansion need expansion, they will get it as well
+ line_dict = lines[i]
+ if line_need_expansion(line_dict):
+ groupby = line_dict.get('groupby')
+ progress = line_dict.get('progress')
+ to_insert = self._expand_unfoldable_line(
+ line_dict['expand_function'], line_dict['id'], groupby, options, progress, 0, line_dict.get('horizontal_split_side'),
+ unfold_all_batch_data=custom_unfold_all_batch_data,
+ )
+ lines = lines[:i+1] + to_insert + lines[i+1:]
+ i += 1
+
+ return lines
+
+ def _generate_total_below_section_line(self, section_line_dict):
+ return {
+ **section_line_dict,
+ 'id': self._get_generic_line_id(None, None, parent_line_id=section_line_dict['id'], markup='total'),
+ 'level': section_line_dict['level'] if section_line_dict['level'] != 0 else 1, # Total line should not be level 0
+ 'name': _("Total %s", section_line_dict['name']),
+ 'parent_id': section_line_dict['id'],
+ 'unfoldable': False,
+ 'unfolded': False,
+ 'caret_options': None,
+ 'action_id': None,
+ 'page_break': False, # If the section's line possesses a page break, we don't want the total to have it.
+ }
+
+ def _get_static_line_dict(self, options, line, all_column_groups_expression_totals, parent_id=None):
+ line_id = self._get_generic_line_id('account.report.line', line.id, parent_line_id=parent_id)
+ columns = self._build_static_line_columns(line, options, all_column_groups_expression_totals)
+ groupby = line._get_groupby(options)
+ has_children = (groupby and any(col['has_sublines'] for col in columns)) or bool(line.children_ids)
+
+ rslt = {
+ 'id': line_id,
+ 'name': line.name,
+ 'groupby': groupby,
+ 'unfoldable': line.foldable and has_children,
+ 'unfolded': (not line.foldable and (groupby or has_children)) or line_id in options['unfolded_lines'] or has_children and options['unfold_all'],
+ 'columns': columns,
+ 'level': line.hierarchy_level,
+ 'page_break': line.print_on_new_page,
+ 'action_id': line.action_id.id,
+ 'expand_function': groupby and '_report_expand_unfoldable_line_with_groupby' or None,
+ }
+
+ if line.horizontal_split_side:
+ rslt['horizontal_split_side'] = line.horizontal_split_side
+
+ if parent_id:
+ rslt['parent_id'] = parent_id
+
+ if options['export_mode'] == 'file':
+ rslt['code'] = line.code
+
+ if options['show_debug_column']:
+ first_group_key = list(options['column_groups'].keys())[0]
+ column_group_totals = all_column_groups_expression_totals[first_group_key]
+ # Only consider the first column group, as show_debug_column is only true if there is but one.
+
+ engine_selection_labels = dict(self.env['account.report.expression']._fields['engine']._description_selection(self.env))
+ expressions_detail = defaultdict(lambda: [])
+ col_expression_to_figure_type = {
+ column.get('expression_label'): column.get('figure_type') for column in options['columns']
+ }
+ for expression in line.expression_ids.filtered(lambda x: not x.label.startswith('_default')):
+ engine_label = engine_selection_labels[expression.engine]
+ figure_type = expression.figure_type or col_expression_to_figure_type.get(expression.label) or 'none'
+ expressions_detail[engine_label].append((
+ expression.label,
+ {'formula': expression.formula, 'subformula': expression.subformula, 'value': self.format_value(options, column_group_totals[expression]['value'], figure_type)}
+ ))
+
+ # Sort results so that they can be rendered nicely in the UI
+ for details in expressions_detail.values():
+ details.sort(key=lambda x: x[0])
+ sorted_expressions_detail = sorted(expressions_detail.items(), key=lambda x: x[0])
+
+ if sorted_expressions_detail:
+ try:
+ rslt['debug_popup_data'] = json.dumps({'expressions_detail': sorted_expressions_detail})
+ except TypeError:
+ raise UserError(_(
+ 'Invalid subformula in expression "%(expression)s" of line "%(line)s": %(subformula)s',
+ expression=expression.label,
+ line=expression.report_line_id.name,
+ subformula=expression.subformula,
+ ))
+ return rslt
+
+ @api.model
+ def _build_static_line_columns(self, line, options, all_column_groups_expression_totals, groupby_model=None):
+ line_expressions_map = {expr.label: expr for expr in line.expression_ids}
+ columns = []
+ for column_data in options['columns']:
+ col_group_key = column_data['column_group_key']
+ current_group_expression_totals = all_column_groups_expression_totals[col_group_key]
+ target_line_res_dict = {expr.label: current_group_expression_totals[expr] for expr in line.expression_ids if not expr.label.startswith('_default')}
+
+ column_expr_label = column_data['expression_label']
+ column_res_dict = target_line_res_dict.get(column_expr_label, {})
+ column_value = column_res_dict.get('value')
+ column_has_sublines = column_res_dict.get('has_sublines', False)
+ column_expression = line_expressions_map.get(column_expr_label, self.env['account.report.expression'])
+ figure_type = column_expression.figure_type or column_data['figure_type']
+
+ # Handle info popup
+ info_popup_data = {}
+
+ # Check carryover
+ carryover_expr_label = '_carryover_%s' % column_expr_label
+ carryover_value = target_line_res_dict.get(carryover_expr_label, {}).get('value', 0)
+ if self.env.company.currency_id.compare_amounts(0, carryover_value) != 0:
+ info_popup_data['carryover'] = self._format_value(options, carryover_value, 'monetary')
+
+ carryover_expression = line_expressions_map[carryover_expr_label]
+ if carryover_expression.carryover_target:
+ info_popup_data['carryover_target'] = carryover_expression._get_carryover_target_expression(options).report_line_name
+ # If it's not set, it means the carryover needs to target the same expression
+
+ applied_carryover_value = target_line_res_dict.get('_applied_carryover_%s' % column_expr_label, {}).get('value', 0)
+ if self.env.company.currency_id.compare_amounts(0, applied_carryover_value) != 0:
+ info_popup_data['applied_carryover'] = self._format_value(options, applied_carryover_value, 'monetary')
+ info_popup_data['allow_carryover_audit'] = self.env.user.has_group('base.group_no_one')
+ info_popup_data['expression_id'] = line_expressions_map['_applied_carryover_%s' % column_expr_label]['id']
+ info_popup_data['column_group_key'] = col_group_key
+
+ # Handle manual edition popup
+ edit_popup_data = {}
+ formatter_params = {}
+ if column_expression.engine == 'external' and column_expression.subformula \
+ and len(options['companies']) == 1 \
+ and (not options['available_vat_fiscal_positions'] or options['fiscal_position'] != 'all'):
+
+ # Compute rounding for manual values
+ rounding = None
+ if figure_type == 'integer':
+ rounding = 0
+ else:
+ rounding_opt_match = re.search(r"\Wrounding\W*=\W*(?P\d+)", column_expression.subformula)
+ if rounding_opt_match:
+ rounding = int(rounding_opt_match.group('rounding'))
+ elif figure_type == 'monetary':
+ rounding = self.env.company.currency_id.decimal_places
+
+ if 'editable' in column_expression.subformula:
+ edit_popup_data = {
+ 'column_group_key': col_group_key,
+ 'target_expression_id': column_expression.id,
+ 'rounding': rounding,
+ 'figure_type': figure_type,
+ 'column_value': column_value,
+ }
+
+ formatter_params['digits'] = rounding
+
+ # Handle editable financial budgets
+ editable_budget = groupby_model == 'account.account' and options['column_groups'][col_group_key]['forced_options'].get('compute_budget')
+ if editable_budget and self.env.user.has_group('account.group_account_manager'):
+ edit_popup_data = {
+ 'column_group_key': col_group_key,
+ 'target_expression_id': column_expression.id,
+ 'rounding': self.env.company.currency_id.decimal_places,
+ 'figure_type': 'monetary',
+ 'column_value': column_value,
+ }
+
+ # Build result
+ if column_value is not None: #In case column value is zero, we still want to go through the condition
+ foreign_currency_id = target_line_res_dict.get(f'_currency_{column_expr_label}', {}).get('value')
+ if foreign_currency_id:
+ formatter_params['currency'] = self.env['res.currency'].browse(foreign_currency_id)
+
+ column_data = self._build_column_dict(
+ column_value,
+ column_data,
+ options=options,
+ column_expression=column_expression if column_expression else None,
+ has_sublines=column_has_sublines,
+ report_line_id=line.id,
+ **formatter_params,
+ )
+
+ if info_popup_data:
+ column_data['info_popup_data'] = json.dumps(info_popup_data)
+
+ if edit_popup_data:
+ column_data['edit_popup_data'] = json.dumps(edit_popup_data)
+
+ columns.append(column_data)
+
+ return columns
+
+ def _build_column_dict(
+ self, col_value, col_data,
+ options=None, currency=False, digits=1,
+ column_expression=None, has_sublines=False,
+ report_line_id=None,
+ ):
+ # Empty column
+ if col_value is None and col_data is None:
+ return {}
+
+ col_data = col_data or {}
+ column_expression = column_expression or self.env['account.report.expression']
+ options = options or {}
+
+ blank_if_zero = column_expression.blank_if_zero or col_data.get('blank_if_zero', False)
+ figure_type = column_expression.figure_type or col_data.get('figure_type', 'string')
+
+ format_params = {}
+ if figure_type == 'monetary' and currency:
+ format_params['currency_id'] = currency.id
+ elif figure_type in ('float', 'percentage'):
+ format_params['digits'] = digits
+
+ col_group_key = col_data.get('column_group_key')
+
+ return {
+ 'auditable': col_value is not None
+ and column_expression.auditable
+ and not options['column_groups'][col_group_key]['forced_options'].get('compute_budget'),
+ 'blank_if_zero': blank_if_zero,
+ 'column_group_key': col_group_key,
+ 'currency': currency,
+ 'currency_symbol': (currency or self.env.company.currency_id).symbol if options.get('multi_currency') else None,
+ 'digits': digits,
+ 'expression_label': col_data.get('expression_label'),
+ 'figure_type': figure_type,
+ 'green_on_positive': column_expression.green_on_positive,
+ 'has_sublines': has_sublines,
+ 'is_zero': col_value is None or (
+ isinstance(col_value, (int, float))
+ and figure_type in NUMBER_FIGURE_TYPES
+ and self._is_value_zero(col_value, figure_type, format_params)
+ ),
+ 'no_format': col_value,
+ 'format_params': format_params,
+ 'report_line_id': report_line_id,
+ 'sortable': col_data.get('sortable', False),
+ 'comparison_mode': col_data.get('comparison_mode'),
+ }
+
+ def _inject_account_names_for_consolidation(self, lines):
+ """ When grouping by account_code, in order to make the consolidation clearer, we add the account name in the context
+ of the current company next to the account_code.
+ """
+ account_codes = []
+ for line in lines:
+ markup = self._get_markup(line['id'])
+ if isinstance(markup, dict) and markup.get('groupby') == 'account_code':
+ account_codes.append(line['name'])
+ if not account_codes:
+ return
+
+ account_code_to_account_name_dict = {account.code: account.name for account in self.env['account.account'].search([
+ *self.env['account.account']._check_company_domain(self.env.company),
+ ('code', 'in', account_codes),
+ ])}
+ for line in lines:
+ markup = self._get_markup(line['id'])
+ if isinstance(markup, dict) and markup.get('groupby') == 'account_code':
+ account_code = line['name']
+ account_name = account_code_to_account_name_dict.get(account_code)
+ if account_code and account_name:
+ line['name'] = f'{account_code} {account_name}'
+
+ def _get_dynamic_lines(self, options, all_column_groups_expression_totals, warnings=None):
+ if self.custom_handler_model_id:
+ rslt = self.env[self.custom_handler_model_name]._dynamic_lines_generator(self, options, all_column_groups_expression_totals, warnings=warnings)
+ self._apply_integer_rounding_to_dynamic_lines(options, (line for _sequence, line in rslt))
+ return rslt
+ return []
+
+ def _apply_integer_rounding_to_dynamic_lines(self, options, dynamic_lines):
+ if options.get('integer_rounding_enabled'):
+ for line in dynamic_lines:
+ for column_dict in line.get('columns', []):
+ if 'name' not in column_dict and column_dict.get('figure_type') == 'monetary' and column_dict.get('no_format'):
+ # If 'name' is already in it, no need to round the amount ; it is forced by the custom report already
+ column_dict['no_format'] = float_round(
+ column_dict['no_format'],
+ precision_digits=0,
+ rounding_method=options['integer_rounding'],
+ )
+
+ def _compute_expression_totals_for_each_column_group(self, expressions, options,
+ groupby_to_expand=None, forced_all_column_groups_expression_totals=None, col_groups_restrict=None, offset=0, limit=None, include_default_vals=False, warnings=None):
+ """
+ Main computation function for static lines.
+
+ :param expressions: The account.report.expression objects to evaluate.
+
+ :param options: The options dict for this report, obtained from.get_options({}).
+
+ :param groupby_to_expand: The full groupby string for the grouping we want to evaluate. If None, the aggregated value will be computed.
+ For example, when evaluating a group by partner_id, which further will be divided in sub-groups by account_id,
+ then id, the full groupby string will be: 'partner_id, account_id, id'.
+
+ :param forced_all_column_groups_expression_totals: The expression totals already computed for this report, to which we will add the
+ new totals we compute for expressions (or update the existing ones if some
+ expressions are already in forced_all_column_groups_expression_totals). This is
+ a dict in the same format as returned by this function.
+ This parameter is for example used when adding manual values, where only
+ the expressions possibly depending on the new manual value
+ need to be updated, while we want to keep all the other values as-is.
+
+ :param col_groups_restrict: List of column group keys of the groups to compute. Other column groups will be ignored, and will
+ not be added to the result of this function (they can still be provided beforehand through
+ forced_all_column_groups_expression_totals). If not provided, all colum groups will be computed.
+
+ :param offset: The SQL offset to use when computing the result of these expressions. Used if self.load_more_limit is set, to handle
+ the load more feature.
+
+ :param limit: The SQL limit to apply when computing these expressions' result. Used if self.load_more_limit is set, to handle
+ the load more feature.
+
+ :return: dict(column_group_key, expressions_totals), where:
+ - column group key is string identifying each column group in a unique way ; as in options['column_groups']
+ - expressions_totals is a dict in the format returned by _compute_expression_totals_for_single_column_group
+ """
+
+ def add_expressions_to_groups(expressions_to_add, grouped_formulas, force_date_scope=None):
+ """ Groups the expressions that should be computed together.
+ """
+ for expression in expressions_to_add:
+ engine = expression.engine
+
+ if engine not in grouped_formulas:
+ grouped_formulas[engine] = {}
+
+ date_scope = force_date_scope or self._standardize_date_scope_for_date_range(expression.date_scope)
+ groupby_data = expression.report_line_id._parse_groupby(options, groupby_to_expand=groupby_to_expand)
+
+ next_groupby = groupby_data['next_groupby'] if engine not in NO_NEXT_GROUPBY_ENGINES else None
+ grouping_key = (date_scope, groupby_data['current_groupby'], next_groupby)
+
+ if grouping_key not in grouped_formulas[engine]:
+ grouped_formulas[engine][grouping_key] = {}
+
+ formula = expression.formula
+
+ if expression.engine == 'aggregation' and expression.formula == 'sum_children':
+ formula = ' + '.join(
+ f'_expression:{child_expr.id}'
+ for child_expr in expression.report_line_id.children_ids.expression_ids.filtered(lambda e: e.label == expression.label)
+ )
+
+ if formula not in grouped_formulas[engine][grouping_key]:
+ grouped_formulas[engine][grouping_key][formula] = expression
+ else:
+ grouped_formulas[engine][grouping_key][formula] |= expression
+
+ if groupby_to_expand and any(not expression.report_line_id._get_groupby(options) for expression in expressions):
+ raise UserError(_("Trying to expand groupby results on lines without a groupby value."))
+
+ # Group formulas for batching (when possible)
+ grouped_formulas = {}
+ if expressions and not include_default_vals:
+ expressions = expressions.filtered(lambda x: not x.label.startswith('_default'))
+ for expression in expressions:
+ add_expressions_to_groups(expression, grouped_formulas)
+
+ if expression.engine == 'aggregation' and expression.subformula == 'cross_report':
+ # Always expand aggregation expressions, in case their subexpressions are not in expressions parameter
+ # (this can happen in cross report, or when auditing an individual aggregation expression)
+ expanded_cross = expression._expand_aggregations()
+ forced_date_scope = self._standardize_date_scope_for_date_range(expression.date_scope)
+ add_expressions_to_groups(expanded_cross, grouped_formulas, force_date_scope=forced_date_scope)
+
+ # Treat each formula batch for each column group
+ all_column_groups_expression_totals = {}
+ for group_key, group_options in self._split_options_per_column_group(options).items():
+ if forced_all_column_groups_expression_totals:
+ forced_column_group_totals = forced_all_column_groups_expression_totals.get(group_key, None)
+ else:
+ forced_column_group_totals = None
+
+ if not col_groups_restrict or group_key in col_groups_restrict:
+ current_group_expression_totals = self._compute_expression_totals_for_single_column_group(
+ group_options,
+ grouped_formulas,
+ forced_column_group_expression_totals=forced_column_group_totals,
+ offset=offset,
+ limit=limit,
+ warnings=warnings,
+ )
+ else:
+ current_group_expression_totals = forced_column_group_totals
+
+ all_column_groups_expression_totals[group_key] = current_group_expression_totals
+
+ return all_column_groups_expression_totals
+
+ def _standardize_date_scope_for_date_range(self, date_scope):
+ """ Depending on the fact the report accepts date ranges or not, different date scopes might mean the same thing.
+ This function is used so that, in those cases, only one of these date_scopes' values is used, to avoid useless creation
+ of multiple computation batches and improve the overall performance as much as possible.
+ """
+ if not self.filter_date_range and date_scope == 'strict_range':
+ return 'from_beginning'
+ else:
+ return date_scope
+
+ def _split_options_per_column_group(self, options):
+ """ Get a specific option dict per column group, each enforcing the comparison and horizontal grouping associated
+ with the column group. Each of these options dict will contain a new key 'owner_column_group', with the column group key of the
+ group it was generated for.
+
+ :param options: The report options upon which the returned options be be based.
+
+ :return: A dict(column_group_key, options_dict), where column_group_key is the string identifying each column group (the keys
+ of options['column_groups'], and options_dict the generated options for this group.
+ """
+ options_per_group = {}
+ for group_key in options['column_groups']:
+ group_options = self._get_column_group_options(options, group_key)
+ options_per_group[group_key] = group_options
+
+ return options_per_group
+
+ def _get_column_group_options(self, options, group_key):
+ column_group = options['column_groups'][group_key]
+ return {
+ **options,
+ **column_group['forced_options'],
+ 'forced_domain': options.get('forced_domain', []) + column_group['forced_domain'] + column_group['forced_options'].get('forced_domain', []),
+ 'owner_column_group': group_key,
+ }
+
+ def _compute_expression_totals_for_single_column_group(self, column_group_options, grouped_formulas, forced_column_group_expression_totals=None, offset=0, limit=None, warnings=None):
+ """ Evaluates expressions for a single column group.
+
+ :param column_group_options: The options dict obtained from _split_options_per_column_group() for the column group to evaluate.
+
+ :param grouped_formulas: A dict(engine, formula_dict), where:
+ - engine is a string identifying a report engine, in the same format as in account.report.expression's engine
+ field's technical labels.
+ - formula_dict is a dict in the same format as _compute_formula_batch's formulas_dict parameter,
+ containing only aggregation formulas.
+
+ :param forced_column_group_expression_totals: The expression totals previously computed, in the same format as this function's result.
+ If provided, the result of this function will be an updated version of this parameter,
+ recomputing the expressions in grouped_fomulas.
+
+ :param offset: The SQL offset to use when computing the result of these expressions. Used if self.load_more_limit is set, to handle
+ the load more feature.
+
+ :param limit: The SQL limit to apply when computing these expressions' result. Used if self.load_more_limit is set, to handle
+ the load more feature.
+
+ :return: A dict(expression, {'value': value, 'has_sublines': has_sublines}), where:
+ - expression is one of the account.report.expressions that got evaluated
+
+ - value is the result of that evaluation. Two cases are possible:
+ - if we're evaluating a groupby: value will then be a in the form [(groupby_key, group_val)], where
+ - groupby_key is the key used in the SQL GROUP BY clause to generate this result
+ - group_val: The result computed by the engine for this group. Typically a float.
+
+ - else: value will directly be the result computed for this expression
+
+ - has_sublines: [optional key, will default to False if absent]
+ Whether or not this result corresponds to 1 or more subelements in the database (typically move lines).
+ This is used to know whether an unfoldable line has results to unfold in the UI.
+ """
+ def inject_formula_results(formula_results, column_group_expression_totals, cross_report_expression_totals=None):
+ for (_key, expressions), result in formula_results.items():
+ for expression in expressions:
+ subformula_error_format = _(
+ 'Invalid subformula in expression "%(expression)s" of line "%(line)s": %(subformula)s',
+ expression=expression.label,
+ line=expression.report_line_id.name,
+ subformula=expression.subformula,
+ )
+ if expression.engine not in ('aggregation', 'external') and expression.subformula:
+ # aggregation subformulas behave differently (cross_report is markup ; if_below, if_above and force_between need evaluation)
+ # They are directly handled in aggregation engine
+ result_value_key = expression.subformula
+ else:
+ result_value_key = 'result'
+
+ # The expression might be signed, so we can't just access the dict key, and directly evaluate it instead.
+
+ if isinstance(result, list):
+ # Happens when expanding a groupby line, to compute its children.
+ # We then want to keep a list(grouping key, total) as the final result of each total
+ expression_value = []
+ expression_has_sublines = False
+ for key, result_dict in result:
+ try:
+ expression_value.append((key, safe_eval(result_value_key, result_dict)))
+ except (ValueError, SyntaxError):
+ raise UserError(subformula_error_format)
+ expression_has_sublines = expression_has_sublines or result_dict.get('has_sublines')
+ else:
+ # For non-groupby lines, we directly set the total value for the line.
+ try:
+ expression_value = safe_eval(result_value_key, result)
+ except (ValueError, SyntaxError):
+ raise UserError(subformula_error_format)
+ expression_has_sublines = result.get('has_sublines')
+
+ if column_group_options.get('integer_rounding_enabled'):
+ in_monetary_column = any(
+ col['expression_label'] == expression.label
+ for col in column_group_options['columns']
+ if col['figure_type'] == 'monetary'
+ )
+
+ if (in_monetary_column and not expression.figure_type) or expression.figure_type == 'monetary':
+ method = column_group_options['integer_rounding']
+ if isinstance(expression_value, list):
+ expression_value = [(key, float_round(value, precision_digits=0, rounding_method=method)) for key, value in expression_value]
+ else:
+ expression_value = float_round(expression_value, precision_digits=0, rounding_method=method)
+
+ expression_result = {
+ 'value': expression_value,
+ 'has_sublines': expression_has_sublines,
+ }
+
+ if expression.report_line_id.report_id == self:
+ if expression in column_group_expression_totals:
+ # This can happen because of a cross report aggregation referencing an expression of its own report,
+ # but forcing a different date_scope onto it. This case is not supported for now ; splitting the aggregation can be
+ # used as a workaround.
+ raise UserError(_(
+ "Expression labelled '%(label)s' of line '%(line)s' is being overwritten when computing the current report. "
+ "Make sure the cross-report aggregations of this report only reference terms belonging to other reports.",
+ label=expression.label, line=expression.report_line_id.name
+ ))
+ column_group_expression_totals[expression] = expression_result
+ elif cross_report_expression_totals is not None:
+ # Entering this else means this expression needs to be evaluated because of a cross_report aggregation
+ cross_report_expression_totals[expression] = expression_result
+
+ # Batch each engine that can be
+ column_group_expression_totals = dict(forced_column_group_expression_totals) if forced_column_group_expression_totals else {}
+ cross_report_expr_totals_by_scope = {}
+ batchable_engines = [
+ selection_val[0]
+ for selection_val in self.env['account.report.expression']._fields['engine'].selection
+ if selection_val[0] != 'aggregation'
+ ]
+ for engine in batchable_engines:
+ for (date_scope, current_groupby, next_groupby), formulas_dict in grouped_formulas.get(engine, {}).items():
+ formula_results = self._compute_formula_batch(column_group_options, engine, date_scope, formulas_dict, current_groupby, next_groupby,
+ offset=offset, limit=limit, warnings=warnings)
+ inject_formula_results(
+ formula_results,
+ column_group_expression_totals,
+ cross_report_expression_totals=cross_report_expr_totals_by_scope.setdefault(date_scope, {})
+ )
+
+ # Now that everything else has been computed, resolve aggregation expressions
+ # (they can't be treated as the other engines, as if we batch them per date_scope, we'll not be able
+ # to compute expressions depending on other expressions with a different date scope).
+ aggregation_formulas_dict = {}
+ for (date_scope, _current_groupby, _next_groupby), formulas_dict in grouped_formulas.get('aggregation', {}).items():
+ for formula, expressions in formulas_dict.items():
+ for expression in expressions:
+ # group_by are ignored by this engine, so we merge every grouped entry into a common dict
+ forced_date_scope = date_scope if expression.subformula == 'cross_report' or expression.report_line_id.report_id != self else None
+ aggreation_formula_dict_key = (formula, forced_date_scope)
+ aggregation_formulas_dict.setdefault(aggreation_formula_dict_key, self.env['account.report.expression'])
+ aggregation_formulas_dict[aggreation_formula_dict_key] |= expression
+
+ if aggregation_formulas_dict:
+ aggregation_formula_results = self._compute_totals_no_batch_aggregation(column_group_options, aggregation_formulas_dict, column_group_expression_totals, cross_report_expr_totals_by_scope)
+ inject_formula_results(aggregation_formula_results, column_group_expression_totals)
+
+ return column_group_expression_totals
+
+ def _compute_totals_no_batch_aggregation(self, column_group_options, formulas_dict, other_current_report_expr_totals, other_cross_report_expr_totals_by_scope):
+ """ Computes expression totals for 'aggregation' engine, after all other engines have been evaluated.
+
+ :param column_group_options: The options for the column group being evaluated, as obtained from _split_options_per_column_group.
+
+ :param formulas_dict: A dict {(formula, forced_date_scope): expressions}, containing only aggregation formulas.
+ forced_date_scope will only be set in case of cross_report expressions. Else, it will be None
+
+ :param other_current_report_expr_totals: The expressions_totals obtained after computing all non-aggregation engines, for the expressions
+ belonging directly to self (so, not the ones referenced by a cross_report aggreation).
+ This is a dict in the same format as _compute_expression_totals_for_single_column_group's result
+ (the only difference being it does not contain any aggregation expression yet).
+
+ :param other_cross_report_expr_totals: A dict(forced_date_scope, expression_totals), where expression_totals is in the same form as
+ _compute_expression_totals_for_single_column_group's result. This parameter contains the results
+ of the non-aggregation expressions used by cross_report expressions ; they all belong to different
+ reports than self. The forced_date_scope corresponds to the original date_scope set on the
+ cross_report expression referencing them. The same expressions can be referenced multiple times
+ under different date scopes.
+
+ :return : A dict((formula, expressions), result), where result is in the form {'result': numeric_value}
+ """
+ def _resolve_subformula_on_dict(result, line_codes_expression_map, subformula):
+ split_subformula = subformula.split('.')
+ if len(split_subformula) > 1:
+ line_code, expression_label = split_subformula
+ return result[line_codes_expression_map[line_code][expression_label]]
+
+ if subformula.startswith('_expression:'):
+ expression_id = int(subformula.split(':')[1])
+ return result[expression_id]
+
+ # Wrong subformula; the KeyError is caught in the function below
+ raise KeyError()
+
+ def _check_is_float(to_test):
+ try:
+ float(to_test)
+ return True
+ except ValueError:
+ return False
+
+ def add_expression_to_map(expression, expression_res, figure_types_cache, current_report_eval_dict, current_report_codes_map, other_reports_eval_dict, other_reports_codes_map, cross_report=False):
+ """
+ Process an expression and its result, updating various dictionaries with relevant information.
+ Parameters:
+ - expression (object): The expression object to process.
+ - expression_res (dict): The result of the expression.
+ - figure_types_cache (dict): {report : {label: figure_type}}.
+ - current_report_eval_dict (dict): {expression_id: value}.
+ - current_report_codes_map (dict): {line_code: {expression_label: expression_id}}.
+ - other_reports_eval_dict (dict): {forced_date_scope: {expression_id: value}}.
+ - other_reports_codes_map (dict): {forced_date_scope: {line_code: {expression_label: expression_id}}}.
+ - cross_report: A boolean to know if we are processsing cross_report expression.
+ """
+
+ expr_report = expression.report_line_id.report_id
+ report_default_figure_types = figure_types_cache.setdefault(expr_report, {})
+ expression_label = report_default_figure_types.get(expression.label, '_not_in_cache')
+ if expression_label == '_not_in_cache':
+ report_default_figure_types[expression.label] = expr_report.column_ids.filtered(
+ lambda x: x.expression_label == expression.label).figure_type
+
+ default_figure_type = figure_types_cache[expr_report][expression.label]
+ figure_type = expression.figure_type or default_figure_type
+ value = expression_res['value']
+ if figure_type == 'monetary' and value:
+ value = self.env.company.currency_id.round(value)
+
+ if cross_report:
+ other_reports_eval_dict.setdefault(forced_date_scope, {})[expression.id] = value
+ else:
+ current_report_eval_dict[expression.id] = value
+
+ current_report_eval_dict = {} # {expression_id: value}
+ other_reports_eval_dict = {} # {forced_date_scope: {expression_id: value}}
+ current_report_codes_map = {} # {line_code: {expression_label: expression_id}}
+ other_reports_codes_map = {} # {forced_date_scope: {line_code: {expression_label: expression_id}}}
+
+ figure_types_cache = {} # {report : {label: figure_type}}
+ for expression, expression_res in other_current_report_expr_totals.items():
+ add_expression_to_map(expression, expression_res, figure_types_cache, current_report_eval_dict, current_report_codes_map, other_reports_eval_dict, other_reports_codes_map)
+ if expression.report_line_id.code:
+ current_report_codes_map.setdefault(expression.report_line_id.code, {})[expression.label] = expression.id
+
+ for forced_date_scope, scope_expr_totals in other_cross_report_expr_totals_by_scope.items():
+ for expression, expression_res in scope_expr_totals.items():
+ add_expression_to_map(expression, expression_res, figure_types_cache, current_report_eval_dict, current_report_codes_map, other_reports_eval_dict, other_reports_codes_map, True)
+ if expression.report_line_id.code:
+ other_reports_codes_map.setdefault(forced_date_scope, {}).setdefault(expression.report_line_id.code, {})[expression.label] = expression.id
+
+ # Complete current_report_eval_dict with the formulas of uncomputed aggregation lines
+ aggregations_terms_to_evaluate = set() # Those terms are part of the formulas to evaluate; we know they will get a value eventually
+ for (formula, forced_date_scope), expressions in formulas_dict.items():
+ for expression in expressions:
+ aggregations_terms_to_evaluate.add(f"_expression:{expression.id}") # In case it needs to be called by sum_children
+
+ if expression.report_line_id.code:
+ if expression.report_line_id.report_id == self:
+ current_report_codes_map.setdefault(expression.report_line_id.code, {})[expression.label] = expression.id
+ else:
+ other_reports_codes_map.setdefault(forced_date_scope, {}).setdefault(expression.report_line_id.code, {})[expression.label] = expression.id
+
+ aggregations_terms_to_evaluate.add(f"{expression.report_line_id.code}.{expression.label}")
+
+ if not expression.subformula:
+ # Expressions with bounds cannot be replaced by their formula in formulas calling them (otherwize, bounds would be ignored).
+ # Same goes for cross_report, otherwise the forced_date_scope will be ignored, leading to an impossibility to get evaluate the expression.
+ if expression.report_line_id.report_id == self:
+ eval_dict = current_report_eval_dict
+ else:
+ eval_dict = other_reports_eval_dict.setdefault(forced_date_scope, {})
+
+ eval_dict[expression.id] = formula
+
+ rslt = {}
+ to_treat = [(formula, formula, forced_date_scope) for (formula, forced_date_scope) in formulas_dict.keys()] # Formed like [(expanded formula, original unexpanded formula)]
+ term_separator_regex = r'(?\w+)\("
+ r"(?P\w+)[.](?P\w+),[ ]*"
+ r"(?P.*)\)$",
+ expression.subformula
+ )
+ if not other_expr_criterium_match:
+ raise UserError(_("Wrong format for if_other_expr_above/if_other_expr_below formula: %s", expression.subformula))
+
+ criterium_code = other_expr_criterium_match['line_code']
+ criterium_label = other_expr_criterium_match['expr_label']
+ criterium_expression_id = full_codes_map.get(criterium_code, {}).get(criterium_label)
+ criterium_val = full_eval_dict.get(criterium_expression_id)
+
+ if not criterium_expression_id:
+ raise UserError(_("This subformula references an unknown expression: %s", expression.subformula))
+
+ if not isinstance(criterium_val, (float, int)):
+ # The criterium expression has not be evaluated yet. Postpone the evaluation of this formula, and skip this expression
+ # for now. We still try to evaluate other expressions using this formula if any; this means those expressions will
+ # be processed a second time later, giving the same result. This is a rare corner case, and not so costly anyway.
+ to_treat.append((formula, unexpanded_formula, forced_date_scope))
+ continue
+
+ bound_subformula = other_expr_criterium_match['criterium'].replace('other_expr_', '') # e.g. 'if_other_expr_above' => 'if_above'
+ bound_params = other_expr_criterium_match['bound_params']
+ bound_value = self._aggregation_apply_bounds(column_group_options, f"{bound_subformula}({bound_params})", criterium_val)
+ expression_result = formula_result * int(bool(bound_value))
+
+ else:
+ expression_result = self._aggregation_apply_bounds(column_group_options, expression.subformula, formula_result)
+
+ if column_group_options.get('integer_rounding_enabled'):
+ expression_result = float_round(expression_result, precision_digits=0, rounding_method=column_group_options['integer_rounding'])
+
+ # Store result
+ standardized_expression_scope = self._standardize_date_scope_for_date_range(expression.date_scope)
+ if (forced_date_scope == standardized_expression_scope or not forced_date_scope) and expression.report_line_id.report_id == self:
+ # This condition ensures we don't return necessary subcomputations in the final result
+ rslt[(unexpanded_formula, expression)] = {'result': expression_result}
+
+ # Handle recursive aggregations (explicit or through the sum_children shortcut).
+ # We need to make the result of our computation available to other aggregations, as they are still waiting in to_treat to be evaluated.
+ if expression.report_line_id.report_id == self:
+ current_report_eval_dict[expression.id] = expression_result
+ else:
+ other_reports_eval_dict.setdefault(forced_date_scope, {})[expression.id] = expression_result
+
+ return rslt
+
+ def _aggregation_apply_bounds(self, column_group_options, subformula, unbound_value):
+ """ Applies the bounds of the provided aggregation expression to an unbounded value that got computed for it and returns the result.
+ Bounds can be defined as subformulas of aggregation expressions, with the following possible values:
+
+ - if_above(CUR(bound_value)):
+ => Result will be 0 if it's <= the provided bound value; else it'll be unbound_value
+
+ - if_below(CUR(bound_value)):
+ => Result will be 0 if it's >= the provided bound value; else it'll be unbound_value
+
+ - if_between(CUR(bound_value1), CUR(bound_value2)):
+ => Result will be unbound_value if it's strictly between the provided bounds. Else, it will
+ be brought back to the closest bound.
+
+ - round(decimal_places):
+ => Result will be round(unbound_value, decimal_places)
+
+ (where CUR is a currency code, and bound_value* are float amounts in CUR currency)
+ """
+ if not subformula:
+ return unbound_value
+
+ # So an expression can't have bounds and be cross_reports, for simplicity.
+ # To do that, just split the expression in two parts.
+ if subformula and subformula.startswith('round'):
+ precision_string = re.match(r"round\((?P\d+)\)", subformula)['precision']
+ return round(unbound_value, int(precision_string))
+
+ if subformula not in {'cross_report', 'ignore_zero_division'}:
+ company_currency = self.env.company.currency_id
+ date_to = column_group_options['date']['date_to']
+
+ match = re.match(
+ r"(?P\w*)"
+ r"\((?P[A-Z]{3})\((?P[-]?\d+(\.\d+)?)\)"
+ r"(,(?P[A-Z]{3})\((?P[-]?\d+(\.\d+)?)\))?\)$",
+ subformula.replace(' ', '')
+ )
+ group_values = match.groupdict()
+
+ # Convert the provided bounds into company currency
+ currency_code_1 = group_values.get('currency_1')
+ currency_code_2 = group_values.get('currency_2')
+ currency_codes = [
+ currency_code
+ for currency_code in [currency_code_1, currency_code_2]
+ if currency_code and currency_code != company_currency.name
+ ]
+
+ if currency_codes:
+ currencies = self.env['res.currency'].with_context(active_test=False).search([('name', 'in', currency_codes)])
+ else:
+ currencies = self.env['res.currency']
+
+ amount_1 = float(group_values['amount_1'] or 0)
+ amount_2 = float(group_values['amount_2'] or 0)
+ for currency in currencies:
+ if currency != company_currency:
+ if currency.name == currency_code_1:
+ amount_1 = currency._convert(amount_1, company_currency, self.env.company, date_to)
+ if amount_2 and currency.name == currency_code_2:
+ amount_2 = currency._convert(amount_2, company_currency, self.env.company, date_to)
+
+ # Evaluate result
+ criterium = group_values['criterium']
+ if criterium == 'if_below':
+ if company_currency.compare_amounts(unbound_value, amount_1) >= 0:
+ return 0
+ elif criterium == 'if_above':
+ if company_currency.compare_amounts(unbound_value, amount_1) <= 0:
+ return 0
+ elif criterium == 'if_between':
+ if company_currency.compare_amounts(unbound_value, amount_1) < 0 or company_currency.compare_amounts(unbound_value, amount_2) > 0:
+ return 0
+ else:
+ raise UserError(_("Unknown bound criterium: %s", criterium))
+
+ return unbound_value
+
+ def _compute_formula_batch(self, column_group_options, formula_engine, date_scope, formulas_dict, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ """ Evaluates a batch of formulas.
+
+ :param column_group_options: The options for the column group being evaluated, as obtained from _split_options_per_column_group.
+
+ :param formula_engine: A string identifying a report engine. Must be one of account.report.expression's engine field's technical labels.
+
+ :param date_scope: The date_scope under which to evaluate the fomulas. Must be one of account.report.expression's date_scope field's
+ technical labels.
+
+ :param formulas_dict: A dict in the dict(formula, expressions), where:
+ - formula: a formula to be evaluated with the engine referred to by parent dict key
+ - expressions: a recordset of all the expressions to evaluate using formula (possibly with distinct subformulas)
+
+ :param current_groupby: The groupby to evaluate, or None if there isn't any. In case of multi-level groupby, only contains the element
+ that needs to be computed (so, if unfolding a line doing 'partner_id,account_id,id'; current_groupby will only be
+ 'partner_id'). Subsequent groupby will be in next_groupby.
+
+ :param next_groupby: Full groupby string of the groups that will have to be evaluated next for these expressions, or None if there isn't any.
+ For example, in the case depicted in the example of current_groupby, next_groupby will be 'account_id,id'.
+
+ :param offset: The SQL offset to use when computing the result of these expressions.
+
+ :param limit: The SQL limit to apply when computing these expressions' result.
+
+ :return: The result might have two different formats depending on the situation:
+ - if we're computing a groupby: {(formula, expressions): [(grouping_key, {'result': value, 'has_sublines': boolean}), ...], ...}
+ - if we're not: {(formula, expressions): {'result': value, 'has_sublines': boolean}, ...}
+ 'result' key is the default; different engines might use one or multiple other keys instead, depending of the subformulas they allow
+ (e.g. 'sum', 'sum_if_pos', ...)
+ """
+ engine_function_name = f'_compute_formula_batch_with_engine_{formula_engine}'
+ return getattr(self, engine_function_name)(
+ column_group_options, date_scope, formulas_dict, current_groupby, next_groupby,
+ offset=offset, limit=limit, warnings=warnings,
+ )
+
+ def _compute_formula_batch_with_engine_tax_tags(self, options, date_scope, formulas_dict, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ """ Report engine.
+
+ The formulas made for this report simply consist of a tag label. When an expression using this engine is created, it also creates two
+ account.account.tag objects, namely -tag and +tag, where tag is the chosen formula. The balance of the expressions using this engine is
+ computed by gathering all the move lines using their tags, and applying the sign of their tag to their balance, together with a -1 factor
+ if the tax_tag_invert field of the move line is True.
+
+ This engine does not support any subformula.
+ """
+ self._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else []))
+ all_expressions = self.env['account.report.expression']
+ for expressions in formulas_dict.values():
+ all_expressions |= expressions
+ tags = all_expressions._get_matching_tags()
+
+ query = self._get_report_query(options, date_scope)
+ groupby_sql = self.env['account.move.line']._field_to_sql('account_move_line', current_groupby, query) if current_groupby else None
+ tail_query = self._get_engine_query_tail(offset, limit)
+ lang = get_lang(self.env, self.env.user.lang).code
+ acc_tag_name = self.with_context(lang='en_US').env['account.account.tag']._field_to_sql('acc_tag', 'name')
+ sql = SQL(
+ """
+ SELECT
+ SUBSTRING(%(acc_tag_name)s, 2, LENGTH(%(acc_tag_name)s) - 1) AS formula,
+ SUM(%(balance_select)s
+ * CASE WHEN acc_tag.tax_negate THEN -1 ELSE 1 END
+ * CASE WHEN account_move_line.tax_tag_invert THEN -1 ELSE 1 END
+ ) AS balance,
+ COUNT(account_move_line.id) AS aml_count
+ %(select_groupby_sql)s
+
+ FROM %(table_references)s
+
+ JOIN account_account_tag_account_move_line_rel aml_tag
+ ON aml_tag.account_move_line_id = account_move_line.id
+ JOIN account_account_tag acc_tag
+ ON aml_tag.account_account_tag_id = acc_tag.id
+ AND acc_tag.id IN %(tag_ids)s
+ %(currency_table_join)s
+
+ WHERE %(search_condition)s
+
+ GROUP BY %(groupby_clause)s
+
+ ORDER BY %(groupby_clause)s
+
+ %(tail_query)s
+ """,
+ acc_tag_name=acc_tag_name,
+ select_groupby_sql=SQL(', %s AS grouping_key', groupby_sql) if groupby_sql else SQL(),
+ table_references=query.from_clause,
+ tag_ids=tuple(tags.ids),
+ balance_select=self._currency_table_apply_rate(SQL("account_move_line.balance")),
+ currency_table_join=self._currency_table_aml_join(options),
+ search_condition=query.where_clause,
+ groupby_clause=SQL(
+ "SUBSTRING(%(acc_tag_name)s, 2, LENGTH(%(acc_tag_name)s) - 1)%(groupby_sql)s",
+ acc_tag_name=acc_tag_name,
+ groupby_sql=SQL(', %s', groupby_sql) if groupby_sql else SQL(),
+ ),
+ tail_query=tail_query,
+ )
+
+ self._cr.execute(sql)
+
+ rslt = {formula_expr: [] if current_groupby else {'result': 0, 'has_sublines': False} for formula_expr in formulas_dict.items()}
+ for query_res in self._cr.dictfetchall():
+
+ formula = query_res['formula']
+ rslt_dict = {'result': query_res['balance'], 'has_sublines': query_res['aml_count'] > 0}
+ if current_groupby:
+ rslt[(formula, formulas_dict[formula])].append((query_res['grouping_key'], rslt_dict))
+ else:
+ rslt[(formula, formulas_dict[formula])] = rslt_dict
+
+ return rslt
+
+ def _compute_formula_batch_with_engine_domain(self, options, date_scope, formulas_dict, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ """ Report engine.
+
+ Formulas made for this engine consist of a domain on account.move.line. Only those move lines will be used to compute the result.
+
+ This engine supports a few subformulas, each returning a slighlty different result:
+ - sum: the result will be sum of the matched move lines' balances
+
+ - sum_if_pos: the result will be the same as sum only if it's positive; else, it will be 0
+
+ - sum_if_neg: the result will be the same as sum only if it's negative; else, it will be 0
+
+ - count_rows: the result will be the number of sublines this expression has. If the parent report line has no groupby,
+ then it will be the number of matching amls. If there is a groupby, it will be the number of distinct grouping
+ keys at the first level of this groupby (so, if groupby is 'partner_id, account_id', the number of partners).
+ """
+ def _format_result_depending_on_groupby(formula_rslt):
+ if not current_groupby:
+ if formula_rslt:
+ # There should be only one element in the list; we only return its totals (a dict) ; so that a list is only returned in case
+ # of a groupby being unfolded.
+ return formula_rslt[0][1]
+ else:
+ # No result at all
+ return {
+ 'sum': 0,
+ 'sum_if_pos': 0,
+ 'sum_if_neg': 0,
+ 'count_rows': 0,
+ 'has_sublines': False,
+ }
+ return formula_rslt
+
+ self._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else []))
+
+ rslt = {}
+
+ for formula, expressions in formulas_dict.items():
+ try:
+ line_domain = literal_eval(formula)
+ except (ValueError, SyntaxError):
+ raise UserError(_(
+ 'Invalid domain formula in expression "%(expression)s" of line "%(line)s": %(formula)s',
+ expression=expressions.label,
+ line=expressions.report_line_id.name,
+ formula=formula,
+ ))
+ query = self._get_report_query(options, date_scope, domain=line_domain)
+
+ groupby_sql = self.env['account.move.line']._field_to_sql('account_move_line', current_groupby, query) if current_groupby else None
+ select_count_field = self.env['account.move.line']._field_to_sql('account_move_line', next_groupby.split(',')[0] if next_groupby else 'id', query)
+
+ tail_query = self._get_engine_query_tail(offset, limit)
+ query = SQL(
+ """
+ SELECT
+ COALESCE(SUM(%(balance_select)s), 0.0) AS sum,
+ COUNT(DISTINCT %(select_count_field)s) AS count_rows
+ %(select_groupby_sql)s
+ FROM %(table_references)s
+ %(currency_table_join)s
+ WHERE %(search_condition)s
+ %(group_by_groupby_sql)s
+ %(order_by_sql)s
+ %(tail_query)s
+ """,
+ select_count_field=select_count_field,
+ select_groupby_sql=SQL(', %s AS grouping_key', groupby_sql) if groupby_sql else SQL(),
+ table_references=query.from_clause,
+ balance_select=self._currency_table_apply_rate(SQL("account_move_line.balance")),
+ currency_table_join=self._currency_table_aml_join(options),
+ search_condition=query.where_clause,
+ group_by_groupby_sql=SQL('GROUP BY %s', groupby_sql) if groupby_sql else SQL(),
+ order_by_sql=SQL(' ORDER BY %s', groupby_sql) if groupby_sql else SQL(),
+ tail_query=tail_query,
+ )
+
+ # Fetch the results.
+ formula_rslt = []
+ self._cr.execute(query)
+ all_query_res = self._cr.dictfetchall()
+
+ total_sum = 0
+ for query_res in all_query_res:
+ res_sum = query_res['sum']
+ total_sum += res_sum
+ totals = {
+ 'sum': res_sum,
+ 'sum_if_pos': 0,
+ 'sum_if_neg': 0,
+ 'count_rows': query_res['count_rows'],
+ 'has_sublines': query_res['count_rows'] > 0,
+ }
+ formula_rslt.append((query_res.get('grouping_key', None), totals))
+
+ # Handle sum_if_pos, -sum_if_pos, sum_if_neg and -sum_if_neg
+ expressions_by_sign_policy = defaultdict(lambda: self.env['account.report.expression'])
+ for expression in expressions:
+ subformula_without_sign = expression.subformula.replace('-', '').strip()
+ if subformula_without_sign in ('sum_if_pos', 'sum_if_neg'):
+ expressions_by_sign_policy[subformula_without_sign] += expression
+ else:
+ expressions_by_sign_policy['no_sign_check'] += expression
+
+ # Then we have to check the total of the line and only give results if its sign matches the desired policy.
+ # This is important for groupby managements, for which we can't just check the sign query_res by query_res
+ if expressions_by_sign_policy['sum_if_pos'] or expressions_by_sign_policy['sum_if_neg']:
+ sign_policy_with_value = 'sum_if_pos' if self.env.company.currency_id.compare_amounts(total_sum, 0.0) >= 0 else 'sum_if_neg'
+ # >= instead of > is intended; usability decision: 0 is considered positive
+
+ formula_rslt_with_sign = [(grouping_key, {**totals, sign_policy_with_value: totals['sum']}) for grouping_key, totals in formula_rslt]
+
+ for sign_policy in ('sum_if_pos', 'sum_if_neg'):
+ policy_expressions = expressions_by_sign_policy[sign_policy]
+
+ if policy_expressions:
+ if sign_policy == sign_policy_with_value:
+ rslt[(formula, policy_expressions)] = _format_result_depending_on_groupby(formula_rslt_with_sign)
+ else:
+ rslt[(formula, policy_expressions)] = _format_result_depending_on_groupby([])
+
+ if expressions_by_sign_policy['no_sign_check']:
+ rslt[(formula, expressions_by_sign_policy['no_sign_check'])] = _format_result_depending_on_groupby(formula_rslt)
+
+ return rslt
+
+ def _compute_formula_batch_with_engine_account_codes(self, options, date_scope, formulas_dict, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ r""" Report engine.
+
+ Formulas made for this engine target account prefixes. Each of the prefix used in the formula will be evaluated as the sum of the move
+ lines made on the accounts matching it. Those prefixes can be used together with arithmetic operations to perform them on the obtained
+ results.
+ Example: '123 - 456' will substract the balance of all account starting with 456 from the one of all accounts starting with 123.
+
+ It is also possible to exclude some subprefixes, with \ operator.
+ Example: '123\(1234)' will match prefixes all accounts starting with '123', except the ones starting with '1234'
+
+ To only match the balance of an account is it's positive (debit) or negative (credit), the letter D or C can be put just next to the prefix:
+ Example '123D': will give the total balance of accounts starting with '123' if it's positive, else it will be evaluated as 0.
+
+ Multiple subprefixes can be excluded if needed.
+ Example: '123\(1234,1236)
+
+ All these syntaxes can be mixed together.
+ Example: '123D\(1235) + 56 - 416C'
+
+ Note: if C or D character needs to be part of the prefix, it is possible to differentiate them of debit and credit match characters
+ by using an empty prefix exclusion.
+ Example 1: '123D\' will take the total balance of accounts starting with '123D'
+ Example 2: '123D\C' will return the balance of accounts starting with '123D' if it's negative, 0 otherwise.
+ """
+ self._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else []))
+
+ # Gather the account code prefixes to compute the total from
+ prefix_details_by_formula = {} # in the form {formula: [(1, prefix1), (-1, prefix2)]}
+ prefixes_to_compute = set()
+ for formula in formulas_dict:
+ prefix_details_by_formula[formula] = []
+ for token in ACCOUNT_CODES_ENGINE_SPLIT_REGEX.split(formula.replace(' ', '')):
+ if token:
+ token_match = ACCOUNT_CODES_ENGINE_TERM_REGEX.match(token)
+
+ if not token_match:
+ raise UserError(_("Invalid token '%(token)s' in account_codes formula '%(formula)s'", token=token, formula=formula))
+
+ parsed_token = token_match.groupdict()
+
+ if not parsed_token:
+ raise UserError(_("Could not parse account_code formula from token '%s'", token))
+
+ multiplicator = -1 if parsed_token['sign'] == '-' else 1
+ excluded_prefixes_match = token_match['excluded_prefixes']
+ excluded_prefixes = excluded_prefixes_match.split(',') if excluded_prefixes_match else []
+ prefix = token_match['prefix']
+
+ # We group using both prefix and excluded_prefixes as keys, for the case where two expressions would
+ # include the same prefix, but exlcude different prefixes (example 104\(1041) and 104\(1042))
+ prefix_key = (prefix, *excluded_prefixes)
+ prefix_details_by_formula[formula].append((multiplicator, prefix_key, token_match['balance_character']))
+ prefixes_to_compute.add((prefix, tuple(excluded_prefixes)))
+
+ # Create the subquery for the WITH linking our prefixes with account.account entries
+ all_prefixes_queries: list[SQL] = []
+ prefilter = self.env['account.account']._check_company_domain(self.get_report_company_ids(options))
+ for prefix, excluded_prefixes in prefixes_to_compute:
+ account_domain = [
+ *prefilter,
+ ]
+
+ tag_match = ACCOUNT_CODES_ENGINE_TAG_ID_PREFIX_REGEX.match(prefix)
+
+ if tag_match:
+ if tag_match['ref']:
+ tag_id = self.env['ir.model.data']._xmlid_to_res_id(tag_match['ref'])
+ else:
+ tag_id = int(tag_match['id'])
+
+ account_domain.append(('tag_ids', 'in', [tag_id]))
+ else:
+ account_domain.append(('code', '=like', f'{prefix}%'))
+
+ excluded_prefixes_domains = []
+
+ for excluded_prefix in excluded_prefixes:
+ excluded_prefixes_domains.append([('code', '=like', f'{excluded_prefix}%')])
+
+ if excluded_prefixes_domains:
+ account_domain.append('!')
+ account_domain += osv.expression.OR(excluded_prefixes_domains)
+
+ prefix_query = self.env['account.account']._where_calc(account_domain)
+ all_prefixes_queries.append(prefix_query.select(
+ SQL("%s AS prefix", [prefix, *excluded_prefixes]),
+ SQL("account_account.id AS account_id"),
+ ))
+
+ # Build a map to associate each account with the prefixes it matches
+ accounts_prefix_map = defaultdict(list)
+ for prefix, account_id in self.env.execute_query(SQL(' UNION ALL ').join(all_prefixes_queries)):
+ accounts_prefix_map[account_id].append(tuple(prefix))
+
+ # Run main query
+ query = self._get_report_query(options, date_scope)
+
+ current_groupby_aml_sql = self.env['account.move.line']._field_to_sql('account_move_line', current_groupby, query) if current_groupby else None
+ tail_query = self._get_engine_query_tail(offset, limit)
+ if current_groupby_aml_sql and tail_query:
+ tail_query_additional_groupby_where_sql = SQL(
+ """
+ AND %(current_groupby_aml_sql)s IN (
+ SELECT DISTINCT %(current_groupby_aml_sql)s
+ FROM account_move_line
+ WHERE %(search_condition)s
+ ORDER BY %(current_groupby_aml_sql)s
+ %(tail_query)s
+ )
+ """,
+ current_groupby_aml_sql=current_groupby_aml_sql,
+ search_condition=query.where_clause,
+ tail_query=tail_query,
+ )
+ else:
+ tail_query_additional_groupby_where_sql = SQL()
+
+ extra_groupby_sql = SQL(", %s", current_groupby_aml_sql) if current_groupby_aml_sql else SQL()
+ extra_select_sql = SQL(", %s AS grouping_key", current_groupby_aml_sql) if current_groupby_aml_sql else SQL()
+
+ query = SQL(
+ """
+ SELECT
+ account_move_line.account_id AS account_id,
+ SUM(%(balance_select)s) AS sum,
+ COUNT(account_move_line.id) AS aml_count
+ %(extra_select_sql)s
+ FROM %(table_references)s
+ %(currency_table_join)s
+ WHERE %(search_condition)s
+ %(tail_query_additional_groupby_where_sql)s
+ GROUP BY account_move_line.account_id%(extra_groupby_sql)s
+ %(order_by_sql)s
+ %(tail_query)s
+ """,
+ extra_select_sql=extra_select_sql,
+ table_references=query.from_clause,
+ balance_select=self._currency_table_apply_rate(SQL("account_move_line.balance")),
+ currency_table_join=self._currency_table_aml_join(options),
+ search_condition=query.where_clause,
+ extra_groupby_sql=extra_groupby_sql,
+ tail_query_additional_groupby_where_sql=tail_query_additional_groupby_where_sql,
+ order_by_sql=SQL('ORDER BY %s', current_groupby_aml_sql) if current_groupby_aml_sql else SQL(),
+ tail_query=tail_query if not tail_query_additional_groupby_where_sql else SQL(),
+ )
+ self._cr.execute(query)
+
+ # Parse result
+ rslt = {}
+
+ res_by_prefix_account_id = {}
+ for query_res in self._cr.dictfetchall():
+ # Done this way so that we can run similar code for groupby and non-groupby
+ grouping_key = query_res['grouping_key'] if current_groupby else None
+ account_id = query_res['account_id']
+ for prefix_key in accounts_prefix_map[account_id]:
+ res_by_prefix_account_id.setdefault(prefix_key, {})\
+ .setdefault(account_id, [])\
+ .append((grouping_key, {'result': query_res['sum'], 'has_sublines': query_res['aml_count'] > 0}))
+
+ for formula, prefix_details in prefix_details_by_formula.items():
+ rslt_key = (formula, formulas_dict[formula])
+ rslt_destination = rslt.setdefault(rslt_key, [] if current_groupby else {'result': 0, 'has_sublines': False})
+ rslt_groups_by_grouping_keys = {}
+ for multiplicator, prefix_key, balance_character in prefix_details:
+ res_by_account_id = res_by_prefix_account_id.get(prefix_key, {})
+
+ for account_results in res_by_account_id.values():
+ account_total_value = sum(group_val['result'] for (group_key, group_val) in account_results)
+ comparator = self.env.company.currency_id.compare_amounts(account_total_value, 0.0)
+
+ # Manage balance_character.
+ if not balance_character or (balance_character == 'D' and comparator >= 0) or (balance_character == 'C' and comparator < 0):
+
+ for group_key, group_val in account_results:
+ rslt_group = {
+ **group_val,
+ 'result': multiplicator * group_val['result'],
+ }
+ if not current_groupby:
+ rslt_destination['result'] += rslt_group['result']
+ rslt_destination['has_sublines'] = rslt_destination['has_sublines'] or rslt_group['has_sublines']
+ elif group_key in rslt_groups_by_grouping_keys:
+ # Will happen if the same grouping key is used on move lines with different accounts.
+ # This comes from the GROUPBY in the SQL query, which uses both grouping key and account.
+ # When this happens, we want to aggregate the results of each grouping key, to avoid duplicates in the end result.
+ already_treated_rslt_group = rslt_groups_by_grouping_keys[group_key]
+ already_treated_rslt_group['has_sublines'] = already_treated_rslt_group['has_sublines'] or rslt_group['has_sublines']
+ already_treated_rslt_group['result'] += rslt_group['result']
+ else:
+ rslt_groups_by_grouping_keys[group_key] = rslt_group
+ rslt_destination.append((group_key, rslt_group))
+
+ return rslt
+
+ def _compute_formula_batch_with_engine_external(self, options, date_scope, formulas_dict, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ """ Report engine.
+
+ This engine computes its result from the account.report.external.value objects that are linked to the expression.
+
+ Two different formulas are possible:
+ - sum: if the result must be the sum of all the external values in the period.
+ - most_recent: it the result must be the value of the latest external value in the period, which can be a number or a text
+
+ No subformula is allowed for this engine.
+ """
+ self._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else []))
+
+ if current_groupby or next_groupby or offset or limit:
+ raise UserError(_("'external' engine does not support groupby, limit nor offset."))
+
+ # Date clause
+ date_from, date_to = self._get_date_bounds_info(options, date_scope)
+ external_value_domain = [('date', '<=', date_to)]
+ if date_from:
+ external_value_domain.append(('date', '>=', date_from))
+
+ # Company clause
+ external_value_domain.append(('company_id', 'in', self.get_report_company_ids(options)))
+
+ # Fiscal Position clause
+ fpos_option = options['fiscal_position']
+ if fpos_option == 'domestic':
+ external_value_domain.append(('foreign_vat_fiscal_position_id', '=', False))
+ elif fpos_option != 'all':
+ # Then it's a fiscal position id
+ external_value_domain.append(('foreign_vat_fiscal_position_id', '=', int(fpos_option)))
+
+ # Do the computation
+ where_clause = self.env['account.report.external.value']._where_calc(external_value_domain).where_clause
+
+ # We have to execute two separate queries, one for text values and one for numeric values
+ num_queries = []
+ string_queries = []
+ monetary_queries = []
+ for formula, expressions in formulas_dict.items():
+ query_end = SQL()
+ if formula == 'most_recent':
+ query_end = SQL(
+ """
+ GROUP BY date
+ ORDER BY date DESC
+ LIMIT 1
+ """,
+ )
+ string_query = """
+ SELECT %(expression_id)s, text_value
+ FROM account_report_external_value
+ WHERE %(where_clause)s AND target_report_expression_id = %(expression_id)s
+ """
+ monetary_query = """
+ SELECT
+ %(expression_id)s,
+ COALESCE(SUM(COALESCE(%(balance_select)s, 0)), 0)
+ FROM account_report_external_value
+ %(currency_table_join)s
+ WHERE %(where_clause)s AND target_report_expression_id = %(expression_id)s
+ %(query_end)s
+ """
+ num_query = """
+ SELECT %(expression_id)s, SUM(COALESCE(value, 0))
+ FROM account_report_external_value
+ WHERE %(where_clause)s AND target_report_expression_id = %(expression_id)s
+ %(query_end)s
+ """
+
+ for expression in expressions:
+ if expression.figure_type == "string":
+ string_queries.append(SQL(
+ string_query,
+ expression_id=expression.id,
+ where_clause=where_clause,
+ ))
+ elif expression.figure_type == "monetary":
+ monetary_queries.append(SQL(
+ monetary_query,
+ expression_id=expression.id,
+ balance_select=self._currency_table_apply_rate(SQL("CAST(value AS numeric)")),
+ currency_table_join=SQL(
+ """
+ JOIN %(currency_table)s
+ ON account_currency_table.company_id = account_report_external_value.company_id
+ AND account_currency_table.rate_type = 'current'
+ """,
+ currency_table=self._get_currency_table(options),
+ ),
+ where_clause=where_clause,
+ query_end=query_end,
+ ))
+ else:
+ num_queries.append(SQL(
+ num_query,
+ expression_id=expression.id,
+ where_clause=where_clause,
+ query_end=query_end,
+ ))
+
+ # Convert to dict to have expression ids as keys
+ query_results_dict = {}
+ for query_list in (num_queries, string_queries, monetary_queries):
+ if query_list:
+ query_results = self.env.execute_query(SQL(' UNION ALL ').join(SQL("(%s)", query) for query in query_list))
+ query_results_dict.update(dict(query_results))
+
+ # Build result dict
+ rslt = {}
+ for formula, expressions in formulas_dict.items():
+ for expression in expressions:
+ expression_value = query_results_dict.get(expression.id)
+ # If expression_value is None, we have no previous value for this expression (set default at 0.0)
+ expression_value = expression_value or ('' if expression.figure_type == 'string' else 0.0)
+ rslt[(formula, expression)] = {'result': expression_value, 'has_sublines': False}
+
+ return rslt
+
+ def _compute_formula_batch_with_engine_custom(self, options, date_scope, formulas_dict, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ self._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else []))
+
+ rslt = {}
+ for formula, expressions in formulas_dict.items():
+ custom_engine_function = self._get_custom_report_function(formula, 'custom_engine')
+ rslt[(formula, expressions)] = custom_engine_function(
+ expressions, options, date_scope, current_groupby, next_groupby, offset=offset, limit=limit, warnings=warnings)
+ return rslt
+
+ def _get_engine_query_tail(self, offset, limit) -> SQL:
+ """ Helper to generate the OFFSET, LIMIT and ORDER conditions of formula engines' queries.
+ """
+ query_tail = SQL()
+
+ if offset:
+ query_tail = SQL("%s OFFSET %s", query_tail, offset)
+
+ if limit:
+ query_tail = SQL("%s LIMIT %s", query_tail, limit)
+
+ return query_tail
+
+ def _generate_carryover_external_values(self, options):
+ """ Generates the account.report.external.value objects corresponding to this report's carryover under the provided options.
+
+ In case of multicompany setup, we need to split the carryover per company, for ease of audit, and so that the carryover isn't broken when
+ a company leaves a tax unit.
+
+ We first generate the carryover for the wholy-aggregated report, so that we can see what final result we want.
+ Indeed due to force_between, if_above and if_below conditions, each carryover might be different from the sum of the individidual companies'
+ carryover values. To handle this case, we generate each company's carryover values separately, then do a carryover adjustment on the
+ main company (main for tax units, first one selected else) in order to bring their total to the result we computed for the whole unit.
+ """
+ self.ensure_one()
+
+ if len(options['column_groups']) > 1:
+ # The options must be forged in order to generate carryover values. Entering this conditions means this hasn't been done in the right way.
+ raise UserError(_("Carryover can only be generated for a single column group."))
+
+ # Get the expressions to evaluate from the report
+ carryover_expressions = self.line_ids.expression_ids.filtered(lambda x: x.label.startswith('_carryover_'))
+ expressions_to_evaluate = carryover_expressions._expand_aggregations()
+
+ # Expression totals for all selected companies
+ expression_totals_per_col_group = self._compute_expression_totals_for_each_column_group(expressions_to_evaluate, options)
+ expression_totals = expression_totals_per_col_group[list(options['column_groups'].keys())[0]]
+ carryover_values = {expression: expression_totals[expression]['value'] for expression in carryover_expressions}
+
+ if len(options['companies']) == 1:
+ company = self.env['res.company'].browse(self.get_report_company_ids(options))
+ self._create_carryover_for_company(options, company, {expr: result for expr, result in carryover_values.items()})
+ else:
+ multi_company_carryover_values_sum = defaultdict(lambda: 0)
+
+ column_group_key = next(col_group_key for col_group_key in options['column_groups'])
+ for company_opt in options['companies']:
+ company = self.env['res.company'].browse(company_opt['id'])
+ company_options = {**options, 'companies': [{'id': company.id, 'name': company.name}]}
+ company_expressions_totals = self._compute_expression_totals_for_each_column_group(expressions_to_evaluate, company_options)
+ company_carryover_values = {expression: company_expressions_totals[column_group_key][expression]['value'] for expression in carryover_expressions}
+ self._create_carryover_for_company(options, company, company_carryover_values)
+
+ for carryover_expr, carryover_val in company_carryover_values.items():
+ multi_company_carryover_values_sum[carryover_expr] += carryover_val
+
+ # Adjust multicompany amounts on main company
+ main_company = self._get_sender_company_for_export(options)
+ for expr in carryover_expressions:
+ difference = carryover_values[expr] - multi_company_carryover_values_sum[expr]
+ self._create_carryover_for_company(options, main_company, {expr: difference}, label=_("Carryover adjustment for tax unit"))
+
+ @api.model
+ def _generate_default_external_values(self, date_from, date_to, is_tax_report=False):
+ """ Generates the account.report.external.value objects for the given dates.
+ If is_tax_report, the values are only created for tax reports, else for all other reports.
+ """
+ options_dict = {}
+ default_expr_by_report = defaultdict(list)
+ tax_report = self.env.ref('account.generic_tax_report')
+ company = self.env.company
+ previous_options = {
+ 'date': {
+ 'date_from': date_from,
+ 'date_to': date_to,
+ }
+ }
+
+ # Get all the default expressions from all reports
+ default_expressions = self.env['account.report.expression'].search([('label', '=like', '_default_%')])
+ # Options depend on the report, also we need to filter out tax report/other reports depending on is_tax_report
+ # Hence we need to group the default expressions by report
+ for expr in default_expressions:
+ report = expr.report_line_id.report_id
+ if is_tax_report == (tax_report in (report + report.root_report_id + report.section_main_report_ids.root_report_id)):
+ if report not in options_dict:
+ options = report.with_context(allowed_company_ids=[company.id]).get_options(previous_options)
+ options_dict[report] = options
+
+ if report._is_available_for(options_dict[report]):
+ default_expr_by_report[report].append(expr)
+
+ external_values_create_vals = []
+ for report, report_default_expressions in default_expr_by_report.items():
+ options = options_dict[report]
+ fpos_options = {options['fiscal_position']}
+
+ for available_fp in options['available_vat_fiscal_positions']:
+ fpos_options.add(available_fp['id'])
+
+ # remove 'all' from fiscal positions if we have several of them - all will then include the sum of other fps
+ # but if there aren't any other fps, we need to keep 'all'
+ if len(fpos_options) > 1 and 'all' in fpos_options:
+ fpos_options.remove('all')
+
+ # The default values should be created for every fiscal position available
+ for fiscal_pos in fpos_options:
+ fiscal_pos_id = int(fiscal_pos) if fiscal_pos not in {'domestic', 'all'} else None
+ fp_options = {**options, 'fiscal_position': fiscal_pos}
+
+ expressions_to_compute = {}
+ for default_expression in report_default_expressions:
+ # The default expression needs to have the same label as the target external expression, e.g. '_default_balance'
+ target_label = default_expression.label[len('_default_'):]
+ target_external_expression = default_expression.report_line_id.expression_ids.filtered(lambda x: x.label == target_label)
+ # If the value has been created before/modified manually, we shouldn't create anything
+ # and we won't recompute expression totals for them
+ external_value = self.env['account.report.external.value'].search([
+ ('company_id', '=', company.id),
+ ('date', '>=', date_from),
+ ('date', '<=', date_to),
+ ('foreign_vat_fiscal_position_id', '=', fiscal_pos_id),
+ ('target_report_expression_id', '=', target_external_expression.id),
+ ])
+
+ if not external_value:
+ expressions_to_compute[default_expression] = target_external_expression.id
+
+ # Evaluate the expressions for the report to fetch the value of the default expression
+ # These have to be computed for each fiscal position
+ expression_totals_per_col_group = report.with_company(company)\
+ ._compute_expression_totals_for_each_column_group(expressions_to_compute, fp_options, include_default_vals=True)
+ expression_totals = expression_totals_per_col_group[list(fp_options['column_groups'].keys())[0]]
+
+ for expression, target_expression in expressions_to_compute.items():
+ external_values_create_vals.append({
+ 'name': _("Manual value"),
+ 'value': expression_totals[expression]['value'],
+ 'date': date_to,
+ 'target_report_expression_id': target_expression,
+ 'foreign_vat_fiscal_position_id': fiscal_pos_id,
+ 'company_id': company.id,
+ })
+
+ self.env['account.report.external.value'].create(external_values_create_vals)
+
+ @api.model
+ def _get_sender_company_for_export(self, options):
+ """ Return the sender company when generating an export file from this report.
+ :return: self.env.company if not using a tax unit, else the main company of that unit
+ """
+ if options.get('tax_unit', 'company_only') != 'company_only':
+ tax_unit = self.env['account.tax.unit'].browse(options['tax_unit'])
+ return tax_unit.main_company_id
+
+ report_companies = self.env['res.company'].browse(self.get_report_company_ids(options))
+ options_main_company = report_companies[0]
+
+ if options.get('tax_unit') is not None and options_main_company._get_branches_with_same_vat() == report_companies:
+ # The line with the smallest number of parents in the VAT sub-hierarchy is assumed to be the root
+ return report_companies.sorted(lambda x: len(x.parent_ids))[0]
+ elif options_main_company._all_branches_selected():
+ return options_main_company.root_id
+
+ return options_main_company
+
+ def _create_carryover_for_company(self, options, company, carryover_per_expression, label=None):
+ date_from = options['date']['date_from']
+ date_to = options['date']['date_to']
+ fiscal_position_opt = options['fiscal_position']
+
+ if carryover_per_expression and fiscal_position_opt == 'all':
+ # Not supported, as it wouldn't make sense, and would make the code way more complicated (because of if_below/if_above/force_between,
+ # just in the same way as it is explained below for multi company)
+ raise UserError(_("Cannot generate carryover values for all fiscal positions at once!"))
+
+ external_values_create_vals = []
+ for expression, carryover_value in carryover_per_expression.items():
+ if not company.currency_id.is_zero(carryover_value):
+ target_expression = expression._get_carryover_target_expression(options)
+ external_values_create_vals.append({
+ 'name': label or _("Carryover from %(date_from)s to %(date_to)s", date_from=format_date(self.env, date_from), date_to=format_date(self.env, date_to)),
+ 'value': carryover_value,
+ 'date': date_to,
+ 'target_report_expression_id': target_expression.id,
+ 'foreign_vat_fiscal_position_id': fiscal_position_opt if isinstance(fiscal_position_opt, int) else None,
+ 'carryover_origin_expression_label': expression.label,
+ 'carryover_origin_report_line_id': expression.report_line_id.id,
+ 'company_id': company.id,
+ })
+
+ self.env['account.report.external.value'].create(external_values_create_vals)
+
+ def get_default_report_filename(self, options, extension):
+ """The default to be used for the file when downloading pdf,xlsx,..."""
+ self.ensure_one()
+
+ sections_source_id = options['sections_source_id']
+ if sections_source_id != self.id:
+ sections_source = self.env['account.report'].browse(sections_source_id)
+ else:
+ sections_source = self
+
+ return f"{sections_source.name.lower().replace(' ', '_')}.{extension}"
+
+ def execute_action(self, options, params=None):
+ action_id = int(params.get('actionId'))
+ action = self.env['ir.actions.actions'].sudo().browse([action_id])
+ action_type = action.type
+ action = self.env[action.type].sudo().browse([action_id])
+ action_read = clean_action(action.read()[0], env=action.env)
+
+ if action_type == 'ir.actions.client':
+ # Check if we are opening another report. If so, generate options for it from the current options.
+ if action.tag == 'account_report':
+ target_report = self.env['account.report'].browse(ast.literal_eval(action_read['context'])['report_id'])
+ new_options = target_report.get_options(previous_options=options)
+ action_read.update({'params': {'options': new_options, 'ignore_session': True}})
+
+ if params.get('id'):
+ # Add the id of the calling object in the action's context
+ if isinstance(params['id'], int):
+ # id of the report line might directly be the id of the model we want.
+ model_id = params['id']
+ else:
+ # It can also be a generic account.report id, as defined by _get_generic_line_id
+ model_id = self._get_model_info_from_id(params['id'])[1]
+
+ context = action_read.get('context') and literal_eval(action_read['context']) or {}
+ context.setdefault('active_id', model_id)
+ action_read['context'] = context
+
+ return action_read
+
+ def action_audit_cell(self, options, params):
+ report_line = self.env['account.report.line'].browse(params['report_line_id'])
+ expression_label = params['expression_label']
+ expression = report_line.expression_ids.filtered(lambda x: x.label == expression_label)
+ column_group_options = self._get_column_group_options(options, params['column_group_key'])
+
+ # Audit of external values
+ if expression.engine == 'external':
+ date_from, date_to = self._get_date_bounds_info(column_group_options, expression.date_scope)
+ external_values_domain = [('target_report_expression_id', '=', expression.id), ('date', '<=', date_to)]
+ if date_from:
+ external_values_domain.append(('date', '>=', date_from))
+
+ if expression.formula == 'most_recent':
+ query = self.env['account.report.external.value']._where_calc(external_values_domain)
+ rows = self.env.execute_query(SQL("""
+ SELECT ARRAY_AGG(id)
+ FROM %s
+ WHERE %s
+ GROUP BY date
+ ORDER BY date DESC
+ LIMIT 1
+ """, query.from_clause, query.where_clause or SQL("TRUE")))
+ if rows:
+ external_values_domain = [('id', 'in', rows[0][0])]
+
+ return {
+ 'name': _("Manual values"),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.report.external.value',
+ 'view_mode': 'list',
+ 'views': [(False, 'list')],
+ 'domain': external_values_domain,
+ }
+
+ # Audit of move lines
+ # If we're auditing a groupby line, we need to make sure to restrict the result of what we audit to the right group values
+ column = next((col for col in report_line.report_id.column_ids if col.expression_label == expression_label), self.env['account.report.column'])
+ if column.custom_audit_action_id:
+ action_dict = column.custom_audit_action_id._get_action_dict()
+ else:
+ action_dict = {
+ 'name': _("Journal Items"),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.move.line',
+ 'view_mode': 'list',
+ 'views': [(False, 'list')],
+ }
+
+ action = clean_action(action_dict, env=self.env)
+ action['domain'] = self._get_audit_line_domain(column_group_options, expression, params)
+ return action
+
+ def action_view_all_variants(self, options, params):
+ return {
+ 'name': _('All Report Variants'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.report',
+ 'view_mode': 'list',
+ 'views': [(False, 'list'), (False, 'form')],
+ 'context': {
+ 'active_test': False,
+ },
+ 'domain': [('id', 'in', self._get_variants(options['variants_source_id']).filtered(
+ lambda x: x._is_available_for(options)
+ ).mapped('id'))],
+ }
+
+ def _get_audit_line_domain(self, column_group_options, expression, params):
+ groupby_domain = self._get_audit_line_groupby_domain(params['calling_line_dict_id'])
+ # Aggregate all domains per date scope, then create the final domain.
+ audit_or_domains_per_date_scope = {}
+ for expression_to_audit in expression._expand_aggregations():
+ expression_domain = self._get_expression_audit_aml_domain(expression_to_audit, column_group_options)
+
+ if expression_domain is None:
+ continue
+
+ date_scope = expression.date_scope if expression.subformula == 'cross_report' else expression_to_audit.date_scope
+ audit_or_domains = audit_or_domains_per_date_scope.setdefault(date_scope, [])
+ audit_or_domains.append(osv.expression.AND([
+ expression_domain,
+ groupby_domain,
+ ]))
+
+ if audit_or_domains_per_date_scope:
+ domain = osv.expression.OR([
+ osv.expression.AND([
+ osv.expression.OR(audit_or_domains),
+ self._get_options_domain(column_group_options, date_scope),
+ groupby_domain,
+ ])
+ for date_scope, audit_or_domains in audit_or_domains_per_date_scope.items()
+ ])
+ else:
+ # Happens when no expression was provided (empty recordset), or if none of the expressions had a standard engine
+ domain = osv.expression.AND([
+ self._get_options_domain(column_group_options, 'strict_range'),
+ groupby_domain,
+ ])
+
+ # Analytic Filter
+ if column_group_options.get("analytic_accounts"):
+ domain = osv.expression.AND([
+ domain,
+ [("analytic_distribution", "in", column_group_options["analytic_accounts"])],
+ ])
+
+ return domain
+
+ def _get_audit_line_groupby_domain(self, calling_line_dict_id):
+ parsed_line_dict_id = self._parse_line_id(calling_line_dict_id)
+ groupby_domain = []
+ for markup, dummy, grouping_key in parsed_line_dict_id:
+ if isinstance(markup, dict) and 'groupby' in markup:
+ groupby_field_name = markup['groupby']
+ custom_handler_model = self._get_custom_handler_model()
+ if custom_handler_model and (custom_groupby_data := self.env[custom_handler_model]._get_custom_groupby_map().get(groupby_field_name)):
+ groupby_domain += custom_groupby_data['domain_builder'](grouping_key)
+ else:
+ groupby_domain.append((groupby_field_name, '=', grouping_key))
+
+ return groupby_domain
+
+ def _get_expression_audit_aml_domain(self, expression_to_audit, options):
+ """ Returns the domain used to audit a single provided expression.
+
+ 'account_codes' engine's D and C formulas can't be handled by a domain: we make the choice to display
+ everything for them (so, audit shows all the lines that are considered by the formula). To avoid confusion from the user
+ when auditing such lines, a default group by account can be used in the list view.
+ """
+ if expression_to_audit.engine == 'account_codes':
+ formula = expression_to_audit.formula.replace(' ', '')
+
+ account_codes_domains = []
+ for token in ACCOUNT_CODES_ENGINE_SPLIT_REGEX.split(formula.replace(' ', '')):
+ if token:
+ match_dict = ACCOUNT_CODES_ENGINE_TERM_REGEX.match(token).groupdict()
+ tag_match = ACCOUNT_CODES_ENGINE_TAG_ID_PREFIX_REGEX.match(match_dict['prefix'])
+ account_codes_domain = []
+
+ if tag_match:
+ if tag_match['ref']:
+ tag_id = self.env['ir.model.data']._xmlid_to_res_id(tag_match['ref'])
+ else:
+ tag_id = int(tag_match['id'])
+
+ account_codes_domain.append(('account_id.tag_ids', 'in', [tag_id]))
+ else:
+ account_codes_domain.append(('account_id.code', '=like', f"{match_dict['prefix']}%"))
+
+ excluded_prefix_str = match_dict['excluded_prefixes']
+ if excluded_prefix_str:
+ for excluded_prefix in excluded_prefix_str.split(','):
+ # "'not like', prefix%" doesn't work
+ account_codes_domain += ['!', ('account_id.code', '=like', f"{excluded_prefix}%")]
+
+ account_codes_domains.append(account_codes_domain)
+
+ return osv.expression.OR(account_codes_domains)
+
+ if expression_to_audit.engine == 'tax_tags':
+ tags = self.env['account.account.tag']._get_tax_tags(expression_to_audit.formula, expression_to_audit.report_line_id.report_id.country_id.id)
+ return [('tax_tag_ids', 'in', tags.ids)]
+
+ if expression_to_audit.engine == 'domain':
+ return ast.literal_eval(expression_to_audit.formula)
+
+ return None
+
+ def open_journal_items(self, options, params):
+ ''' Open the journal items view with the proper filters and groups '''
+ record_model, record_id = self._get_model_info_from_id(params.get('line_id'))
+ view_id = self.env.ref(params['view_ref']).id if params.get('view_ref') else None
+
+ ctx = {
+ 'search_default_group_by_account': 1,
+ 'search_default_posted': 0 if options.get('all_entries') else 1,
+ 'date_from': options.get('date').get('date_from'),
+ 'date_to': options.get('date').get('date_to'),
+ 'search_default_journal_id': params.get('journal_id', False),
+ 'expand': 1,
+ }
+
+ if options['date'].get('date_from'):
+ ctx['search_default_date_between'] = 1
+ else:
+ ctx['search_default_date_before'] = 1
+
+ if options.get('selected_journal_groups'):
+ ctx.update({
+ 'search_default_journal_group_id': [options['selected_journal_groups']['id']],
+ })
+
+ journal_type = params.get('journal_type')
+ if journal_type or options.get('selected_journal_groups') and options['selected_journal_groups']['journal_types']:
+ type_to_view_param = {
+ 'bank': {
+ 'filter': 'search_default_bank',
+ 'view_id': self.env.ref('account.view_move_line_tree_grouped_bank_cash').id
+ },
+ 'cash': {
+ 'filter': 'search_default_cash',
+ 'view_id': self.env.ref('account.view_move_line_tree_grouped_bank_cash').id
+ },
+ 'general': {
+ 'filter': 'search_default_misc_filter',
+ 'view_id': self.env.ref('account.view_move_line_tree_grouped_misc').id
+ },
+ 'sale': {
+ 'filter': 'search_default_sales',
+ 'view_id': self.env.ref('account.view_move_line_tree_grouped_sales_purchases').id
+ },
+ 'purchase': {
+ 'filter': 'search_default_purchases',
+ 'view_id': self.env.ref('account.view_move_line_tree_grouped_sales_purchases').id
+ },
+ 'credit': {
+ 'filter': 'search_default_credit',
+ 'view_id': self.env.ref('account.view_move_line_tree').id
+ },
+ }
+ if options.get('selected_journal_groups'):
+ ctx_to_update = {}
+ for journal_type in options['selected_journal_groups']['journal_types']:
+ ctx_to_update[type_to_view_param[journal_type]['filter']] = 1
+ ctx.update(ctx_to_update)
+ else:
+ ctx.update({
+ type_to_view_param[journal_type]['filter']: 1,
+ })
+ view_id = type_to_view_param[journal_type]['view_id']
+
+ action_domain = [('display_type', 'not in', ('line_section', 'line_note'))]
+
+ if record_id is None:
+ # Default filters don't support the 'no set' value. For this case, we use a domain on the action instead
+ model_fields_map = {
+ 'account.account': 'account_id',
+ 'res.partner': 'partner_id',
+ 'account.journal': 'journal_id',
+ }
+ model_field = model_fields_map.get(record_model)
+ if model_field:
+ action_domain += [(model_field, '=', False)]
+ else:
+ model_default_filters = {
+ 'account.account': 'search_default_account_id',
+ 'res.partner': 'search_default_partner_id',
+ 'account.journal': 'search_default_journal_id',
+ 'product.product': 'search_default_product_id',
+ 'product.category': 'search_default_product_category_id',
+ }
+ model_filter = model_default_filters.get(record_model)
+ if model_filter:
+ ctx.update({
+ 'active_id': record_id,
+ model_filter: [record_id],
+ })
+
+ if options:
+ for account_type in options.get('account_type', []):
+ ctx.update({
+ f"search_default_{account_type['id']}": account_type['selected'] and 1 or 0,
+ })
+
+ if options.get('journals') and 'search_default_journal_id' not in ctx:
+ selected_journals = [journal['id'] for journal in options['journals'] if journal.get('selected')]
+ if len(selected_journals) == 1:
+ ctx['search_default_journal_id'] = selected_journals
+
+ if options.get('analytic_accounts'):
+ analytic_ids = [int(r) for r in options['analytic_accounts']]
+ ctx.update({
+ 'search_default_analytic_accounts': 1,
+ 'analytic_ids': analytic_ids,
+ })
+
+ return {
+ 'name': self._get_action_name(params, record_model, record_id),
+ 'view_mode': 'list,pivot,graph,kanban',
+ 'res_model': 'account.move.line',
+ 'views': [(view_id, 'list')],
+ 'type': 'ir.actions.act_window',
+ 'domain': action_domain,
+ 'context': ctx,
+ }
+
+ def open_unposted_moves(self, options, params=None):
+ ''' Open the list of draft journal entries that might impact the reporting'''
+ action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_journal_line")
+ action = clean_action(action, env=self.env)
+ action['domain'] = [('state', '=', 'draft'), ('date', '<=', options['date']['date_to'])]
+ #overwrite the context to avoid default filtering on 'misc' journals
+ action['context'] = {}
+ return action
+
+ def _get_generated_deferral_entries_domain(self, options):
+ """Get the search domain for the generated deferral entries of the current period.
+
+ :param options: the report's `options` dict containing `date_from`, `date_to` and `deferred_report_type`
+ :return: a search domain that can be used to get the deferral entries
+ """
+ if options.get('deferred_report_type') == 'expense':
+ account_types = ('expense', 'expense_depreciation', 'expense_direct_cost')
+ else:
+ account_types = ('income', 'income_other')
+ date_to = fields.Date.from_string(options['date']['date_to'])
+ date_to_next_reversal = fields.Date.to_string(date_to + datetime.timedelta(days=1))
+ return [
+ ('company_id', '=', self.env.company.id),
+ # We exclude the reversal entries of the previous period that fall on the first day of this period
+ ('date', '>', options['date']['date_from']),
+ # We include the reversal entries of the current period that fall on the first day of the next period
+ ('date', '<=', date_to_next_reversal),
+ ('deferred_original_move_ids', '!=', False),
+ ('line_ids.account_id.account_type', 'in', account_types),
+ ('state', '!=', 'cancel'),
+ ]
+
+ def open_deferral_entries(self, options, params):
+ domain = self._get_generated_deferral_entries_domain(options)
+ deferral_line_ids = self.env['account.move'].search(domain).line_ids.ids
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Deferred Entries'),
+ 'res_model': 'account.move.line',
+ 'domain': [('id', 'in', deferral_line_ids)],
+ 'views': [(False, 'list'), (False, 'form')],
+ 'context': {
+ 'search_default_group_by_move': True,
+ 'expand': True,
+ }
+ }
+
+ def action_modify_manual_value(self, line_id, options, column_group_key, new_value_str, target_expression_id, rounding, json_friendly_column_group_totals):
+ """ Edit a manual value from the report, updating or creating the corresponding account.report.external.value object.
+
+ :param options: The option dict the report is evaluated with.
+
+ :param column_group_key: The string identifying the column group into which the change as manual value needs to be done.
+
+ :param new_value_str: The new value to be set, as a string.
+
+ :param rounding: The number of decimal digits to round with.
+
+ :param json_friendly_column_group_totals: The expression totals by column group already computed for this report, in the format returned
+ by _get_json_friendly_column_group_totals. These will be used to reevaluate the report, recomputing
+ only the expressions depending on the newly-modified manual value, and keeping all the results
+ from the previous computations for the other ones.
+ """
+ self.ensure_one()
+
+ target_column_group_options = self._get_column_group_options(options, column_group_key)
+ self._init_currency_table(target_column_group_options)
+
+ if target_column_group_options.get('compute_budget'):
+ expressions_to_recompute = self.env['account.report.expression'].browse(target_expression_id) \
+ + self.line_ids.expression_ids.filtered(lambda x: x.engine == 'aggregation')
+ self._action_modify_manual_budget_value(line_id, target_column_group_options, new_value_str, target_expression_id, rounding)
+ else:
+ expressions_to_recompute = self.line_ids.expression_ids.filtered(lambda x: x.engine in ('external', 'aggregation'))
+ self._action_modify_manual_external_value(target_column_group_options, new_value_str, target_expression_id, rounding)
+
+ # We recompute values for each column group, not only the one we modified a value in; this is important in case some date_scope is used to
+ # retrieve the manual value from a previous period.
+
+ all_column_groups_expression_totals = self._convert_json_friendly_column_group_totals(
+ json_friendly_column_group_totals,
+ expressions_to_exclude=expressions_to_recompute,
+ )
+
+ recomputed_expression_totals = self._compute_expression_totals_for_each_column_group(
+ expressions_to_recompute, options, forced_all_column_groups_expression_totals=all_column_groups_expression_totals)
+
+ return {
+ 'lines': self._get_lines(options, all_column_groups_expression_totals=recomputed_expression_totals),
+ 'column_groups_totals': self._get_json_friendly_column_group_totals(recomputed_expression_totals),
+ }
+
+ def _convert_json_friendly_column_group_totals(self, json_friendly_column_group_totals, expressions_to_exclude=None, col_groups_to_exclude=None):
+ """ json_friendly_column_group_totals contains ids instead of expressions (because it comes from js) ; this function is used
+ to convert them back to records.
+ """
+ all_column_groups_expression_totals = {}
+ for column_group_key, expression_totals in json_friendly_column_group_totals.items():
+ if col_groups_to_exclude and column_group_key in col_groups_to_exclude:
+ continue
+
+ all_column_groups_expression_totals[column_group_key] = {}
+ for expr_id, expr_totals in expression_totals.items():
+ expression = self.env['account.report.expression'].browse(int(expr_id)) # Should already be in cache, so acceptable
+ if not expressions_to_exclude or expression not in expressions_to_exclude:
+ all_column_groups_expression_totals[column_group_key][expression] = expr_totals
+
+ return all_column_groups_expression_totals
+
+ def _action_modify_manual_external_value(self, target_column_group_options, new_value_str, target_expression_id, rounding):
+ """ Edit a manual value from the report, updating or creating the corresponding account.report.external.value object.
+
+ :param target_column_group_options: The options dict of the column group where the modification happened.
+
+ :param column_group_key: The string identifying the column group into which the change as manual value needs to be done.
+
+ :param new_value_str: The new value to be set, as a string.
+
+ :param rounding: The number of decimal digits to round with.
+
+ """
+ if len(target_column_group_options['companies']) > 1:
+ raise UserError(_("Editing a manual report line is not allowed when multiple companies are selected."))
+
+ if target_column_group_options['fiscal_position'] == 'all' and target_column_group_options['available_vat_fiscal_positions']:
+ raise UserError(_("Editing a manual report line is not allowed in multivat setup when displaying data from all fiscal positions."))
+
+ # Create the manual value
+ target_expression = self.env['account.report.expression'].browse(target_expression_id)
+ date_from, date_to = self._get_date_bounds_info(target_column_group_options, target_expression.date_scope)
+ fiscal_position_id = target_column_group_options['fiscal_position'] if isinstance(target_column_group_options['fiscal_position'], int) else False
+
+ external_values_domain = [
+ ('target_report_expression_id', '=', target_expression.id),
+ ('company_id', '=', self.env.company.id),
+ ('foreign_vat_fiscal_position_id', '=', fiscal_position_id),
+ ]
+
+ if target_expression.formula == 'most_recent':
+ value_to_adjust = 0
+ existing_value_to_modify = self.env['account.report.external.value'].search([
+ *external_values_domain,
+ ('date', '=', date_to),
+ ])
+
+ # There should be at most 1
+ if len(existing_value_to_modify) > 1:
+ raise UserError(_("Inconsistent data: more than one external value at the same date for a 'most_recent' external line."))
+ else:
+ existing_external_values = self.env['account.report.external.value'].search([
+ *external_values_domain,
+ ('date', '>=', date_from),
+ ('date', '<=', date_to),
+ ], order='date ASC')
+ existing_value_to_modify = existing_external_values[-1] if existing_external_values and str(existing_external_values[-1].date) == date_to else None
+ value_to_adjust = sum(existing_external_values.filtered(lambda x: x != existing_value_to_modify).mapped('value'))
+
+ if not new_value_str and target_expression.figure_type != 'string':
+ new_value_str = '0'
+
+ try:
+ float(new_value_str)
+ is_number = True
+ except ValueError:
+ is_number = False
+
+ if target_expression.figure_type == 'string':
+ value_to_set = new_value_str
+ else:
+ if not is_number:
+ raise UserError(_("%s is not a numeric value", new_value_str))
+ if target_expression.figure_type == 'boolean':
+ rounding = 0
+ value_to_set = float_round(float(new_value_str) - value_to_adjust, precision_digits=rounding)
+
+ field_name = 'value' if target_expression.figure_type != 'string' else 'text_value'
+
+ if existing_value_to_modify:
+ existing_value_to_modify[field_name] = value_to_set
+ existing_value_to_modify.flush_recordset()
+ else:
+ self.env['account.report.external.value'].create({
+ 'name': _("Manual value"),
+ field_name: value_to_set,
+ 'date': date_to,
+ 'target_report_expression_id': target_expression.id,
+ 'company_id': self.env.company.id,
+ 'foreign_vat_fiscal_position_id': fiscal_position_id,
+ })
+
+ def _action_modify_manual_budget_value(self, line_id, target_column_group_options, new_value_str, target_expression_id, rounding):
+ target_expression = self.env['account.report.expression'].browse(target_expression_id)
+
+ if not new_value_str and target_expression.figure_type != 'string':
+ new_value_str = '0'
+
+ try:
+ value_to_set = float_round(float(new_value_str), precision_digits=rounding)
+ except ValueError:
+ raise UserError(_("%s is not a numeric value", new_value_str))
+
+ model, account_id = self._get_model_info_from_id(line_id)
+ if model != 'account.account':
+ raise UserError(_("Budget items can only be edited from account lines."))
+
+ # Depending on the expression's formula, the balance of the account could be multiplied by -1
+ # within the report. We need to apply the same multiplier on the budget item we create.
+ if target_expression.engine == 'domain' and target_expression.subformula.startswith('-'):
+ value_to_set *= -1
+ elif target_expression.engine == 'account_codes':
+ account = self.env['account.account'].browse(account_id)
+
+ # Search for the sign to apply to this account
+ for token in ACCOUNT_CODES_ENGINE_SPLIT_REGEX.split(target_expression.formula.replace(' ', '')):
+ if not token:
+ continue
+
+ token_match = ACCOUNT_CODES_ENGINE_TERM_REGEX.match(token)
+ multiplicator = -1 if token_match['sign'] == '-' else 1
+ prefix = token_match['prefix']
+
+ tag_match = ACCOUNT_CODES_ENGINE_TAG_ID_PREFIX_REGEX.match(prefix)
+ if tag_match:
+ if tag_match['ref']:
+ tag = self.env.ref(tag_match['ref'])
+ else:
+ tag = self.env['account.account.tag'].browse(tag_match['id'])
+
+ account_matches = tag in account.tag_ids
+ else:
+ account_matches = account.code.startswith(prefix)
+
+ if account_matches:
+ value_to_set *= multiplicator
+ break
+
+ self.env['account.report.budget'].browse(target_column_group_options['compute_budget'])._create_or_update_budget_items(
+ value_to_set,
+ account_id,
+ rounding,
+ target_column_group_options['date']['date_from'],
+ target_column_group_options['date']['date_to'],
+ )
+
+ def action_display_inactive_sections(self, options):
+ self.ensure_one()
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _("Enable Sections"),
+ 'view_mode': 'list,form',
+ 'res_model': 'account.report',
+ 'domain': [('section_main_report_ids', 'in', options['sections_source_id']), ('active', '=', False)],
+ 'views': [(False, 'list'), (False, 'form')],
+ 'context': {
+ 'list_view_ref': 'odex30_account_reports.account_report_add_sections_tree',
+ 'active_test': False,
+ },
+ }
+
+ @api.model
+ def sort_lines(self, lines, options, result_as_index=False):
+ ''' Sort report lines based on the 'order_column' key inside the options.
+ The value of options['order_column'] is an integer, positive or negative, indicating on which column
+ to sort and also if it must be an ascending sort (positive value) or a descending sort (negative value).
+ Note that for this reason, its indexing is made starting at 1, not 0.
+ If this key is missing or falsy, lines is returned directly.
+
+ This method has some limitations:
+ - The selected_column must have 'sortable' in its classes.
+ - All lines are sorted except:
+ - lines having the 'total' class
+ - static lines (lines with model 'account.report.line')
+ - This only works when each line has an unique id.
+ - All lines inside the selected_column must have a 'no_format' value.
+
+ Example:
+
+ parent_line_1 balance=11
+ child_line_1 balance=1
+ child_line_2 balance=3
+ child_line_3 balance=2
+ child_line_4 balance=7
+ child_line_5 balance=4
+ child_line_6 (total line)
+ parent_line_2 balance=10
+ child_line_7 balance=5
+ child_line_8 balance=6
+ child_line_9 (total line)
+
+
+ The resulting lines will be:
+
+ parent_line_2 balance=10
+ child_line_7 balance=5
+ child_line_8 balance=6
+ child_line_9 (total line)
+ parent_line_1 balance=11
+ child_line_1 balance=1
+ child_line_3 balance=2
+ child_line_2 balance=3
+ child_line_5 balance=4
+ child_line_4 balance=7
+ child_line_6 (total line)
+
+ :param lines: The report lines.
+ :param options: The report options.
+ :return: Lines sorted by the selected column.
+ '''
+ def needs_to_be_at_bottom(line_elem):
+ return self._get_markup(line_elem.get('id')) in ('total', 'load_more')
+
+ def compare_values(a_line, b_line):
+ if column_index is False:
+ return 0
+ type_seq = {
+ type(None): 0,
+ bool: 1,
+ float: 2,
+ int: 2,
+ str: 3,
+ datetime.date: 4,
+ datetime.datetime: 5,
+ }
+
+ a_line_dict = lines[a_line] if result_as_index else a_line
+ b_line_dict = lines[b_line] if result_as_index else b_line
+ a_total = needs_to_be_at_bottom(a_line_dict)
+ b_total = needs_to_be_at_bottom(b_line_dict)
+ a_model = self._get_model_info_from_id(a_line_dict['id'])[0]
+ b_model = self._get_model_info_from_id(b_line_dict['id'])[0]
+
+ # static lines are not sorted
+ if a_model == b_model == 'account.report.line':
+ return 0
+
+ if a_total:
+ if b_total: # a_total & b_total
+ return 0
+ else: # a_total & !b_total
+ return -1 if descending else 1
+ if b_total: # => !a_total & b_total
+ return 1 if descending else -1
+
+ a_val = a_line_dict['columns'][column_index].get('no_format')
+ b_val = b_line_dict['columns'][column_index].get('no_format')
+ type_a, type_b = type_seq[type(a_val)], type_seq[type(b_val)]
+
+ if type_a == type_b:
+ return 0 if a_val == b_val else 1 if a_val > b_val else -1
+ else:
+ return type_a - type_b
+
+ def merge_tree(tree_elem, ls):
+ nonlocal descending # The direction of the sort is needed to compare total lines
+ ls.append(tree_elem)
+
+ elem = tree[lines[tree_elem]['id']] if result_as_index else tree[tree_elem['id']]
+
+ for tree_subelem in sorted(elem, key=comp_key, reverse=descending):
+ merge_tree(tree_subelem, ls)
+
+ descending = options['order_column']['direction'] == 'DESC' # To keep total lines at the end, used in compare_values & merge_tree scopes
+
+ column_index = False
+ for index, col in enumerate(options['columns']):
+ if options['order_column']['expression_label'] == col['expression_label']:
+ column_index = index # To know from which column to sort, used in merge_tree scope
+ break
+
+ comp_key = cmp_to_key(compare_values)
+ sorted_list = []
+ tree = defaultdict(list)
+ non_total_parents = set()
+
+ for index, line in enumerate(lines):
+ line_parent = line.get('parent_id') or None
+
+ if result_as_index:
+ tree[line_parent].append(index)
+ else:
+ tree[line_parent].append(line)
+
+ line_markup = self._get_markup(line['id'])
+
+ if line_markup != 'total':
+ non_total_parents.add(line_parent)
+
+ if None not in tree and len(non_total_parents) == 1:
+ # Happens when unfolding a groupby line, to sort its children.
+ sorting_root = next(iter(non_total_parents))
+ else:
+ sorting_root = None
+
+ for line in sorted(tree[sorting_root], key=comp_key, reverse=descending):
+ merge_tree(line, sorted_list)
+
+ return sorted_list
+
+ def _get_annotations_domain_date_from(self, options):
+ if options['date']['filter'] in {'today', 'custom'} and options['date']['mode'] == 'single':
+ options_company_ids = [company['id'] for company in options['companies']]
+ root_companies_ids = self.env['res.company'].browse(options_company_ids).root_id.ids
+ fiscal_year = self.env['account.fiscal.year'].search_fetch([
+ ('company_id', 'in', root_companies_ids),
+ ('date_from', '<=', options['date']['date_to']),
+ ('date_to', '>=', options['date']['date_to']),
+ ], limit=1, field_names=['date_from'])
+ if fiscal_year:
+ return datetime.datetime.combine(fiscal_year.date_from, datetime.time.min)
+
+ period_date_from, _ = date_utils.get_fiscal_year(
+ datetime.datetime.strptime(options['date']['date_to'], '%Y-%m-%d'),
+ day=self.env.company.fiscalyear_last_day,
+ month=int(self.env.company.fiscalyear_last_month)
+ )
+ return period_date_from
+
+ date_from = datetime.datetime.strptime(options['date']['date_from'], '%Y-%m-%d')
+ if options['date']['period_type'] == "fiscalyear":
+ period_date_from, _ = date_utils.get_fiscal_year(date_from)
+ elif options['date']['period_type'] in ["year", "quarter", "month", "week", "day", "hour"]:
+ period_date_from = date_utils.start_of(date_from, options['date']['period_type'])
+ else:
+ period_date_from = date_from
+ return period_date_from
+
+ def _adjust_date_for_joined_comparison(self, options, period_date_from):
+ comparison_filter = options.get('comparison', {}).get('filter')
+ if comparison_filter == 'previous_period':
+ comparison_date_from = datetime.datetime.strptime(options['comparison'].get('periods', [{}])[-1].get('date_from'), '%Y-%m-%d')
+ return min(period_date_from, comparison_date_from)
+ return period_date_from
+
+ def _adjust_domain_for_unjoined_comparison(self, options, dates_domain):
+ comparison_filter = options.get('comparison', {}).get('filter')
+ if comparison_filter and comparison_filter not in {'no_comparison', 'previous_period'}:
+ unlinked_comparison_periods_domains_list = [
+ ['&', ('date', '>=', period['date_from']), ('date', '<=', period['date_to'])]
+ for period in options['comparison']['periods']
+ ]
+ dates_domain = osv.expression.OR([dates_domain, *unlinked_comparison_periods_domains_list])
+
+ return dates_domain
+
+ def _build_annotations_domain(self, options):
+ domain = [('report_id', '=', options['report_id'])]
+ if options.get('date'):
+ period_date_from = self._get_annotations_domain_date_from(options)
+ period_date_from = self._adjust_date_for_joined_comparison(options, period_date_from)
+ dates_domain = osv.expression.AND([
+ [('date', '>=', period_date_from)],
+ [('date', '<=', options['date']['date_to'])],
+ ])
+ dates_domain = self._adjust_domain_for_unjoined_comparison(options, dates_domain)
+
+ domain = osv.expression.AND([
+ domain,
+ osv.expression.OR([
+ [('date', '=', False)],
+ dates_domain,
+ ]),
+ ])
+
+ fiscal_position_option = options.get('fiscal_position')
+ if isinstance(fiscal_position_option, int):
+ domain = osv.expression.AND([domain, [('fiscal_position_id', '=', fiscal_position_option)]])
+ elif fiscal_position_option == 'domestic':
+ domain = osv.expression.AND([domain, [('fiscal_position_id', '=', False)]])
+ return domain
+
+ def get_annotations(self, options):
+ """
+ This method handles which annotations have to be displayed on the report.
+ This decision is based on the different dates and mode of display of those dates in the report.
+
+ param options: dict of options used to generate the report
+ return: dict of lists containing for each annotated line_id of the report the list of annotations linked to it
+ """
+ self.ensure_one()
+ annotations_by_line = defaultdict(list)
+ annotations = self.env['account.report.annotation'].search_read(self._build_annotations_domain(options))
+ for annotation in annotations:
+ line_id_without_tax_grouping = self.env['account.report.annotation']._remove_tax_grouping_from_line_id(annotation['line_id'])
+ annotation['create_date'] = annotation['create_date'].date()
+ annotations_by_line[line_id_without_tax_grouping].append(annotation)
+ return annotations_by_line
+
+ def get_report_information(self, options):
+ """
+ return a dictionary of information that will be consumed by the AccountReport component.
+ """
+ self.ensure_one()
+ self.env.flush_all()
+
+ warnings = {}
+ self._init_currency_table(options)
+ all_column_groups_expression_totals = self._compute_expression_totals_for_each_column_group(self.line_ids.expression_ids, options, warnings=warnings)
+
+ # Convert all_column_groups_expression_totals to a json-friendly form (its keys are records)
+ json_friendly_column_group_totals = self._get_json_friendly_column_group_totals(all_column_groups_expression_totals)
+
+ if self.custom_handler_model_name:
+ custom_display_config = self.env[self.custom_handler_model_name]._get_custom_display_config()
+ elif self.root_report_id and self.root_report_id.custom_handler_model_name:
+ custom_display_config = self.env[self.root_report_id.custom_handler_model_name]._get_custom_display_config()
+ else:
+ custom_display_config = {}
+
+ return {
+ 'caret_options': self._get_caret_options(),
+ 'column_headers_render_data': self._get_column_headers_render_data(options),
+ 'column_groups_totals': json_friendly_column_group_totals,
+ 'context': self.env.context,
+ 'custom_display': custom_display_config,
+ 'filters': {
+ 'show_all': self.filter_unfold_all,
+ 'show_analytic': options.get('display_analytic', False),
+ 'show_analytic_groupby': options.get('display_analytic_groupby', False),
+ 'show_analytic_plan_groupby': options.get('display_analytic_plan_groupby', False),
+ 'show_draft': self.filter_show_draft,
+ 'show_hierarchy': options.get('display_hierarchy_filter', False),
+ 'show_period_comparison': self.filter_period_comparison,
+ 'show_totals': self.env.company.totals_below_sections and not options.get('ignore_totals_below_sections'),
+ 'show_unreconciled': self.filter_unreconciled,
+ 'show_hide_0_lines': self.filter_hide_0_lines,
+ },
+ 'annotations': self.get_annotations(options),
+ 'groups': {
+ 'analytic_accounting': self.env.user.has_group('analytic.group_analytic_accounting'),
+ 'account_readonly': self.env.user.has_group('account.group_account_readonly'),
+ 'account_user': self.env.user.has_group('account.group_account_user'),
+ },
+ 'lines': self._get_lines(options, all_column_groups_expression_totals=all_column_groups_expression_totals, warnings=warnings),
+ 'warnings': warnings,
+ 'report': {
+ 'company_name': self.env.company.name,
+ 'company_country_code': self.env.company.country_code,
+ 'company_currency_symbol': self.env.company.currency_id.symbol,
+ 'name': self.name,
+ 'root_report_id': self.root_report_id,
+ }
+ }
+
+ @api.readonly
+ def get_report_information_readonly(self, options):
+ """ Readonly version of get_report_information, to be called from RPC when options['readonly_query'] is True,
+ to better spread the load on servers when possible.
+ """
+ return self.get_report_information(options)
+
+ def _get_json_friendly_column_group_totals(self, all_column_groups_expression_totals):
+ # Convert all_column_groups_expression_totals to a json-friendly form (its keys are records)
+ json_friendly_column_group_totals = {}
+ for column_group_key, expressions_totals in all_column_groups_expression_totals.items():
+ json_friendly_column_group_totals[column_group_key] = {expression.id: totals for expression, totals in expressions_totals.items()}
+ return json_friendly_column_group_totals
+
+ def _is_available_for(self, options):
+ """ Called on report variants to know whether they are available for the provided options or not, computed for their root report,
+ computing their availability_condition field.
+
+ Note that only the options initialized by the init_options with a more prioritary sequence than _init_options_variants are guaranteed to
+ be in the provided options' dict (since this function is called by _init_options_variants, while resolving a call to get_options()).
+ """
+ self.ensure_one()
+
+ companies = self.env['res.company'].browse(self.get_report_company_ids(options))
+
+ if self.availability_condition == 'country':
+ countries = companies.account_fiscal_country_id
+ if self.filter_fiscal_position:
+ foreign_vat_fpos = self.env['account.fiscal.position'].search([
+ ('foreign_vat', '!=', False),
+ ('company_id', 'in', companies.ids),
+ ])
+ countries += foreign_vat_fpos.country_id
+
+ return not self.country_id or self.country_id in countries
+
+ elif self.availability_condition == 'coa':
+ # When restricting to 'coa', the report is only available is all the companies have the same CoA as the report
+ return self.chart_template in set(companies.mapped('chart_template'))
+
+ return True
+
+ def _get_column_headers_render_data(self, options):
+ column_headers_render_data = {}
+
+ # We only want to consider the columns that are visible in the current report and don't rely on self.column_ids
+ # since custom reports could alter them (e.g. for multi-currency purposes)
+ columns = [col for col in options['columns'] if col['column_group_key'] == next(k for k in options['column_groups'])]
+
+ # Compute the colspan of each header level, aka the number of single columns it contains at the base of the hierarchy
+ level_colspan_list = column_headers_render_data['level_colspan'] = []
+ for i in range(len(options['column_headers'])):
+ colspan = max(len(columns), 1)
+ for column_header in options['column_headers'][i + 1:]:
+ # Separate non-budget and budget headers
+ budget_count = sum(
+ any(key in header.get('forced_options', {}) for key in ('compute_budget', 'budget_percentage'))
+ for header in column_header
+ )
+ non_budget_count = len(column_header) - budget_count
+
+ # budget headers (amount and percentage) can only contain a single column each, regardless of the amount of columns in the report.
+ # This implies that we first need to multiply for the 'regular' columns and then add the budget columns.
+ colspan *= non_budget_count
+ colspan += budget_count
+
+ level_colspan_list.append(colspan)
+
+ # Compute the number of times each header level will have to be repeated, and its colspan to properly handle horizontal groups/comparisons
+ column_headers_render_data['level_repetitions'] = []
+ for i in range(len(options['column_headers'])):
+ colspan = 1
+ for column_header in options['column_headers'][:i]:
+ colspan *= len(column_header)
+ column_headers_render_data['level_repetitions'].append(colspan)
+
+ # Custom reports have the possibility to define custom subheaders that will be displayed between the generic header and the column names.
+ column_headers_render_data['custom_subheaders'] = options.get('custom_columns_subheaders', []) * len(options['column_groups'])
+
+ return column_headers_render_data
+
+ def _get_action_name(self, params, record_model=None, record_id=None):
+ if not (record_model or record_id):
+ record_model, record_id = self._get_model_info_from_id(params.get('line_id'))
+ return params.get('name') or self.env[record_model].browse(record_id).display_name or ''
+
+ def _format_lines_for_display(self, lines, options):
+ """
+ This method should be overridden in a report in order to apply specific formatting when printing
+ the report lines.
+
+ Used for example by the carryover functionnality in the generic tax report.
+ :param lines: A list with the lines for this report.
+ :param options: The options for this report.
+ :return: The formatted list of lines
+ """
+ return lines
+
+ def get_expanded_lines(self, options, line_dict_id, groupby, expand_function_name, progress, offset, horizontal_split_side):
+ self.env.flush_all()
+ self._init_currency_table(options)
+
+ lines = self._expand_unfoldable_line(expand_function_name, line_dict_id, groupby, options, progress, offset, horizontal_split_side)
+ lines = self._fully_unfold_lines_if_needed(lines, options)
+
+ self._inject_account_names_for_consolidation(lines)
+
+ if self.custom_handler_model_id:
+ lines = self.env[self.custom_handler_model_name]._custom_line_postprocessor(self, options, lines)
+
+ self._format_column_values(options, lines)
+ return lines
+
+ @api.readonly
+ def get_expanded_lines_readonly(self, options, line_dict_id, groupby, expand_function_name, progress, offset, horizontal_split_side):
+ """ Readonly version of get_expanded_lines_readonly, to be called from RPC when options['readonly_query'] is True,
+ to better spread the load on servers when possible.
+ """
+ return self.get_expanded_lines(options, line_dict_id, groupby, expand_function_name, progress, offset, horizontal_split_side)
+
+ def _expand_unfoldable_line(self, expand_function_name, line_dict_id, groupby, options, progress, offset, horizontal_split_side, unfold_all_batch_data=None):
+ if not expand_function_name:
+ raise UserError(_("Trying to expand a line without an expansion function."))
+
+ if not progress:
+ progress = {column_group_key: 0 for column_group_key in options['column_groups']}
+
+ expand_function = self._get_custom_report_function(expand_function_name, 'expand_unfoldable_line')
+ expansion_result = expand_function(line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=unfold_all_batch_data)
+
+ rslt = expansion_result['lines']
+
+ if horizontal_split_side:
+ for line in rslt:
+ line['horizontal_split_side'] = horizontal_split_side
+
+ # Apply integer rounding to the result if needed.
+ # The groupby expansion function is the only one guaranteed to call the expressions computation,
+ # so the values computed for it will already have been rounded if integer rounding is enabled. No need to round them again.
+ if expand_function_name != '_report_expand_unfoldable_line_with_groupby':
+ self._apply_integer_rounding_to_dynamic_lines(options, rslt)
+
+ if expansion_result.get('has_more'):
+ # We only add load_more line for groupby
+ next_offset = offset + expansion_result['offset_increment']
+ rslt.append(self._get_load_more_line(next_offset, line_dict_id, expand_function_name, groupby, expansion_result.get('progress', 0), options))
+
+ # In some specific cases, we may want to add lines that are always at the end. So they need to be added after the load more line.
+ if expansion_result.get('after_load_more_lines'):
+ rslt.extend(expansion_result['after_load_more_lines'])
+
+ return self._add_totals_below_sections(rslt, options)
+
+ def _add_totals_below_sections(self, lines, options):
+ """ Returns a new list, corresponding to lines with the required total lines added as sublines of the sections it contains.
+ """
+ if not self.env.company.totals_below_sections or options.get('ignore_totals_below_sections'):
+ return lines
+
+ # Gather the lines needing the totals
+ lines_needing_total_below = set()
+ for line_dict in lines:
+ line_markup = self._get_markup(line_dict['id'])
+
+ if line_markup != 'total':
+ # If we are on the first level of an expandable line, we arelady generate its total
+ if line_dict.get('unfoldable') or (line_dict.get('unfolded') and line_dict.get('expand_function')):
+ lines_needing_total_below.add(line_dict['id'])
+
+ # All lines that are parent of other lines need to receive a total
+ line_parent_id = line_dict.get('parent_id')
+ if line_parent_id:
+ lines_needing_total_below.add(line_parent_id)
+
+ # Inject the totals
+ if lines_needing_total_below:
+ lines_with_totals_below = []
+ totals_below_stack = []
+ for line_dict in lines:
+ while totals_below_stack and not line_dict['id'].startswith(totals_below_stack[-1]['parent_id'] + LINE_ID_HIERARCHY_DELIMITER):
+ lines_with_totals_below.append(totals_below_stack.pop())
+
+ lines_with_totals_below.append(line_dict)
+
+ if line_dict['id'] in lines_needing_total_below and any(col.get('no_format') is not None for col in line_dict['columns']):
+ totals_below_stack.append(self._generate_total_below_section_line(line_dict))
+
+ while totals_below_stack:
+ lines_with_totals_below.append(totals_below_stack.pop())
+
+ return lines_with_totals_below
+
+ return lines
+
+ @api.model
+ def _get_load_more_line(self, offset, parent_line_id, expand_function_name, groupby, progress, options):
+ """ Returns a 'Load more' line allowing to reach the subsequent elements of an unfolded line with an expand function if the maximum
+ limit of sublines is reached (we load them by batch, using the load_more_limit field's value).
+
+ :param offset: The offset to be passed to the expand function to generate the next results, when clicking on this 'load more' line.
+
+ :param parent_line_id: The generic id of the line this load more line is created for.
+
+ :param expand_function_name: The name of the expand function this load_more is created for (so, the one of its parent).
+
+ :param progress: A json-formatted dict(column_group_key, value) containing the progress value for each column group, as it was
+ returned by the expand function. This is for example used by reports such as the general ledger, whose lines display a c
+ cumulative sum of their balance and the one of all the previous lines under the same parent. In this case, progress
+ will be the total sum of all the previous lines before the load_more line, that the subsequent lines will need to use as
+ base for their own cumulative sum.
+
+ :param options: The options dict corresponding to this report's state.
+ """
+ return {
+ 'id': self._get_generic_line_id(None, None, parent_line_id=parent_line_id, markup='load_more'),
+ 'name': _("Load more..."),
+ 'parent_id': parent_line_id,
+ 'expand_function': expand_function_name,
+ 'columns': [{} for col in options['columns']],
+ 'unfoldable': False,
+ 'unfolded': False,
+ 'offset': offset,
+ 'groupby': groupby, # We keep the groupby value from the parent, so that it can be propagated through js
+ 'progress': progress,
+ }
+
+ def _report_expand_unfoldable_line_with_groupby(self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None):
+ # The line we're expanding might be an inner groupby; we first need to find the report line generating it
+ report_line_id = None
+ for dummy, model, model_id in reversed(self._parse_line_id(line_dict_id)):
+ if model == 'account.report.line':
+ report_line_id = model_id
+ break
+
+ if report_line_id is None:
+ raise UserError(_("Trying to expand a group for a line which was not generated by a report line: %s", line_dict_id))
+
+ line = self.env['account.report.line'].browse(report_line_id)
+
+ if ',' not in groupby and options['export_mode'] is None:
+ # if ',' not in groupby, then its a terminal groupby (like 'id' in 'partner_id, id'), so we can use the 'load more' feature if necessary
+ # When printing, we want to ignore the limit.
+ limit_to_load = self.load_more_limit or None
+ else:
+ # Else, we disable it
+ limit_to_load = None
+ offset = 0
+
+ rslt_lines = line._expand_groupby(line_dict_id, groupby, options, offset=offset, limit=limit_to_load, load_one_more=bool(limit_to_load), unfold_all_batch_data=unfold_all_batch_data)
+ lines_to_load = rslt_lines[:self.load_more_limit] if limit_to_load else rslt_lines
+
+ if not limit_to_load and options['export_mode'] is None:
+ lines_to_load = self._regroup_lines_by_name_prefix(options, rslt_lines, '_report_expand_unfoldable_line_groupby_prefix_group', line.hierarchy_level,
+ groupby=groupby, parent_line_dict_id=line_dict_id)
+
+ return {
+ 'lines': lines_to_load,
+ 'offset_increment': len(lines_to_load),
+ 'has_more': len(lines_to_load) < len(rslt_lines) if limit_to_load else False,
+ }
+
+ def _regroup_lines_by_name_prefix(self, options, lines_to_group, expand_function_name, parent_level, matched_prefix='', groupby=None, parent_line_dict_id=None):
+ """ Postprocesses a list of report line dictionaries in order to regroup them by name prefix and reduce the overall number of lines
+ if their number is above a provided threshold (set in the report configuration).
+
+ The lines regrouped under a common prefix will be removed from the returned list of lines; only the prefix line will stay, folded.
+ Its expand function must ensure the right sublines are reloaded when unfolding it.
+
+ :param options: Option dict for this report.
+ :lines_to_group: The lines list to regroup by prefix if necessary. They must all have the same parent line (which might be no line at all).
+ :expand_function_name: Name of the expand function to be called on created prefix group lines, when unfolding them
+ :parent_level: Level of the parent line, which generated the lines in lines_to_group. It will be used to compute the level of the prefix group lines.
+ :matched_prefix': A string containing the parent prefix that's already matched. For example, when computing prefix 'ABC', matched_prefix will be 'AB'.
+ :groupby: groupby value of the parent line, which generated the lines in lines_to_group.
+ :parent_line_dict_id: id of the parent line, which generated the lines in lines_to_group.
+
+ :return: lines_to_group, grouped by prefix if it was necessary.
+ """
+ threshold = options['prefix_groups_threshold']
+
+ # When grouping by prefix, we ignore the totals
+ lines_to_group_without_totals = list(filter(lambda x: self._get_markup(x['id']) != 'total', lines_to_group))
+
+ if options['export_mode'] == 'print' or threshold <= 0 or len(lines_to_group_without_totals) < threshold:
+ # No grouping needs to be done
+ return lines_to_group
+
+ char_index = len(matched_prefix)
+ prefix_groups = defaultdict(list)
+ rslt = []
+ for line in lines_to_group_without_totals:
+ line_name = line['name'].strip()
+
+ if len(line_name) - 1 < char_index:
+ rslt.append(line)
+ else:
+ prefix_groups[line_name[char_index].lower()].append(line)
+
+ float_figure_types = {'monetary', 'integer', 'float'}
+ unfold_all = options['export_mode'] == 'print' or options.get('unfold_all')
+ for prefix_key, prefix_sublines in sorted(prefix_groups.items(), key=lambda x: x[0]):
+ # Compute the total of this prefix line, summming all of its content
+ prefix_expression_totals_by_group = {}
+ for column_index, column_data in enumerate(options['columns']):
+ if column_data['figure_type'] in float_figure_types:
+ # Then we want to sum this column's value in our children
+ for prefix_subline in prefix_sublines:
+ prefix_expr_label_result = prefix_expression_totals_by_group.setdefault(column_data['column_group_key'], {})
+ prefix_expr_label_result.setdefault(column_data['expression_label'], 0)
+ prefix_expr_label_result[column_data['expression_label']] += (prefix_subline['columns'][column_index]['no_format'] or 0)
+
+ column_values = []
+ for column in options['columns']:
+ col_value = prefix_expression_totals_by_group.get(column['column_group_key'], {}).get(column['expression_label'])
+
+ column_values.append(self._build_column_dict(col_value, column, options=options))
+
+ line_id = self._get_generic_line_id(None, None, parent_line_id=parent_line_dict_id, markup={'groupby_prefix_group': prefix_key})
+
+ sublines_nber = len(prefix_sublines)
+ prefix_to_display = prefix_key.upper()
+
+ if re.match(r'\s', prefix_to_display[-1]):
+ # In case the last character of the prefix to_display is blank, replace it by "[ ]", to make the space more visible to the user.
+ prefix_to_display = f'{prefix_to_display[:-1]}[ ]'
+
+ if sublines_nber == 1:
+ prefix_group_line_name = f"{matched_prefix}{prefix_to_display} " + _("(1 line)")
+ else:
+ prefix_group_line_name = f"{matched_prefix}{prefix_to_display} " + _("(%s lines)", sublines_nber)
+
+ prefix_group_line = {
+ 'id': line_id,
+ 'name': prefix_group_line_name,
+ 'unfoldable': True,
+ 'unfolded': unfold_all or line_id in options['unfolded_lines'],
+ 'columns': column_values,
+ 'groupby': groupby,
+ 'level': parent_level + 1,
+ 'parent_id': parent_line_dict_id,
+ 'expand_function': expand_function_name,
+ 'hide_line_buttons': True,
+ }
+ rslt.append(prefix_group_line)
+
+ return rslt
+
+ def _report_expand_unfoldable_line_groupby_prefix_group(self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None):
+ """ Expand function used by prefix_group lines generated for groupby lines.
+ """
+ report_line_id = None
+ parent_groupby_count = 0
+ for markup, model, model_id in reversed(self._parse_line_id(line_dict_id)):
+ if model == 'account.report.line':
+ report_line_id = model_id
+ break
+ elif isinstance(markup, dict) and 'groupby' in markup or 'groupby_prefix_group' in markup:
+ parent_groupby_count += 1
+
+ if report_line_id is None:
+ raise UserError(_("Trying to expand a group for a line which was not generated by a report line: %s", line_dict_id))
+
+ report_line = self.env['account.report.line'].browse(report_line_id)
+
+
+ matched_prefix = self._get_prefix_groups_matched_prefix_from_line_id(line_dict_id)
+ first_groupby = groupby.split(',')[0]
+ expand_options = {
+ **options,
+ 'forced_domain': options.get('forced_domain', []) + [(f"{f'{first_groupby}.' if first_groupby != 'id' else ''}name", '=ilike', f'{matched_prefix}%')]
+ }
+ expanded_groupby_lines = report_line._expand_groupby(line_dict_id, groupby, expand_options)
+ parent_level = report_line.hierarchy_level + parent_groupby_count * 2
+
+ lines = self._regroup_lines_by_name_prefix(
+ options,
+ expanded_groupby_lines,
+ '_report_expand_unfoldable_line_groupby_prefix_group',
+ parent_level,
+ groupby=groupby,
+ matched_prefix=matched_prefix,
+ parent_line_dict_id=line_dict_id,
+ )
+
+ return {
+ 'lines': lines,
+ 'offset_increment': len(lines),
+ 'has_more': False,
+ }
+
+ @api.model
+ def _get_prefix_groups_matched_prefix_from_line_id(self, line_dict_id):
+ matched_prefix = ''
+ for markup, dummy1, dummy2 in self._parse_line_id(line_dict_id):
+ if markup and isinstance(markup, dict) and 'groupby_prefix_group' in markup:
+ prefix_piece = markup['groupby_prefix_group']
+ matched_prefix += prefix_piece.upper()
+ else:
+ # Might happen if a groupby is grouped by prefix, then a subgroupby is grouped by another subprefix.
+ # In this case, we want to reset the prefix group to only consider the one used in the subgroupby.
+ matched_prefix = ''
+
+ return matched_prefix
+
+ @api.model
+ def format_value(self, options, value, figure_type, format_params=None):
+ if format_params is None:
+ format_params = {}
+
+ if 'currency' in format_params:
+ format_params['currency'] = self.env['res.currency'].browse(format_params['currency'].id)
+
+ return self._format_value(options=options, value=value, figure_type=figure_type, format_params=format_params)
+
+ def _format_value(self, options, value, figure_type, format_params=None):
+ """ Formats a value for display in a report (not especially numerical). figure_type provides the type of formatting we want.
+ """
+ if value is None:
+ return ''
+
+ if figure_type == 'none':
+ return value
+
+ if isinstance(value, str) or figure_type == 'string':
+ return str(value)
+
+ if format_params is None:
+ format_params = {}
+
+ formatLang_params = {
+ 'rounding_method': 'HALF-UP',
+ 'rounding_unit': options.get('rounding_unit'),
+ }
+
+ if figure_type == 'monetary':
+ currency = self.env['res.currency'].browse(format_params['currency_id']) if 'currency_id' in format_params else self.env.company.currency_id
+ if options.get('multi_currency'):
+ formatLang_params['currency_obj'] = currency
+ else:
+ formatLang_params['digits'] = currency.decimal_places
+
+ elif figure_type == 'integer':
+ formatLang_params['digits'] = 0
+
+ elif figure_type == 'boolean':
+ return _("Yes") if bool(value) else _("No")
+
+ elif figure_type in ('date', 'datetime'):
+ return format_date(self.env, value)
+
+ else:
+ formatLang_params['digits'] = format_params.get('digits', 1)
+
+ if self._is_value_zero(value, figure_type, format_params):
+ # Make sure -0.0 becomes 0.0
+ value = abs(value)
+
+ if self._context.get('no_format'):
+ return value
+
+ formatted_amount = formatLang(self.env, value, **formatLang_params)
+
+ if figure_type == 'percentage':
+ return f"{formatted_amount}%"
+
+ return formatted_amount
+
+ @api.model
+ def _is_value_zero(self, amount, figure_type, format_params):
+ if amount is None:
+ return True
+
+ if figure_type == 'monetary':
+ currency = self.env['res.currency'].browse(format_params['currency_id']) if 'currency_id' in format_params else self.env.company.currency_id
+ return currency.is_zero(amount)
+ elif figure_type in NUMBER_FIGURE_TYPES:
+ return float_is_zero(amount, precision_digits=format_params.get('digits', 0))
+ else:
+ return False
+
+ def format_date(self, options, dt_filter='date'):
+ date_from = fields.Date.from_string(options[dt_filter]['date_from'])
+ date_to = fields.Date.from_string(options[dt_filter]['date_to'])
+ return self._get_dates_period(date_from, date_to, options['date']['mode'])['string']
+
+ def export_file(self, options, file_generator):
+ self.ensure_one()
+
+ export_options = {**options, 'export_mode': 'file'}
+
+ return {
+ 'type': 'ir_actions_account_report_download',
+ 'data': {
+ 'options': json.dumps(export_options),
+ 'file_generator': file_generator,
+ }
+ }
+
+ def _get_report_send_recipients(self, options):
+ custom_handler_model = self._get_custom_handler_model()
+ if custom_handler_model and hasattr(self.env[custom_handler_model], '_get_report_send_recipients'):
+ return self.env[custom_handler_model]._get_report_send_recipients(options)
+ return self.env['res.partner']
+
+ def export_to_pdf(self, options):
+ self.ensure_one()
+
+ base_url = self.env['ir.config_parameter'].sudo().get_param('report.url') or self.env['ir.config_parameter'].sudo().get_param('web.base.url')
+ rcontext = {
+ 'mode': 'print',
+ 'base_url': base_url,
+ 'company': self.env.company,
+ }
+
+ print_options = self.get_options(previous_options={**options, 'export_mode': 'print'})
+ if print_options['sections']:
+ reports_to_print = self.env['account.report'].browse([section['id'] for section in print_options['sections']])
+ else:
+ reports_to_print = self
+
+ reports_options = []
+ for report in reports_to_print:
+ reports_options.append(report.get_options(previous_options={**print_options, 'selected_section_id': report.id}))
+
+ grouped_reports_by_format = groupby(
+ zip(reports_to_print, reports_options),
+ key=lambda report: len(report[1]['columns']) > 5 or report[1].get('horizontal_split')
+ )
+
+ footer = self.env['ir.actions.report']._render_template("odex30_account_reports.internal_layout", values=rcontext)
+ footer = self.env['ir.actions.report']._render_template("web.minimal_layout", values=dict(rcontext, subst=True, body=markupsafe.Markup(footer.decode())))
+
+ action_report = self.env['ir.actions.report']
+ files_stream = []
+ for is_landscape, reports_with_options in grouped_reports_by_format:
+ bodies = []
+
+ for report, report_options in reports_with_options:
+ bodies.append(report._get_pdf_export_html(
+ report_options,
+ report._filter_out_folded_children(report._get_lines(report_options)),
+ additional_context={'base_url': base_url}
+ ))
+
+ files_stream.append(
+ io.BytesIO(action_report._run_wkhtmltopdf(
+ bodies,
+ footer=footer.decode(),
+ landscape=is_landscape or self._context.get('force_landscape_printing'),
+ specific_paperformat_args={
+ 'data-report-margin-top': 10,
+ 'data-report-header-spacing': 10,
+ 'data-report-margin-bottom': 15,
+ }
+ )
+ ))
+
+ if len(files_stream) > 1:
+ result_stream = action_report._merge_pdfs(files_stream)
+ result = result_stream.getvalue()
+ # Close the different stream
+ result_stream.close()
+ for file_stream in files_stream:
+ file_stream.close()
+ else:
+ result = files_stream[0].read()
+
+ return {
+ 'file_name': self.get_default_report_filename(options, 'pdf'),
+ 'file_content': result,
+ 'file_type': 'pdf',
+ }
+
+ def _get_pdf_export_html(self, options, lines, additional_context=None, template=None):
+ report_info = self.get_report_information(options)
+
+ custom_print_templates = report_info['custom_display'].get('pdf_export', {})
+ template = custom_print_templates.get('pdf_export_main', 'odex30_account_reports.pdf_export_main')
+
+ render_values = {
+ 'report': self,
+ 'report_title': self.name,
+ 'options': options,
+ 'table_start': markupsafe.Markup(''),
+ 'table_end': markupsafe.Markup('''
+
+
+
+
+ '''),
+ 'column_headers_render_data': self._get_column_headers_render_data(options),
+ 'custom_templates': custom_print_templates,
+ }
+ if additional_context:
+ render_values.update(additional_context)
+
+ if options.get('order_column'):
+ lines = self.sort_lines(lines, options)
+
+ lines = self._format_lines_for_display(lines, options)
+
+ render_values['lines'] = lines
+
+ # Manage annotations.
+ render_values['annotations'] = self._build_annotations_list_for_pdf_export(options['date'], lines, report_info['annotations'])
+
+ options['css_custom_class'] = report_info['custom_display'].get('css_custom_class', '')
+
+ # Render.
+ return self.env['ir.qweb']._render(template, render_values)
+
+ def _build_annotations_list_for_pdf_export(self, date_options, lines, annotations_per_line_id):
+ annotations_to_render = []
+ number = 0
+ for line in lines:
+ if line_annotations := annotations_per_line_id.get(line['id']):
+ line['annotations'] = []
+ for annotation in line_annotations:
+ report_period_date_from = datetime.datetime.strptime(date_options['date_from'], '%Y-%m-%d').date()
+ report_period_date_to = datetime.datetime.strptime(date_options['date_to'], '%Y-%m-%d').date()
+ if not annotation['date'] or report_period_date_from <= annotation['date'] <= report_period_date_to:
+ number += 1
+ line['annotations'].append(str(number))
+ annotations_to_render.append({
+ 'number': str(number),
+ 'text': annotation['text'],
+ 'date': format_date(self.env, annotation['date']) if annotation['date'] else None,
+ })
+ return annotations_to_render
+
+ def _filter_out_folded_children(self, lines):
+ """ Returns a list containing all the lines of the provided list that need to be displayed when printing,
+ hence removing the children whose parent is folded (especially useful to remove total lines).
+ """
+ rslt = []
+ folded_lines = set()
+ for line in lines:
+ if line.get('unfoldable') and not line.get('unfolded'):
+ folded_lines.add(line['id'])
+
+ if 'parent_id' not in line or line['parent_id'] not in folded_lines:
+ rslt.append(line)
+ return rslt
+
+ def export_to_xlsx(self, options, response=None):
+ def add_worksheet_unique_name(workbook, sheet_name):
+ existing_names = set(workbook.sheetnames.keys())
+ count = 1
+ max_length = 31
+ new_sheet_name = sheet_name[:max_length]
+
+ while new_sheet_name in existing_names:
+ suffix = f" ({count})"
+ truncated_name = sheet_name[:max_length - len(suffix)]
+ new_sheet_name = f"{truncated_name}{suffix}"
+ count += 1
+ return workbook.add_worksheet(new_sheet_name)
+
+ self.ensure_one()
+ output = io.BytesIO()
+ workbook = xlsxwriter.Workbook(output, {
+ 'in_memory': True,
+ 'strings_to_formulas': False,
+ })
+
+ print_options = self.get_options(previous_options={**options, 'export_mode': 'print'})
+ if print_options['sections']:
+ reports_to_print = self.env['account.report'].browse([section['id'] for section in print_options['sections']])
+ else:
+ reports_to_print = self
+
+ reports_options = []
+ for report in reports_to_print:
+ report_options = report.get_options(previous_options={**print_options, 'selected_section_id': report.id})
+ reports_options.append(report_options)
+ report._inject_report_into_xlsx_sheet(report_options, workbook, add_worksheet_unique_name(workbook, report.name))
+
+ self._add_options_xlsx_sheet(workbook, reports_options)
+
+ workbook.close()
+ output.seek(0)
+ generated_file = output.read()
+ output.close()
+
+ return {
+ 'file_name': self.get_default_report_filename(options, 'xlsx'),
+ 'file_content': generated_file,
+ 'file_type': 'xlsx',
+ }
+
+ @api.model
+ def _set_xlsx_cell_sizes(self, sheet, fonts, col, row, value, style, has_colspan):
+ """ This small helper will resize the cells if needed, to allow to get a better output. """
+ def get_string_width(font, string):
+ return font.getlength(string) / 5
+
+ # Get the correct font for the row style
+ font_type = ('Bol' if style.bold else 'Reg') + ('Ita' if style.italic else '')
+ report_font = fonts[font_type]
+
+ # 8.43 is the default width of a column in Excel.
+ if parse_version(xlsxwriter.__version__) >= parse_version('3.0.6'):
+ # cols_sizes was removed in 3.0.6 and colinfo was replaced by col_info
+ try:
+ col_width = sheet.col_info[col][0]
+ except KeyError:
+ col_width = 8.43
+ else:
+ col_width = sheet.col_sizes.get(col, [8.43])[0]
+
+ row_height = sheet.row_sizes.get(row, [8.43])[0]
+
+ if value is None:
+ value = ''
+ else:
+ try: # noqa: SIM105
+ # This is needed, otherwise we could compute width on very long number such as 12.0999999998
+ # which wouldn't show well in the end result as the numbers are rounded.
+ value = float_repr(float(value), self.env.company.currency_id.decimal_places)
+ except (ValueError, OverflowError):
+ pass
+
+ # Start by computing the width of the cell if we are not using colspans.
+ if not has_colspan:
+ # Ensure to take indents into account when computing the width.
+ formatted_value = f"{' ' * style.indent}{value}"
+ width = get_string_width(
+ report_font,
+ max(formatted_value.split('\n'), key=lambda line: get_string_width(report_font, line))
+ )
+ # We set the width if it is bigger than the current one, with a limit at 75 (max to avoid taking excessive space).
+ if width > col_width:
+ sheet.set_column(col, col, min(width + 4, 75)) # We need to add a little extra padding to ensure our columns are not clipping the text
+
+ def _get_xlsx_export_fonts(self):
+ """ Get the bold, italic and regular LATO font information so that we can use them for format purposes. """
+ fonts = {}
+ for font_type in ('Reg', 'Bol', 'RegIta', 'BolIta'):
+ try:
+ lato_path = f'web/static/fonts/lato/Lato-{font_type}-webfont.ttf'
+ fonts[font_type] = ImageFont.truetype(file_path(lato_path), 12)
+ except (OSError, FileNotFoundError):
+ # This won't give great result, but it will work.
+ fonts[font_type] = ImageFont.load_default()
+ return fonts
+
+ def _inject_report_into_xlsx_sheet(self, options, workbook, sheet):
+ fonts = self._get_xlsx_export_fonts()
+
+ def write_cell(sheet, x, y, value, style, colspan=1, datetime=False):
+ self._set_xlsx_cell_sizes(sheet, fonts, x, y, value, style, colspan > 1)
+ if colspan == 1:
+ if datetime:
+ sheet.write_datetime(y, x, value, style)
+ else:
+ sheet.write(y, x, value, style)
+ else:
+ sheet.merge_range(y, x, y, x + colspan - 1, value, style)
+
+ default_format_props = {'font_name': 'Lato', 'font_size': 12, 'font_color': '#666666', 'num_format': '#,##0.00'}
+ text_format_props = {'font_name': 'Lato', 'font_size': 12, 'font_color': '#666666'}
+ date_format_props = {'font_name': 'Lato', 'font_size': 12, 'font_color': '#666666', 'align': 'left', 'num_format': 'yyyy-mm-dd'}
+ title_format = workbook.add_format({'font_name': 'Lato', 'font_size': 12, 'bold': True, 'bottom': 2})
+ annotation_format = workbook.add_format({**text_format_props, 'text_wrap': True})
+ workbook_formats = {
+ 0: {
+ 'default': workbook.add_format({**default_format_props, 'bold': True, 'font_size': 13, 'bottom': 6}),
+ 'text': workbook.add_format({**text_format_props, 'bold': True, 'font_size': 13, 'bottom': 6}),
+ 'date': workbook.add_format({**date_format_props, 'bold': True, 'font_size': 13, 'bottom': 6}),
+ 'total': workbook.add_format({**default_format_props, 'bold': True, 'font_size': 13, 'bottom': 6}),
+ },
+ 1: {
+ 'default': workbook.add_format({**default_format_props, 'bold': True, 'font_size': 13, 'bottom': 1}),
+ 'text': workbook.add_format({**text_format_props, 'bold': True, 'font_size': 13, 'bottom': 1}),
+ 'date': workbook.add_format({**date_format_props, 'bold': True, 'font_size': 13, 'bottom': 1}),
+ 'total': workbook.add_format({**default_format_props, 'bold': True, 'font_size': 13, 'bottom': 1}),
+ 'default_indent': workbook.add_format({**default_format_props, 'bold': True, 'font_size': 13, 'bottom': 1, 'indent': 1}),
+ 'date_indent': workbook.add_format({**date_format_props, 'bold': True, 'font_size': 13, 'bottom': 1, 'indent': 1}),
+ },
+ 2: {
+ 'default': workbook.add_format({**default_format_props, 'bold': True}),
+ 'text': workbook.add_format({**text_format_props, 'bold': True}),
+ 'date': workbook.add_format({**date_format_props, 'bold': True}),
+ 'initial': workbook.add_format(default_format_props),
+ 'total': workbook.add_format({**default_format_props, 'bold': True}),
+ 'default_indent': workbook.add_format({**default_format_props, 'bold': True, 'indent': 2}),
+ 'date_indent': workbook.add_format({**date_format_props, 'bold': True, 'indent': 2}),
+ 'initial_indent': workbook.add_format({**default_format_props, 'indent': 2}),
+ 'total_indent': workbook.add_format({**default_format_props, 'bold': True, 'indent': 1}),
+ },
+ 'default': {
+ 'default': workbook.add_format(default_format_props),
+ 'text': workbook.add_format(text_format_props),
+ 'date': workbook.add_format(date_format_props),
+ 'total': workbook.add_format(default_format_props),
+ 'default_indent': workbook.add_format({**default_format_props, 'indent': 2}),
+ 'date_indent': workbook.add_format({**date_format_props, 'indent': 2}),
+ 'total_indent': workbook.add_format({**default_format_props, 'indent': 2}),
+ },
+ }
+
+ def get_format(content_type='default', level='default'):
+ if isinstance(level, int) and level not in workbook_formats:
+ workbook_formats[level] = {
+ **workbook_formats['default'],
+ 'default_indent': workbook.add_format({**default_format_props, 'indent': level}),
+ 'date_indent': workbook.add_format({**date_format_props, 'indent': level}),
+ 'total_indent': workbook.add_format({**default_format_props, 'bold': True, 'indent': level - 1}),
+ }
+
+ level_formats = workbook_formats[level]
+ if '_indent' in content_type and not level_formats.get(content_type):
+ return level_formats.get('default_indent', level_formats.get(content_type.removesuffix('_indent'), level_formats['default']))
+ return level_formats.get(content_type, level_formats['default'])
+
+ print_mode_self = self.with_context(no_format=True)
+ lines = self._filter_out_folded_children(print_mode_self._get_lines(options))
+ annotations = self.get_annotations(options)
+
+ # For reports with lines generated for accounts, the account name and codes are shown in a single column.
+ # To help user post-process the report if they need, we should in such a case split the account name and code in two columns.
+ account_lines_split_names = {}
+ for line in lines:
+ line_model = self._get_model_info_from_id(line['id'])[0]
+ if line_model == 'account.account':
+ # Reuse the _split_code_name to split the name and code in two values.
+ account_lines_split_names[line['id']] = self.env['account.account']._split_code_name(line['name'])
+
+ # Set the (Account) Name column width to 50.
+ # If we have account lines and split the name and code in two columns, we will also set the code column.
+ if len(account_lines_split_names) > 0:
+ sheet.set_column(0, 0, 13)
+ sheet.set_column(1, 1, 50)
+ else:
+ sheet.set_column(0, 0, 50)
+
+ if not options.get('no_xlsx_currency_code_columns'):
+ self._add_xlsx_currency_codes_columns(options, lines)
+
+ original_x_offset = 1 if len(account_lines_split_names) > 0 else 0
+
+ y_offset = 0
+ # 1 and not 0 to leave space for the line name. original_x_offset allows making place for the code column if needed.
+ x_offset = original_x_offset + 1
+
+ # Add headers.
+ # For this, iterate in the same way as done in main_table_header template
+ column_headers_render_data = self._get_column_headers_render_data(options)
+ for header_level_index, header_level in enumerate(options['column_headers']):
+ for header_to_render in header_level * column_headers_render_data['level_repetitions'][header_level_index]:
+ colspan = header_to_render.get('colspan', column_headers_render_data['level_colspan'][header_level_index])
+ write_cell(sheet, x_offset, y_offset, header_to_render.get('name', ''), title_format, colspan + (1 if options['show_horizontal_group_total'] and header_level_index == 0 else 0))
+ x_offset += colspan
+ if options.get('column_percent_comparison') == 'growth':
+ write_cell(sheet, x_offset, y_offset, '%', title_format)
+ x_offset += 1
+
+ if options['show_horizontal_group_total'] and header_level_index != 0:
+ horizontal_group_name = next((group['name'] for group in options['available_horizontal_groups'] if group['id'] == options['selected_horizontal_group_id']), None)
+ write_cell(sheet, x_offset, y_offset, horizontal_group_name, title_format)
+ x_offset += 1
+ if annotations:
+ annotations_x_offset = x_offset
+ write_cell(sheet, annotations_x_offset, y_offset, 'Annotations', title_format)
+ x_offset += 1
+ y_offset += 1
+ x_offset = original_x_offset + 1
+
+ for subheader in column_headers_render_data['custom_subheaders']:
+ colspan = subheader.get('colspan', 1)
+ write_cell(sheet, x_offset, y_offset, subheader.get('name', ''), title_format, colspan)
+ x_offset += colspan
+ y_offset += 1
+ x_offset = original_x_offset + 1
+
+ if account_lines_split_names:
+ # If we have a separate account code column, add a title for it
+ write_cell(sheet, x_offset - 2, y_offset, _("Code"), title_format)
+ write_cell(sheet, x_offset - 1, y_offset, _("Account Name"), title_format)
+ sheet.set_column(x_offset, x_offset + len(options['columns']), 10)
+
+ for column in options['columns']:
+ colspan = column.get('colspan', 1)
+ write_cell(sheet, x_offset, y_offset, column.get('name', ''), title_format, colspan)
+ x_offset += colspan
+
+ if options['show_horizontal_group_total']:
+ write_cell(sheet, x_offset, y_offset, options['columns'][0].get('name', ''), title_format, colspan)
+
+ if options.get('column_percent_comparison') == 'growth':
+ write_cell(sheet, x_offset, y_offset, '', title_format, colspan)
+ y_offset += 1
+
+ if options.get('order_column'):
+ lines = self.sort_lines(lines, options)
+
+ # Disable bold styling for the max level.
+ max_level = max(line.get('level', -1) for line in lines) if lines else -1
+ if max_level in {0, 1, 2}:
+ # Total lines are supposed to be a level above, so we don't touch them.
+ for wb_format in (s for s in workbook_formats[max_level] if 'total' not in s):
+ workbook_formats[max_level][wb_format].set_bold(False)
+
+ # Add lines.
+ counter = 1
+ for y, line in enumerate(lines):
+ level = line.get('level')
+ if level == 0:
+ y_offset += 1
+ elif not level:
+ level = 'default'
+
+ line_id = self._parse_line_id(line.get('id'))
+ is_initial_line = line_id[-1][0] == 'initial' if line_id else False
+ is_total_line = line_id[-1][0] == 'total' if line_id else False
+
+ # Write the first column(s), with a specific style to manage the indentation.
+ cell_type, cell_value = self._get_cell_type_value(line)
+ account_code_cell_format = get_format('text', level)
+
+ if cell_type == 'date':
+ cell_format = get_format('date_indent', level)
+ elif is_initial_line:
+ cell_format = get_format('initial_indent', level)
+ elif is_total_line:
+ cell_format = get_format('total_indent', level)
+ else:
+ cell_format = get_format('default_indent', level)
+
+ x_offset = original_x_offset + 1
+ if lines[y]['id'] in account_lines_split_names:
+ # Write the Account Code and Name columns.
+ code, name = account_lines_split_names[lines[y]['id']]
+ # Don't indent the account code and don't format is as a monetary value either.
+ write_cell(sheet, 0, y + y_offset, code, account_code_cell_format)
+ write_cell(sheet, 1, y + y_offset, name, cell_format)
+ else:
+ write_cell(sheet, original_x_offset, y + y_offset, cell_value, cell_format, datetime=cell_type == 'date')
+
+ if 'parent_id' in line and line['parent_id'] in account_lines_split_names:
+ write_cell(sheet, 1 + original_x_offset, y + y_offset, account_lines_split_names[line['parent_id']][0], account_code_cell_format)
+ elif account_lines_split_names:
+ write_cell(sheet, 1 + original_x_offset, y + y_offset, "", account_code_cell_format)
+
+ # Write all the remaining cells.
+ columns = line['columns']
+ if options.get('column_percent_comparison') and 'column_percent_comparison_data' in line:
+ columns += [line['column_percent_comparison_data']]
+
+ if options['show_horizontal_group_total']:
+ columns += [line.get('horizontal_group_total_data', {'name': 0})]
+ for x, column in enumerate(columns, start=x_offset):
+ cell_type, cell_value = self._get_cell_type_value(column)
+ if cell_type == 'date':
+ cell_format = get_format('date', level)
+ elif is_initial_line:
+ cell_format = get_format('initial', level)
+ elif is_total_line:
+ cell_format = get_format('total', level)
+ else:
+ cell_format = get_format('default', level)
+ write_cell(sheet, x + line.get('colspan', 1) - 1, y + y_offset, cell_value, cell_format, datetime=cell_type == 'date')
+
+ # Write annotations.
+ if annotations and (line_annotations := annotations.get(line['id'])):
+ line_annotation_text = []
+ for line_annotation in line_annotations:
+ line_annotation_text.append(f"{counter} - {line_annotation['text']}")
+ counter += 1
+ write_cell(sheet, annotations_x_offset, y + y_offset, "\n".join(line_annotation_text), annotation_format)
+
+ def _add_xlsx_currency_codes_columns(self, options, lines):
+ """ Adds a 'Currency Code' column for each column displaying amounts in foreign currencies. This is done because
+ the raw number is displayed on the xlsx file, making it impossible to know the currency used.
+ To have it displayed, the line must have an expression label starting with '_currency_' """
+ required_currency_code_columns = {
+ label.removeprefix('_currency_')
+ for label in self.line_ids.expression_ids.mapped('label')
+ if label.startswith('_currency_')
+ }
+
+ new_columns = []
+ for col in options['columns']:
+ new_columns.append(col)
+
+ if col['expression_label'] in required_currency_code_columns:
+ new_columns.append({
+ **col,
+ 'name': _("Currency Code"),
+ 'figure_type': 'string',
+ 'expression_label': f"_xlsx_currency_code_{col['expression_label']}"
+ })
+
+ options['columns'] = new_columns
+
+ # Add 'Currency Code' values to each line
+ for line in lines:
+ new_column_values = []
+
+ for index, col_data in enumerate(line['columns']):
+ new_column_values.append(col_data)
+
+ if col_data.get('expression_label') in required_currency_code_columns:
+ currency = col_data.get('currency')
+ currency_code = currency.name if currency else ''
+ new_column = self._build_column_dict(currency_code, options['columns'][index+1], options)
+ new_column['name'] = new_column['no_format']
+ new_column_values.append(new_column)
+
+ line['columns'] = new_column_values
+
+ def _add_options_xlsx_sheet(self, workbook, options_list):
+ """Adds a new sheet for xlsx report exports with a summary of all filters and options activated at the moment of the export."""
+ filters_sheet = workbook.add_worksheet(_("Filters"))
+ # Set first and second column widths.
+ filters_sheet.set_column(0, 0, 20)
+ filters_sheet.set_column(1, 1, 50)
+ name_style = workbook.add_format({'font_name': 'Arial', 'bold': True, 'bottom': 2})
+ y_offset = 0
+
+ if len(options_list) == 1:
+ self.env['account.report'].browse(options_list[0]['report_id'])._inject_report_options_into_xlsx_sheet(options_list[0], filters_sheet, y_offset)
+ return
+
+ # Find uncommon keys
+ options_sets = list(map(set, options_list))
+ common_keys = set.intersection(*options_sets)
+ all_keys = set.union(*options_sets)
+ uncommon_options_keys = all_keys - common_keys
+ # Try to find the common filter values between all reports to avoid duplication.
+ common_options_values = {}
+ for key in common_keys:
+ first_value = options_list[0][key]
+ if all(options[key] == first_value for options in options_list[1:]):
+ common_options_values[key] = first_value
+ else:
+ uncommon_options_keys.add(key)
+
+ # Write common options to the sheet.
+ filters_sheet.write(y_offset, 0, _("All"), name_style)
+ y_offset += 1
+ y_offset = self._inject_report_options_into_xlsx_sheet(common_options_values, filters_sheet, y_offset)
+
+ for report_options in options_list:
+ report = self.env['account.report'].browse(report_options['report_id'])
+
+ filters_sheet.write(y_offset, 0, report.name, name_style)
+ y_offset += 1
+ new_offset = report._inject_report_options_into_xlsx_sheet(report_options, filters_sheet, y_offset, uncommon_options_keys)
+
+ if y_offset == new_offset:
+ y_offset -= 1
+ # Clear the report name's cell since it didn't add any data to the xlsx.
+ filters_sheet.write(y_offset, 0, " ")
+ else:
+ y_offset = new_offset
+
+ def _inject_report_options_into_xlsx_sheet(self, options, sheet, y_offset, options_to_print=None):
+ """
+ Injects the report options into the filters sheet.
+
+ :param options: Dictionary containing report options.
+ :param sheet: XLSX sheet to inject options into.
+ :param y_offset: Offset for the vertical position in the sheet.
+ :param options_to_print: Optional list of names to print. If not provided, all printable options will be included.
+ """
+ def write_filter_lines(filter_title, filter_lines, y_offset):
+ sheet.write(y_offset, 0, filter_title)
+ for line in filter_lines:
+ sheet.write(y_offset, 1, line)
+ y_offset += 1
+ return y_offset
+
+ def should_print_option(option_key):
+ """Check if the option should be printed based on options_to_print."""
+ return not options_to_print or option_key in options_to_print
+
+ # Company
+ if should_print_option('companies'):
+ companies = options['companies']
+ title = _("Companies") if len(companies) > 1 else _("Company")
+ lines = [company['name'] for company in companies]
+ y_offset = write_filter_lines(title, lines, y_offset)
+
+ # Journals
+ if should_print_option('journals') and (journals := options.get('journals')):
+ journal_titles = [journal.get('title') for journal in journals if journal.get('selected')]
+ if journal_titles:
+ y_offset = write_filter_lines(_("Journals"), journal_titles, y_offset)
+
+ # Partners
+ if should_print_option('selected_partner_ids') and (partner_names := options.get('selected_partner_ids')):
+ y_offset = write_filter_lines(_("Partners"), partner_names, y_offset)
+
+ # Partner categories
+ if should_print_option('selected_partner_categories') and (partner_categories := options.get('selected_partner_categories')):
+ y_offset = write_filter_lines(_("Partner Categories"), partner_categories, y_offset)
+
+ # Horizontal groups
+ if should_print_option('selected_horizontal_group_id') and (group_id := options.get('selected_horizontal_group_id')):
+ for horizontal_group in options['available_horizontal_groups']:
+ if horizontal_group['id'] == group_id:
+ filter_name = horizontal_group['name']
+ y_offset = write_filter_lines(_("Horizontal Group"), [filter_name], y_offset)
+ break
+
+ # Currency
+ if should_print_option('company_currency') and options.get('company_currency'):
+ y_offset = write_filter_lines(_("Company Currency"), [options['company_currency']['currency_name']], y_offset)
+
+ # Filters
+ if should_print_option('aml_ir_filters'):
+ if options.get('aml_ir_filters') and any(opt['selected'] for opt in options['aml_ir_filters']):
+ filter_names = [opt['name'] for opt in options['aml_ir_filters'] if opt['selected']]
+ y_offset = write_filter_lines(_("Filters"), filter_names, y_offset)
+
+ # Extra options
+ # Array of tuples for the extra options: (name, option_key, condition)
+ extra_options = [
+ (_("With Draft Entries"), 'all_entries', self.filter_show_draft),
+ (_("Unreconciled Entries"), 'unreconciled', self.filter_unreconciled),
+ (_("Including Analytic Simulations"), 'include_analytic_without_aml', True)
+ ]
+ filter_names = [
+ name for name, option_key, condition in extra_options
+ if (not options_to_print or option_key in options_to_print) and condition and options.get(option_key)
+ ]
+ if filter_names:
+ y_offset = write_filter_lines(_("Options"), filter_names, y_offset)
+
+ return y_offset
+
+ def _get_cell_type_value(self, cell):
+ if 'date' not in cell.get('class', '') or not cell.get('name'):
+ # cell is not a date
+ return ('text', cell.get('name', ''))
+ if isinstance(cell['name'], (float, datetime.date, datetime.datetime)):
+ # the date is xlsx compatible
+ return ('date', cell['name'])
+ try:
+ # the date is parsable to a xlsx compatible date
+ lg = get_lang(self.env, self.env.user.lang)
+ return ('date', datetime.datetime.strptime(cell['name'], lg.date_format))
+ except:
+ # the date is not parsable thus is returned as text
+ return ('text', cell['name'])
+
+ def get_vat_for_export(self, options, raise_warning=True):
+ """ Returns the VAT number to use when exporting this report with the provided
+ options. If a single fiscal_position option is set, its VAT number will be
+ used; else the current company's will be, raising an error if its empty.
+ """
+ self.ensure_one()
+
+ if self.filter_multi_company == 'tax_units' and options['tax_unit'] != 'company_only':
+ tax_unit = self.env['account.tax.unit'].browse(options['tax_unit'])
+ return tax_unit.vat
+
+ if options['fiscal_position'] in {'all', 'domestic'}:
+ company = self._get_sender_company_for_export(options)
+ if not company.vat and raise_warning:
+ action = self.env.ref('base.action_res_company_form')
+ raise RedirectWarning(_('No VAT number associated with your company. Please define one.'), action.id, _("Company Settings"))
+ return company.vat
+
+ fiscal_position = self.env['account.fiscal.position'].browse(options['fiscal_position'])
+ return fiscal_position.foreign_vat
+
+ @api.model
+ def get_report_company_ids(self, options):
+ """ Returns a list containing the ids of the companies to be used to
+ render this report, following the provided options.
+ """
+ return [comp_data['id'] for comp_data in options['companies']]
+
+ def _get_partner_and_general_ledger_initial_balance_line(self, options, parent_line_id, eval_dict, account_currency=None, level_shift=0):
+ """ Helper to generate dynamic 'initial balance' lines, used by general ledger and partner ledger.
+ """
+ line_columns = []
+ for column in options['columns']:
+ col_value = eval_dict[column['column_group_key']].get(column['expression_label'])
+ col_expr_label = column['expression_label']
+
+ if col_value is None or (col_expr_label == 'amount_currency' and not account_currency):
+ line_columns.append(self._build_column_dict(None, None))
+ else:
+ line_columns.append(self._build_column_dict(
+ col_value,
+ column,
+ options=options,
+ currency=account_currency if col_expr_label == 'amount_currency' else None,
+ ))
+
+ # Display unfold & initial balance even when debit/credit column is hidden and the balance == 0
+ if not any(isinstance(column.get('no_format'), (int, float)) and column.get('expression_label') != 'balance' for column in line_columns):
+ return None
+
+ return {
+ 'id': self._get_generic_line_id(None, None, parent_line_id=parent_line_id, markup='initial'),
+ 'name': _("Initial Balance"),
+ 'level': 3 + level_shift,
+ 'parent_id': parent_line_id,
+ 'columns': line_columns,
+ }
+
+ def _compute_column_percent_comparison_data(self, options, value1, value2, green_on_positive=True):
+ ''' Helper to get the additional columns due to the growth comparison feature. When only one comparison is
+ requested, an additional column is there to show the percentage of growth based on the compared period.
+ :param options: The report options.
+ :param value1: The value in the current period.
+ :param value2: The value in the compared period.
+ :param green_on_positive: A flag customizing the value with a green color depending if the growth is positive.
+ :return: The new columns to add to line['columns'].
+ '''
+ if value1 is None or value2 is None or float_is_zero(value2, precision_rounding=0.1):
+ return {'name': _('n/a'), 'mode': 'muted'}
+
+ comparison_type = options['column_percent_comparison']
+ if comparison_type == 'growth':
+
+ values_diff = value1 - value2
+ growth = round(values_diff / value2 * 100, 1)
+
+ # In case the comparison is made on a negative figure, the color should be the other
+ # way around. For example:
+ # 2018 2017 %
+ # Product Sales 1000.00 -1000.00 -200.0%
+ #
+ # The percentage is negative, which is mathematically correct, but my sales increased
+ # => it should be green, not red!
+ if float_is_zero(growth, 1):
+ return {'name': '0.0%', 'mode': 'muted'}
+ else:
+ return {
+ 'name': f"{float_repr(growth, 1)}%",
+ 'mode': 'red' if ((values_diff > 0) ^ green_on_positive) else 'green',
+ }
+
+ elif comparison_type == 'budget':
+ percentage_value = value1 / value2 * 100
+ if float_is_zero(percentage_value, 1):
+ # To avoid negative 0
+ return {'name': '0.0%', 'mode': 'green'}
+
+ comparison_value = float_compare(value1, value2, 1)
+ return {
+ 'name': f"{float_repr(percentage_value, 1)}%",
+ 'mode': 'green' if (comparison_value >= 0 and green_on_positive) or (comparison_value == -1 and not green_on_positive) else 'red',
+ }
+
+ def _set_budget_column_comparisons(self, options, line):
+ """
+ Set the percentage values in the budget columns
+ """
+ for col_index, col in enumerate(line['columns']):
+ col_group_data = options['column_groups'][col['column_group_key']]
+ if 'budget_percentage' in col_group_data.get('forced_options'):
+ budget_id = col_group_data['forced_options']['budget_percentage']
+ date_key = col_group_data.get('forced_options', {}).get('date')
+ if not date_key:
+ continue
+
+ budget_base_col = None
+ budget_amount_col = None
+ for line_col in line['columns']:
+ other_col_group_key = line_col['column_group_key']
+ other_col_options = options['column_groups'][other_col_group_key]
+ if other_col_options.get('forced_options', {}).get('date') == date_key:
+ if other_col_options.get('forced_options', {}).get('budget_base') and line_col['figure_type'] == 'monetary':
+ budget_base_col = line_col
+ elif other_col_options.get('forced_options', {}).get('compute_budget') == budget_id:
+ budget_amount_col = line_col
+
+ value = self._compute_column_percent_comparison_data(
+ options,
+ budget_base_col['no_format'],
+ budget_amount_col['no_format'],
+ green_on_positive=budget_base_col['green_on_positive'],
+ )
+ comparison_column = self._build_column_dict(
+ value['name'],
+ {
+ **budget_amount_col,
+ 'figure_type': 'string',
+ 'comparison_mode': value['mode'],
+ }
+ )
+ line['columns'][col_index] = comparison_column
+
+ def _check_groupby_fields(self, groupby_fields_name: list[str] | str):
+ """ Checks that each string in the groupby_fields_name list is a valid groupby value for an accounting report.
+ So it must be:
+ - a field from account.move.line which is (1) searchable and (2) for which _field_to_sql is implemented,
+ this includes stored and related non-stored fields, or
+ - a custom value allowed by the _get_custom_groupby_map function of the custom handler
+ """
+ self.ensure_one()
+ if isinstance(groupby_fields_name, str | bool):
+ groupby_fields_name = groupby_fields_name.split(',') if groupby_fields_name else []
+
+ for field_name in (fname.strip() for fname in groupby_fields_name):
+ groupby_field = self.env['account.move.line']._fields.get(field_name)
+ if groupby_field:
+ if not groupby_field._description_searchable:
+ raise UserError(self.env._("Field %s of account.move.line is not searchable and can therefore not be used in a groupby expression.", field_name))
+ try:
+ self.env['account.move.line']._field_to_sql('account_move_line', field_name, Query(self.env, 'account_move_line'))
+ except ValueError:
+ raise UserError(self.env._("Field %s of account.move.line cannot be used in a groupby expression.", field_name)) from None
+ elif (custom_handler_name := self._get_custom_handler_model()):
+ if field_name not in self.env[custom_handler_name]._get_custom_groupby_map():
+ raise UserError(_("Field %s does not exist on account.move.line, and is not supported by this report's custom handler.", field_name))
+ else:
+ raise UserError(_("Field %s does not exist on account.move.line.", field_name))
+
+ # ============ Accounts Coverage Debugging Tool - START ================
+ @api.depends('country_id', 'chart_template', 'root_report_id')
+ def _compute_is_account_coverage_report_available(self):
+ for report in self:
+ report.is_account_coverage_report_available = (
+ (
+ report.availability_condition == 'country' and self.env.company.account_fiscal_country_id == report.country_id
+ or
+ report.availability_condition == 'coa' and self.env.company.chart_template == report.chart_template
+ or
+ report.availability_condition == 'always'
+ )
+ and
+ report.root_report_id in (
+ self.env.ref('odex30_account_reports.profit_and_loss', raise_if_not_found=False),
+ self.env.ref('odex30_account_reports.balance_sheet', raise_if_not_found=False)
+ )
+ )
+
+ def action_download_xlsx_accounts_coverage_report(self):
+ """
+ Generate an XLSX file that can be used to debug the
+ report by issuing the following warnings if applicable:
+ - an account exists in the Chart of Accounts but is not mentioned in any line of the report (red)
+ - an account is reported in multiple lines of the report (orange)
+ - an account is reported in a line of the report but does not exist in the Chart of Accounts (yellow)
+ """
+ self.ensure_one()
+ if not self.is_account_coverage_report_available:
+ raise UserError(_("The Accounts Coverage Report is not available for this report."))
+
+ output = io.BytesIO()
+ workbook = xlsxwriter.Workbook(output, {'in_memory': True})
+ worksheet = workbook.add_worksheet(_('Accounts coverage'))
+ worksheet.set_column(0, 0, 20)
+ worksheet.set_column(1, 1, 75)
+ worksheet.set_column(2, 2, 80)
+ worksheet.freeze_panes(1, 0)
+
+ headers = [_("Account Code / Tag"), _("Error message"), _("Report lines mentioning the account code"), '#FFFFFF']
+ lines = [headers] + self._generate_accounts_coverage_report_xlsx_lines()
+ for i, line in enumerate(lines):
+ worksheet.write_row(i, 0, line[:-1], workbook.add_format({'bg_color': line[-1]}))
+
+ workbook.close()
+ attachment_id = self.env['ir.attachment'].create({
+ 'name': f"{self.display_name} - {_('Accounts Coverage Report')}",
+ 'datas': base64.encodebytes(output.getvalue())
+ })
+ return {
+ "type": "ir.actions.act_url",
+ "url": f"/web/content/{attachment_id.id}",
+ "target": "download",
+ }
+
+ def _generate_accounts_coverage_report_xlsx_lines(self):
+ """
+ Generate the lines of the XLSX file that can be used to debug the
+ report by issuing the following warnings if applicable:
+ - an account exists in the Chart of Accounts but is not mentioned in any line of the report (red)
+ - an account is reported in multiple lines of the report (orange)
+ - an account is reported in a line of the report but does not exist in the Chart of Accounts (yellow)
+ """
+ def get_account_domain(prefix):
+ # Helper function to get the right domain to find the account
+ # This function verifies if we have to look for a tag or if we have
+ # to look for an account code.
+ if tag_matching := ACCOUNT_CODES_ENGINE_TAG_ID_PREFIX_REGEX.match(prefix):
+ if tag_matching['ref']:
+ account_tag_id = self.env['ir.model.data']._xmlid_to_res_id(tag_matching['ref'])
+ else:
+ account_tag_id = int(tag_matching['id'])
+ return 'tag_ids', 'in', (account_tag_id,)
+ else:
+ return 'code', '=like', f'{prefix}%'
+
+ self.ensure_one()
+
+ all_reported_accounts = self.env["account.account"] # All accounts mentioned in the report (including those reported without using the account code)
+ accounts_by_expressions = {} # {expression_id: account.account objects}
+ reported_account_codes = [] # [{'prefix': ..., 'balance': ..., 'exclude': ..., 'line': ...}, ...]
+ non_existing_codes = defaultdict(lambda: self.env["account.report.line"]) # {non_existing_account_code: {lines_with_that_code,}}
+ lines_per_non_linked_tag = defaultdict(lambda: self.env['account.report.line'])
+ lines_using_bad_operator_per_tag = defaultdict(lambda: self.env['account.report.line'])
+ candidate_duplicate_codes = defaultdict(lambda: self.env["account.report.line"]) # {candidate_duplicate_account_code: {lines_with_that_code,}}
+ duplicate_codes = defaultdict(lambda: self.env["account.report.line"]) # {verified duplicate_account_code: {lines_with_that_code,}}
+ duplicate_codes_same_line = defaultdict(lambda: self.env["account.report.line"]) # {duplicate_account_code: {line_with_that_code_multiple_times,}}
+ common_account_domain = [
+ *self.env['account.account']._check_company_domain(self.env.company),
+ ('deprecated', '=', False),
+ ]
+
+ # tag_ids already linked to an account - avoid several search_count to know if the tag is used or not
+ tag_ids_linked_to_account = set(self.env['account.account'].search([('tag_ids', '!=', False)]).tag_ids.ids)
+
+ expressions = self.line_ids.expression_ids._expand_aggregations()
+ for i, expr in enumerate(expressions):
+ reported_accounts = self.env["account.account"]
+ if expr.engine == "domain":
+ domain = literal_eval(expr.formula.strip())
+ accounts_domain = []
+ for j, operand in enumerate(domain):
+ if isinstance(operand, tuple):
+ operand = list(operand)
+ # Skip tuples that will not be used in the new domain to retrieve the reported accounts
+ if not operand[0].startswith('account_id.'):
+ if domain[j - 1] in ("&", "|", "!"): # Remove the operator linked to the tuple if it exists
+ accounts_domain.pop()
+ continue
+ operand[0] = operand[0].replace('account_id.', '')
+ # Check that the code exists in the CoA
+ if operand[0] == 'code' and not self.env["account.account"].search_count([operand]):
+ non_existing_codes[operand[2]] |= expr.report_line_id
+ elif operand[0] == 'tag_ids':
+ tag_ids = operand[2]
+ if not isinstance(tag_ids, (list, tuple, set)):
+ tag_ids = [tag_ids]
+
+ if operand[1] in ('=', 'in'):
+ tag_ids_to_browse = [tag_id for tag_id in tag_ids if tag_id not in tag_ids_linked_to_account]
+ for tag in self.env['account.account.tag'].browse(tag_ids_to_browse):
+ lines_per_non_linked_tag[f'{tag.name} ({tag.id})'] |= expr.report_line_id
+ else:
+ for tag in self.env['account.account.tag'].browse(tag_ids):
+ lines_using_bad_operator_per_tag[f'{tag.name} ({tag.id}) - Operator: {operand[1]}'] |= expr.report_line_id
+
+ accounts_domain.append(operand)
+ reported_accounts += self.env['account.account'].search(accounts_domain)
+ elif expr.engine == "account_codes":
+ account_codes = []
+ for token in ACCOUNT_CODES_ENGINE_SPLIT_REGEX.split(expr.formula.replace(' ', '')):
+ if not token:
+ continue
+ token_match = ACCOUNT_CODES_ENGINE_TERM_REGEX.match(token)
+ if not token_match:
+ continue
+
+ parsed_token = token_match.groupdict()
+ account_codes.append({
+ 'prefix': parsed_token['prefix'],
+ 'balance': parsed_token['balance_character'],
+ 'exclude': parsed_token['excluded_prefixes'].split(',') if parsed_token['excluded_prefixes'] else [],
+ 'line': expr.report_line_id,
+ })
+
+ for account_code in account_codes:
+ reported_account_codes.append(account_code)
+ exclude_domain_accounts = [get_account_domain(exclude_code) for exclude_code in account_code['exclude']]
+ reported_accounts += self.env["account.account"].search([
+ *common_account_domain,
+ get_account_domain(account_code['prefix']),
+ *[excl_domain for excl_tuple in exclude_domain_accounts for excl_domain in ("!", excl_tuple)],
+ ])
+
+ # Check that the code exists in the CoA or that the tag is linked to an account
+ prefixes_to_check = [account_code['prefix']] + account_code['exclude']
+ for prefix_to_check in prefixes_to_check:
+ account_domain = get_account_domain(prefix_to_check)
+ if not self.env["account.account"].search_count([
+ *common_account_domain,
+ account_domain,
+ ]):
+ # Identify if we're working with account codes or account tags
+ if account_domain[0] == 'code':
+ non_existing_codes[prefix_to_check] |= account_code['line']
+ elif account_domain[0] == 'tag_ids':
+ lines_per_non_linked_tag[prefix_to_check] |= account_code['line']
+
+ all_reported_accounts |= reported_accounts
+ accounts_by_expressions[expr.id] = reported_accounts
+
+ # Check if an account is reported multiple times in the same line of the report
+ if len(reported_accounts) != len(set(reported_accounts)):
+ seen = set()
+ for reported_account in reported_accounts:
+ if reported_account not in seen:
+ seen.add(reported_account)
+ else:
+ duplicate_codes_same_line[reported_account.code] |= expr.report_line_id
+
+ # Check if the account is reported in multiple lines of the report
+ for expr2 in expressions[:i + 1]:
+ reported_accounts2 = accounts_by_expressions[expr2.id]
+ for duplicate_account in (reported_accounts & reported_accounts2):
+ if len(expr.report_line_id | expr2.report_line_id) > 1 \
+ and expr.date_scope == expr2.date_scope \
+ and expr.subformula == expr2.subformula:
+ candidate_duplicate_codes[duplicate_account.code] |= expr.report_line_id | expr2.report_line_id
+
+ # Check that the duplicates are not false positives because of the balance character
+ for candidate_duplicate_code, candidate_duplicate_lines in candidate_duplicate_codes.items():
+ seen_balance_chars = []
+ for reported_account_code in reported_account_codes:
+ if candidate_duplicate_code.startswith(reported_account_code['prefix']) and reported_account_code['balance']:
+ seen_balance_chars.append(reported_account_code['balance'])
+ if not seen_balance_chars or seen_balance_chars.count("C") > 1 or seen_balance_chars.count("D") > 1:
+ duplicate_codes[candidate_duplicate_code] |= candidate_duplicate_lines
+
+ # Check that all codes in CoA are correctly reported
+ if self.root_report_id == self.env.ref('odex30_account_reports.profit_and_loss'):
+ accounts_in_coa = self.env["account.account"].search([
+ *common_account_domain,
+ ('account_type', 'in', ("income", "income_other", "expense", "expense_depreciation", "expense_direct_cost")),
+ ('account_type', '!=', "off_balance"),
+ ])
+ else: # Balance Sheet
+ accounts_in_coa = self.env["account.account"].search([
+ *common_account_domain,
+ ('account_type', 'not in', ("off_balance", "income", "income_other", "expense", "expense_depreciation", "expense_direct_cost"))
+ ])
+
+ # Compute codes that exist in the CoA but are not reported in the report
+ non_reported_codes = set((accounts_in_coa - all_reported_accounts).mapped('code'))
+
+ # Create the lines that will be displayed in the xlsx
+ all_reported_codes = sorted(set(all_reported_accounts.mapped("code")) | non_reported_codes | non_existing_codes.keys())
+ errors_trie = self._get_accounts_coverage_report_errors_trie(all_reported_codes, non_reported_codes, duplicate_codes, duplicate_codes_same_line, non_existing_codes)
+ errors_trie['children'].update(**self._get_account_tag_coverage_report_errors_trie(lines_per_non_linked_tag, lines_using_bad_operator_per_tag)) # Add tags that are not linked to an account
+
+ errors_trie = self._regroup_accounts_coverage_report_errors_trie(errors_trie)
+ return self._get_accounts_coverage_report_coverage_lines("", errors_trie)
+
+ def _get_accounts_coverage_report_errors_trie(self, all_reported_codes, non_reported_codes, duplicate_codes, duplicate_codes_same_line, non_existing_codes):
+ """
+ Create the trie that will be used to regroup the same errors on the same subcodes.
+ This trie will be in the form of:
+ {
+ "children": {
+ "1": {
+ "children": {
+ "10": { ... },
+ "11": { ... },
+ },
+ "lines": {
+ "Line1",
+ "Line2",
+ },
+ "errors": {
+ "DUPLICATE"
+ }
+ },
+ "lines": {
+ "",
+ },
+ "errors": {
+ None # Avoid that all codes are merged into the root with the code "" in case all of the errors are the same
+ },
+ }
+ """
+ errors_trie = {"children": {}, "lines": {}, "errors": {None}}
+ for reported_code in all_reported_codes:
+ current_trie = errors_trie
+ lines = self.env["account.report.line"]
+ errors = set()
+ if reported_code in non_reported_codes:
+ errors.add("NON_REPORTED")
+ elif reported_code in duplicate_codes_same_line:
+ lines |= duplicate_codes_same_line[reported_code]
+ errors.add("DUPLICATE_SAME_LINE")
+ elif reported_code in duplicate_codes:
+ lines |= duplicate_codes[reported_code]
+ errors.add("DUPLICATE")
+ elif reported_code in non_existing_codes:
+ lines |= non_existing_codes[reported_code]
+ errors.add("NON_EXISTING")
+ else:
+ errors.add("NONE")
+
+ for j in range(1, len(reported_code) + 1):
+ current_trie = current_trie["children"].setdefault(reported_code[:j], {
+ "children": {},
+ "lines": lines,
+ "errors": errors
+ })
+ return errors_trie
+
+ @api.model
+ def _get_account_tag_coverage_report_errors_trie(self, lines_per_non_linked_tag, lines_per_bad_operator_tag):
+ """ As we don't want to make a hierarchy for tags, we use a specific
+ function to handle tags.
+ """
+ errors = {
+ non_linked_tag: {
+ 'children': {},
+ 'lines': line,
+ 'errors': {'NON_LINKED'},
+ }
+ for non_linked_tag, line in lines_per_non_linked_tag.items()
+ }
+ errors.update({
+ bad_operator_tag: {
+ 'children': {},
+ 'lines': line,
+ 'errors': {'BAD_OPERATOR'},
+ }
+ for bad_operator_tag, line in lines_per_bad_operator_tag.items()
+ })
+ return errors
+
+ def _regroup_accounts_coverage_report_errors_trie(self, trie):
+ """
+ Regroup the codes that have the same error under the same common subcode/prefix.
+ This is done in-place on the given trie.
+ """
+ if trie.get("children"):
+ children_errors = set()
+ children_lines = self.env["account.report.line"]
+ if trie.get("errors"): # Add own error
+ children_errors |= set(trie.get("errors"))
+ for child in trie["children"].values():
+ regroup = self._regroup_accounts_coverage_report_errors_trie(child)
+ children_lines |= regroup["lines"]
+ children_errors |= set(regroup["errors"])
+ if len(children_errors) == 1 and children_lines and children_lines == trie["lines"]:
+ trie["children"] = {}
+ trie["lines"] = children_lines
+ trie["errors"] = children_errors
+ return trie
+
+ def _get_accounts_coverage_report_coverage_lines(self, subcode, trie, coverage_lines=None):
+ """
+ Create the coverage lines from the grouped trie. Each line has
+ - the account code
+ - the error message
+ - the lines on which the account code is used
+ - the color of the error message for the xlsx
+ """
+ # Dictionnary of the three possible errors, their message and the corresponding color for the xlsx file
+ ERRORS = {
+ "NON_REPORTED": {
+ "msg": _("This account exists in the Chart of Accounts but is not mentioned in any line of the report"),
+ "color": "#FF0000"
+ },
+ "DUPLICATE": {
+ "msg": _("This account is reported in multiple lines of the report"),
+ "color": "#FF8916"
+ },
+ "DUPLICATE_SAME_LINE": {
+ "msg": _("This account is reported multiple times on the same line of the report"),
+ "color": "#E6A91D"
+ },
+ "NON_EXISTING": {
+ "msg": _("This account is reported in a line of the report but does not exist in the Chart of Accounts"),
+ "color": "#FFBF00"
+ },
+ "NON_LINKED": {
+ "msg": _("This tag is reported in a line of the report but is not linked to any account of the Chart of Accounts"),
+ "color": "#FFBF00",
+ },
+ "BAD_OPERATOR": {
+ "msg": _("The used operator is not supported for this expression."),
+ "color": "#FFBF00",
+ }
+ }
+ if coverage_lines is None:
+ coverage_lines = []
+ if trie.get("children"):
+ for child in trie.get("children"):
+ self._get_accounts_coverage_report_coverage_lines(child, trie["children"][child], coverage_lines)
+ else:
+ error = list(trie["errors"])[0] if trie["errors"] else False
+ if error and error != "NONE":
+ coverage_lines.append([
+ subcode,
+ ERRORS[error]["msg"],
+ " + ".join(trie["lines"].sorted().mapped("name")),
+ ERRORS[error]["color"]
+ ])
+ return coverage_lines
+
+ # ============ Accounts Coverage Debugging Tool - END ================
+
+ def _generate_file_data_with_error_check(self, options, content_generator, generator_params, errors):
+ """ Checks for critical errors (i.e. errors that would cause the rendering to fail) in the generator values.
+ If at least one error is critical, the 'account.report.file.download.error.wizard' wizard is opened
+ before rendering the file, so they can be fixed.
+ If there are only non-critical errors, the wizard is opened after the file has been generated,
+ allowing the user to download it anyway.
+
+ :param dict options: The report options.
+ :param def content_generator: The function used to generate the exported content.
+ :param dict generator_params: The parameters passed to the 'content_generator' method (List).
+ :param list errors: A list of errors in the following format:
+ [
+ {
+ 'message': The error message to be displayed in the wizard (String),
+ 'action_text': The text of the action button (String),
+ 'action': Contains the action values (Dictionary),
+ 'level': One of 'info', 'warning', 'danger'. (String).
+ Only the 'danger' level represents a blocking error.
+ },
+ {...},
+ ]
+ :returns: The data that will be used by the file generator.
+ :rtype: dict
+ """
+ if errors is None:
+ errors = []
+ self.ensure_one()
+ if any(error_value.get('level') == 'danger' for error_value in errors.values()):
+ raise AccountReportFileDownloadException(errors)
+
+ content = content_generator(**generator_params)
+
+ file_data = {
+ 'file_name': self.get_default_report_filename(options, generator_params['file_type']),
+ 'file_content': re.sub(r'\n\s*\n', '\n', content).encode(),
+ 'file_type': generator_params['file_type'],
+ }
+
+ if errors:
+ raise AccountReportFileDownloadException(errors, file_data)
+
+ return file_data
+
+ def action_create_composite_report(self):
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.report',
+ 'views': [[False, 'form']],
+ 'context': {
+ 'default_section_report_ids': self.ids,
+ }
+ }
+
+ def show_error_branch_allowed(self, *args, **kwargs):
+ raise UserError(_("Please select the main company and its branches in the company selector to proceed."))
+
+
+class AccountReportLine(models.Model):
+ _inherit = 'account.report.line'
+
+ display_custom_groupby_warning = fields.Boolean(compute='_compute_display_custom_groupby_warning')
+
+ @api.depends('groupby', 'user_groupby')
+ def _compute_display_custom_groupby_warning(self):
+ for line in self:
+ line.display_custom_groupby_warning = line.get_external_id()[line.id] and line.user_groupby != line.groupby
+
+ @api.constrains('groupby', 'user_groupby')
+ def _validate_groupby(self):
+ super()._validate_groupby()
+ for report_line in self:
+ report_line.report_id._check_groupby_fields(report_line.user_groupby)
+ report_line.report_id._check_groupby_fields(report_line.groupby)
+
+ def _expand_groupby(self, line_dict_id, groupby, options, offset=0, limit=None, load_one_more=False, unfold_all_batch_data=None):
+ """ Expand function used to get the sublines of a groupby.
+ groupby param is a string consisting of one or more coma-separated field names. Only the first one
+ will be used for the expansion; if there are subsequent ones, the generated lines will themselves used them as
+ their groupby value, and point to this expand_function, hence generating a hierarchy of groupby).
+ """
+ self.ensure_one()
+
+ group_indent = 0
+ line_id_list = self.report_id._parse_line_id(line_dict_id)
+
+ # Parse groupby
+ groupby_data = self._parse_groupby(options, groupby_to_expand=groupby)
+ groupby_model = groupby_data['current_groupby_model']
+ next_groupby = groupby_data['next_groupby']
+ current_groupby = groupby_data['current_groupby']
+ custom_groupby_map = groupby_data['custom_groupby_map']
+
+ # If this line is a sub-groupby of groupby line (for example, when grouping by partner, id; the id line is a subgroup of partner),
+ # we need to add the domain of the parent groupby criteria to the options
+ prefix_groups_count = 0
+ sub_groupby_domain = []
+ full_sub_groupby_key_elements = []
+ for markup, model, value in line_id_list:
+ if isinstance(markup, dict) and 'groupby' in markup:
+ field_name = markup['groupby']
+ if field_name in custom_groupby_map:
+ sub_groupby_domain += custom_groupby_map[field_name]['domain_builder'](value)
+ else:
+ sub_groupby_domain.append((field_name, '=', value))
+ full_sub_groupby_key_elements.append(f"{field_name}:{value}")
+ elif isinstance(markup, dict) and 'groupby_prefix_group' in markup:
+ prefix_groups_count += 1
+
+ if model == 'account.group':
+ group_indent += 1
+
+ if sub_groupby_domain:
+ forced_domain = options.get('forced_domain', []) + sub_groupby_domain
+ options = {**options, 'forced_domain': forced_domain}
+
+ # If the report transmitted custom_unfold_all_batch_data dictionary, use it
+ full_sub_groupby_key = f"[{self.id}]{','.join(full_sub_groupby_key_elements)}=>{current_groupby}"
+
+ cached_result = (unfold_all_batch_data or {}).get(full_sub_groupby_key)
+
+ if cached_result is not None:
+ all_column_groups_expression_totals = cached_result
+ else:
+ all_column_groups_expression_totals = self.report_id._compute_expression_totals_for_each_column_group(
+ self.expression_ids,
+ options,
+ groupby_to_expand=groupby,
+ offset=offset,
+ limit=limit + 1 if limit and load_one_more else limit,
+ )
+
+ # Put similar grouping keys from different totals/periods together, so that we don't display multiple
+ # lines for the same grouping key
+
+ figure_types_defaulting_to_0 = {'monetary', 'percentage', 'integer', 'float'}
+
+ default_value_per_expr_label = {
+ col_opt['expression_label']: 0 if col_opt['figure_type'] in figure_types_defaulting_to_0 else None
+ for col_opt in options['columns']
+ }
+
+ # Gather default value for each expression, in case it has no value for a given grouping key
+ default_value_per_expression = {}
+ for expression in self.expression_ids:
+ if expression.figure_type:
+ default_value = 0 if expression.figure_type in figure_types_defaulting_to_0 else None
+ else:
+ default_value = default_value_per_expr_label.get(expression.label)
+
+ default_value_per_expression[expression] = {'value': default_value}
+
+ # Build each group's result
+ aggregated_group_totals = defaultdict(lambda: defaultdict(default_value_per_expression.copy))
+ for column_group_key, expression_totals in all_column_groups_expression_totals.items():
+ for expression in self.expression_ids:
+ for grouping_key, result in expression_totals[expression]['value']:
+ aggregated_group_totals[grouping_key][column_group_key][expression] = {'value': result}
+
+ # Generate groupby lines
+ group_lines_by_keys = {}
+ for grouping_key, group_totals in aggregated_group_totals.items():
+ # For this, we emulate a dict formatted like the result of _compute_expression_totals_for_each_column_group, so that we can call
+ # _build_static_line_columns like on non-grouped lines
+ line_id = self.report_id._get_generic_line_id(groupby_model, grouping_key, parent_line_id=line_dict_id, markup={'groupby': current_groupby})
+ group_line_dict = {
+ # 'name' key will be set later, so that we can browse all the records of this expansion at once (in case we're dealing with records)
+ 'id': line_id,
+ 'unfoldable': bool(next_groupby),
+ 'unfolded': (next_groupby and options['unfold_all']) or line_id in options['unfolded_lines'],
+ 'groupby': next_groupby,
+ 'columns': self.report_id._build_static_line_columns(self, options, group_totals, groupby_model=groupby_model),
+ 'level': self.hierarchy_level + 2 * (prefix_groups_count + len(sub_groupby_domain) + 1) + (group_indent - 1),
+ 'parent_id': line_dict_id,
+ 'expand_function': '_report_expand_unfoldable_line_with_groupby' if next_groupby else None,
+ 'caret_options': groupby_model if not next_groupby else None,
+ }
+
+ if self.report_id.custom_handler_model_id:
+ self.env[self.report_id.custom_handler_model_name]._custom_groupby_line_completer(self.report_id, options, group_line_dict)
+
+ # Growth comparison column.
+ if options.get('column_percent_comparison') == 'growth':
+ compared_expression = self.expression_ids.filtered(lambda expr: expr.label == group_line_dict['columns'][0]['expression_label'])
+ group_line_dict['column_percent_comparison_data'] = self.report_id._compute_column_percent_comparison_data(
+ options, group_line_dict['columns'][0]['no_format'], group_line_dict['columns'][1]['no_format'], green_on_positive=compared_expression.green_on_positive)
+ # Manage budget comparison
+ elif options.get('column_percent_comparison') == 'budget':
+ self.report_id._set_budget_column_comparisons(options, group_line_dict)
+
+ group_lines_by_keys[grouping_key] = group_line_dict
+
+ # Sort grouping keys in the right order and generate line names
+ keys_and_names_in_sequence = {} # Order of this dict will matter
+
+ if groupby_model:
+ browsed_groupby_keys = self.env[groupby_model].browse(list(key for key in group_lines_by_keys if key is not None))
+
+ out_of_sorting_record = None
+ records_to_sort = browsed_groupby_keys
+ if browsed_groupby_keys and load_one_more and len(browsed_groupby_keys) >= limit:
+ out_of_sorting_record = browsed_groupby_keys[-1]
+ records_to_sort = records_to_sort[:-1]
+
+ for record in records_to_sort.with_context(active_test=False).sorted():
+ keys_and_names_in_sequence[record.id] = record.display_name
+
+ if None in group_lines_by_keys:
+ keys_and_names_in_sequence[None] = _("Unknown")
+
+ if out_of_sorting_record:
+ keys_and_names_in_sequence[out_of_sorting_record.id] = out_of_sorting_record.display_name
+
+ else:
+ for non_relational_key in sorted(group_lines_by_keys.keys(), key=lambda k: (k is None, k)):
+ if custom_groupby_name_builder := custom_groupby_map.get(current_groupby, {}).get('label_builder'):
+ keys_and_names_in_sequence[non_relational_key] = custom_groupby_name_builder(non_relational_key)
+ else:
+ if non_relational_key is None:
+ keys_and_names_in_sequence[non_relational_key] = _("Undefined")
+ else:
+ groupby_field = self.env['account.move.line']._fields[groupby_data['current_groupby']]
+ if groupby_field.type == 'selection':
+ selection_options = dict(groupby_field._description_selection(self.env))
+ keys_and_names_in_sequence[non_relational_key] = selection_options.get(non_relational_key) or _("Undefined")
+ else:
+ keys_and_names_in_sequence[non_relational_key] = str(non_relational_key)
+
+ # Build result: add a name to the groupby lines and handle totals below section for multi-level groupby
+ group_lines = []
+ for grouping_key, line_name in keys_and_names_in_sequence.items():
+ group_line_dict = group_lines_by_keys[grouping_key]
+ group_line_dict['name'] = line_name
+ group_lines.append(group_line_dict)
+
+ if options.get('hierarchy'):
+ group_lines = self.report_id._create_hierarchy(group_lines, options)
+
+ return group_lines
+
+ def _get_groupby_line_name(self, groupby_field_name, groupby_model, grouping_key):
+ # TODO master: remove this method as it is dead code
+ if groupby_model is None:
+ return grouping_key
+
+ if grouping_key is None:
+ return _("Unknown")
+
+ return self.env[groupby_model].browse(grouping_key).display_name
+
+ def _parse_groupby(self, options, groupby_to_expand=None):
+ """ Retrieves the information needed to handle the groupby feature on the current line.
+
+ :param groupby_to_expand: A coma-separated string containing, in order, all the fields that are used in the groupby we're expanding.
+ None if we're not expanding anything.
+
+ :return: A dictionary with 4 keys:
+ 'current_groupby': The name of the value to be used to retrieve the results of the current groupby we're
+ expanding, or None if nothing is being expanded. That value can be either a field of account.move.line, or
+ a custom groupby value defined in this report's custom handler's _get_custom_groupby_map function.
+
+ 'next_groupby': The subsequent groupings to be applied after current_groupby, as a string of coma-separated values (again,
+ either field names from account.move.line or a custom groupby defined on the handler).
+ If no subsequent grouping exists, next_groupby will be None.
+
+ 'current_groupby_model': The model name corresponding to current_groupby, or None if current_groupby is None.
+
+ 'custom_groupby_map'; The groupby map, used to handle custom groupby values, as returned by the _get_custom_groupby_map function
+ of the custom handler (by default, it will be an empty dict)
+
+ EXAMPLE:
+ When computing a line with groupby=partner_id,account_id,id , without expanding it:
+ - groupby_to_expand will be None
+ - current_groupby will be None
+ - next_groupby will be 'partner_id,account_id,id'
+ - current_groupby_model will be None
+
+ When expanding the first group level of the line:
+ - groupby_to_expand will be: partner_id,account_id,id
+ - current_groupby will be 'partner_id'
+ - next_groupby will be 'account_id,id'
+ - current_groupby_model will be 'res.partner'
+
+ When expanding further:
+ - groupby_to_expand will be: account_id,id ; corresponding to the next_groupby computed when expanding partner_id
+ - current_groupby will be 'account_id'
+ - next_groupby will be 'id'
+ - current_groupby_model will be 'account.account'
+ """
+ self.ensure_one()
+
+ if groupby_to_expand:
+ groupby_to_expand = groupby_to_expand.replace(' ', '')
+ split_groupby = groupby_to_expand.split(',')
+ current_groupby = split_groupby[0]
+ next_groupby = ','.join(split_groupby[1:]) if len(split_groupby) > 1 else None
+ else:
+ current_groupby = None
+ groupby = self._get_groupby(options)
+ next_groupby = groupby.replace(' ', '') if groupby else None
+
+ custom_handler_name = self.report_id._get_custom_handler_model()
+ custom_groupby_map = self.env[custom_handler_name]._get_custom_groupby_map() if custom_handler_name else {}
+ if current_groupby in custom_groupby_map:
+ groupby_model = custom_groupby_map[current_groupby]['model']
+ elif current_groupby == 'id':
+ groupby_model = 'account.move.line'
+ elif current_groupby:
+ groupby_model = self.env['account.move.line']._fields[current_groupby].comodel_name
+ else:
+ groupby_model = None
+
+ return {
+ 'current_groupby': current_groupby,
+ 'next_groupby': next_groupby,
+ 'current_groupby_model': groupby_model,
+ 'custom_groupby_map': custom_groupby_map,
+ }
+
+ def _get_groupby(self, options):
+ self.ensure_one()
+ if options['export_mode'] == 'file':
+ return self.groupby
+ return self.user_groupby
+
+ def action_reset_custom_groupby(self):
+ self.ensure_one()
+ self.user_groupby = self.groupby
+
+
+class AccountReportExpression(models.Model):
+ _inherit = 'account.report.expression'
+
+ def action_view_carryover_lines(self, options, column_group_key=None):
+ if column_group_key:
+ options = self.report_line_id.report_id._get_column_group_options(options, column_group_key)
+
+ date_from, date_to = self.report_line_id.report_id._get_date_bounds_info(options, self.date_scope)
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Carryover lines for: %s', self.report_line_name),
+ 'res_model': 'account.report.external.value',
+ 'views': [(False, 'list')],
+ 'domain': [
+ ('target_report_expression_id', '=', self.id),
+ ('date', '>=', date_from),
+ ('date', '<=', date_to),
+ ],
+ }
+
+
+class AccountReportExternalValue(models.Model):
+ _inherit = 'account.report.external.value'
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ records = super().create(vals_list)
+ self._check_lock_date_violation(set(self._build_vals_to_check_for_lock_date(records)))
+ return records
+
+ def write(self, vals):
+ # We need to build vals_to_check before the super() call because of the 'target_report_expression_id' field :
+ # if the user tries to modify this specific field, it'll potentially change the linked report id, and so he can
+ # bypass the lock dates from the original report (if it was a tax report for example)
+ vals_to_check = set(self._build_vals_to_check_for_lock_date(self))
+ res = super().write(vals)
+ # Then we add the modified records
+ for lock_date_to_check in self._build_vals_to_check_for_lock_date(self):
+ vals_to_check.add(lock_date_to_check)
+ self._check_lock_date_violation(vals_to_check)
+ return res
+
+ @api.model
+ def _build_vals_to_check_for_lock_date(self, records):
+ """
+ Generator method to build tuples out of records. The tuples will contain 3 values:
+ - is tax, bool: is the external value linked to a tax report
+ - date to check, date: the date we want to check the lock dates for
+ - company, res.company: the company we want to check the lock dates for
+ """
+ generic_tax_report = self.env.ref('account.generic_tax_report')
+ for external_value in records:
+ report = external_value.target_report_expression_id.report_line_id.report_id
+ yield (
+ not self.env.context.get('ignore_tax_lock_date') and generic_tax_report in (report + report.root_report_id + report.section_main_report_ids.root_report_id), # is tax
+ external_value.date, # date to check
+ external_value.company_id, # company
+ )
+
+ def _check_lock_date_violation(self, vals_to_check):
+ """
+ This method raises an error if the companies have lock dates after the date we want to create/write the values
+ :param vals_to_check: a set of tuples like: `{(is_tax, date, company_id)}`
+ """
+ for is_tax, date, company_id in vals_to_check:
+ violated_lock_dates = company_id._get_lock_date_violations(
+ date,
+ sale=False,
+ purchase=False,
+ tax=is_tax,
+ )
+ if violated_lock_dates:
+ lock_date_names = [company_id._fields[lock_date[1]].get_description(self.env)['string'] for lock_date in violated_lock_dates]
+ lock_dates = "\n- " + "\n- ".join(lock_date_names)
+ raise ValidationError(_("You cannot update this value as it's locked by: %s", lock_dates))
+
+
+class AccountReportHorizontalGroup(models.Model):
+ _name = "account.report.horizontal.group"
+ _description = "Horizontal group for reports"
+
+ name = fields.Char(string="Name", required=True, translate=True)
+ rule_ids = fields.One2many(string="Rules", comodel_name='account.report.horizontal.group.rule', inverse_name='horizontal_group_id', required=True)
+ report_ids = fields.Many2many(string="Reports", comodel_name='account.report')
+
+ _sql_constraints = [
+ ('name_uniq', 'unique (name)', "A horizontal group with the same name already exists."),
+ ]
+
+ def _get_header_levels_data(self):
+ return [
+ (rule.field_name, rule._get_matching_records())
+ for rule in self.rule_ids
+ ]
+
+class AccountReportHorizontalGroupRule(models.Model):
+ _name = "account.report.horizontal.group.rule"
+ _description = "Horizontal group rule for reports"
+
+ def _field_name_selection_values(self):
+ return [
+ (aml_field['name'], aml_field['string'])
+ for aml_field in self.env['account.move.line'].fields_get().values()
+ if aml_field['type'] in ('many2one', 'many2many')
+ ]
+
+ horizontal_group_id = fields.Many2one(string="Horizontal Group", comodel_name='account.report.horizontal.group', required=True)
+ domain = fields.Char(string="Domain", required=True, default='[]')
+ field_name = fields.Selection(string="Field", selection='_field_name_selection_values', required=True)
+ res_model_name = fields.Char(string="Model", compute='_compute_res_model_name')
+
+ @api.depends('field_name')
+ def _compute_res_model_name(self):
+ for record in self:
+ if record.field_name:
+ record.res_model_name = self.env['account.move.line']._fields[record.field_name].comodel_name
+ else:
+ record.res_model_name = None
+
+ def _get_matching_records(self):
+ self.ensure_one()
+ model_name = self.env['account.move.line']._fields[self.field_name].comodel_name
+ domain = ast.literal_eval(self.domain)
+ return self.env[model_name].search(domain)
+
+
+class AccountReportCustomHandler(models.AbstractModel):
+ _name = 'account.report.custom.handler'
+ _description = 'Account Report Custom Handler'
+
+ # This abstract model allows case-by-case localized changes of behaviors of reports.
+ # This is used for custom reports, for cases that cannot be supported by the standard engines.
+
+ def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None):
+ """ Generates lines dynamically for reports that require a custom processing which cannot be handled
+ by regular report engines.
+ :return: A list of tuples [(sequence, line_dict), ...], where:
+ - sequence is the sequence to apply when rendering the line (can be mixed with static lines),
+ - line_dict is a dict containing all the line values.
+ """
+ return []
+
+ def _caret_options_initializer(self):
+ """ Returns the caret options dict to be used when rendering this report,
+ in the same format as the one used in _caret_options_initializer_default (defined on 'account.report').
+ If the result is empty, the engine will use the default caret options.
+ """
+ return self.env['account.report']._caret_options_initializer_default()
+
+ def _custom_options_initializer(self, report, options, previous_options):
+ """ To be overridden to add report-specific _init_options... code to the report. """
+ if report.root_report_id and report.root_report_id.custom_handler_model_id != report.custom_handler_model_id:
+ report.root_report_id._init_options_custom(options, previous_options)
+
+ def _custom_line_postprocessor(self, report, options, lines):
+ """ Postprocesses the result of the report's _get_lines() before returning it. """
+ return lines
+
+ def _custom_groupby_line_completer(self, report, options, line_dict):
+ """ Postprocesses the dict generated by the group_by_line, to customize its content. """
+
+ def _custom_unfold_all_batch_data_generator(self, report, options, lines_to_expand_by_function):
+ """ When using the 'unfold all' option, some reports might end up recomputing the same query for
+ each line to unfold, leading to very inefficient computation. This function allows batching this computation,
+ and returns a dictionary where all results are cached, for use in expansion functions.
+ """
+ return None
+
+ def _get_custom_display_config(self):
+ """ To be overridden in order to change the templates used by Javascript to render this report (keeping the same
+ OWL components), and/or replace some of the default OWL components by custom-made ones.
+
+ This function returns a dict (possibly empty, if there is no custom display config):
+
+ {
+ 'css_custom_class: 'class',
+ 'components': {
+
+ },
+ 'pdf_export': {
+
+ },
+ 'templates': {
+
+ },
+ },
+ """
+ return {}
+
+ def _get_custom_groupby_map(self):
+ """ Allows the use of custom values in the groupby field of account.report.line, to use them in custom engines. Those custom
+ values can be anything, and need to be properly handled by the custom engine using them. This allows adding support for grouping on
+ something else than just the fields of account.move.line, which is the default.
+
+ :return: A dict, in the form {groupby_name: {'model': model, 'domain_builder': domain_builder}}, where:
+ - groupby_name is the custom value to use in groupby instead of one of aml's field names
+ - model: is a model name (a string), representing the model the value returned for this custom groupby targets.
+ The model will be used to compute the display_name to show for each generated groupby line, in the UI.
+ This value can be passed to None ; in such case, the raw value returned by the engine will be shown.
+ - domain_builder is a function to be called when expanding a groupby line generated by this custom groupby, to compute the
+ domain to apply in order to restrict the computation to the content of this groupby line.
+ This function must accept a single parameter, corresponding to the groupby value to compute the domain for.
+ - label_builder is a function to be called to compute a label for the groupby value, that will be shown as the line name
+ in the UI. This ways, translatable labels and multi-values keys serialized to json can be fully supported.
+ """
+ return {}
+
+ def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings):
+ """ To be overridden to add report-specific warnings in the warnings dictionary.
+ When a root report defines something in this function, its variants without any custom handler will also call the root report's
+ _customize_warnings function. This can hence be used to share warnings between all variants.
+
+ Should only be used when necessary, _dynamic_lines_generator is preferred.
+ """
+
+ def _enable_export_buttons_for_common_vat_groups_in_branches(self, options):
+ """ DEPRECATED: to be removed in master. Buttons are now set to 'branch_allowed' when needed in get_options() """
+ pass
+
+
+class AccountReportFileDownloadException(Exception):
+ def __init__(self, errors, content=None):
+ super().__init__()
+ self.errors = errors
+ self.content = content
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_sales_report.py b/dev_odex30_accounting/odex30_account_reports/models/account_sales_report.py
new file mode 100644
index 0000000..0f55676
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_sales_report.py
@@ -0,0 +1,420 @@
+from collections import defaultdict
+
+from odoo import _, api, fields, models
+from odoo.tools import SQL
+
+
+class ECSalesReportCustomHandler(models.AbstractModel):
+ _name = 'account.ec.sales.report.handler'
+ _inherit = 'account.report.custom.handler'
+ _description = 'EC Sales Report Custom Handler'
+
+ def _get_custom_display_config(self):
+ return {
+ 'components': {
+ 'AccountReportFilters': 'odex30_account_reports.SalesReportFilters',
+ },
+ }
+
+ def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None):
+ """
+ Generate the dynamic lines for the report in a vertical style (one line per tax per partner).
+ """
+ lines = []
+ totals_by_column_group = {
+ column_group_key: {
+ 'balance': 0.0,
+ 'goods': 0.0,
+ 'triangular': 0.0,
+ 'services': 0.0,
+ 'vat_number': '',
+ 'country_code': '',
+ 'sales_type_code': '',
+ }
+ for column_group_key in options['column_groups']
+ }
+
+ operation_categories = options['sales_report_taxes'].get('operation_category', {})
+ ec_tax_filter_selection = {v.get('id'): v.get('selected') for v in options.get('ec_tax_filter_selection', [])}
+ for partner, results in self._query_partners(report, options, warnings):
+ for tax_ec_category in ('goods', 'triangular', 'services'):
+ if not ec_tax_filter_selection[tax_ec_category]:
+ # Skip the line if the tax is not selected
+ continue
+ partner_values = defaultdict(dict)
+ country_specific_code = operation_categories.get(tax_ec_category)
+ has_found_a_line = False
+ for col_grp_key in options['column_groups']:
+ partner_sum = results.get(col_grp_key, {})
+ partner_values[col_grp_key]['vat_number'] = partner_sum.get('vat_number', 'UNKNOWN')
+ partner_values[col_grp_key]['country_code'] = partner_sum.get('country_code', 'UNKNOWN')
+ partner_values[col_grp_key]['sales_type_code'] = []
+ partner_values[col_grp_key]['balance'] = partner_sum.get(tax_ec_category, 0.0)
+ totals_by_column_group[col_grp_key]['balance'] += partner_sum.get(tax_ec_category, 0.0)
+ for i, operation_id in enumerate(partner_sum.get('tax_element_id', [])):
+ if operation_id in options['sales_report_taxes'][tax_ec_category]:
+ has_found_a_line = True
+ partner_values[col_grp_key]['sales_type_code'] += [
+ country_specific_code or
+ (partner_sum.get('sales_type_code') and partner_sum.get('sales_type_code')[i])
+ or None]
+ partner_values[col_grp_key]['sales_type_code'] = ', '.join(set(partner_values[col_grp_key]['sales_type_code']))
+ if has_found_a_line:
+ lines.append((0, self._get_report_line_partner(report, options, partner, partner_values, markup=tax_ec_category)))
+
+ # Report total line.
+ if lines:
+ lines.append((0, self._get_report_line_total(report, options, totals_by_column_group)))
+
+ return lines
+
+ def _caret_options_initializer(self):
+ """
+ Add custom caret option for the report to link to the partner and allow cleaner overrides.
+ """
+ return {
+ 'ec_sales': [
+ {'name': _("View Partner"), 'action': 'caret_option_open_record_form'}
+ ],
+ }
+
+ def _custom_options_initializer(self, report, options, previous_options):
+ """
+ Add the invoice lines search domain that is specific to the country.
+ Typically, the taxes tag_ids relative to the country for the triangular, sale of goods or services
+ :param dict options: Report options
+ :param dict previous_options: Previous report options
+ """
+ super()._custom_options_initializer(report, options, previous_options=previous_options)
+ self._init_core_custom_options(report, options, previous_options)
+ options.update({
+ 'sales_report_taxes': {
+ 'goods': tuple(self.env['account.tax'].search([
+ *self.env['account.tax']._check_company_domain(self.env.company),
+ ('amount', '=', 0.0),
+ ('amount_type', '=', 'percent'),
+ ('type_tax_use', '=', 'sale'),
+ ]).ids),
+ 'services': tuple(),
+ 'triangular': tuple(),
+ 'use_taxes_instead_of_tags': True,
+ # We can't use tags as we don't have a country tax report correctly set, 'use_taxes_instead_of_tags'
+ # should never be used outside this case
+ }
+ })
+ country_ids = self.env['res.country'].search([
+ ('code', 'in', tuple(self._get_ec_country_codes(options)))
+ ]).ids
+ other_country_ids = tuple(set(country_ids) - {self.env.company.account_fiscal_country_id.id})
+ options.setdefault('forced_domain', []).extend([
+ '|',
+ ('move_id.partner_shipping_id.country_id', 'in', other_country_ids),
+ '&',
+ ('move_id.partner_shipping_id', '=', False),
+ ('partner_id.country_id', 'in', other_country_ids),
+ ])
+
+ report._init_options_journals(options, previous_options=previous_options)
+
+ options['enable_export_buttons_for_common_vat_in_branches'] = True
+
+ def _init_core_custom_options(self, report, options, previous_options):
+ """
+ Add the invoice lines search domain that is common to all countries.
+ :param dict options: Report options
+ :param dict previous_options: Previous report options
+ """
+ default_tax_filter = [
+ {'id': 'goods', 'name': _('Goods'), 'selected': True},
+ {'id': 'triangular', 'name': _('Triangular'), 'selected': True},
+ {'id': 'services', 'name': _('Services'), 'selected': True},
+ ]
+
+ ec_tax_filter_selection = previous_options.get('ec_tax_filter_selection', default_tax_filter)
+ # In case we have a EC sale list report with more ec_tax_filter_selection the previous options will have extra
+ # item we just keep the default ones, and we let variant extend the function to add the ones they need
+ if ec_tax_filter_selection != default_tax_filter:
+ filtered_ec_tax_filter_selection = [item for item in ec_tax_filter_selection if item['id'] in {item['id'] for item in default_tax_filter}]
+ options['ec_tax_filter_selection'] = filtered_ec_tax_filter_selection
+ else:
+ options['ec_tax_filter_selection'] = ec_tax_filter_selection
+
+ def _get_report_line_partner(self, report, options, partner, partner_values, markup=''):
+ """
+ Convert the partner values to a report line.
+ :param dict options: Report options
+ :param recordset partner: the corresponding res.partner record
+ :param dict partner_values: Dictionary of values for the report line
+ :return dict: Return a dict with the values for the report line.
+ """
+ column_values = []
+ for column in options['columns']:
+ value = partner_values[column['column_group_key']].get(column['expression_label'])
+ column_values.append(report._build_column_dict(value, column, options=options))
+
+ return {
+ 'id': report._get_generic_line_id('res.partner', partner.id, markup=markup),
+ 'name': partner is not None and (partner.name or '')[:128] or _('Unknown Partner'),
+ 'columns': column_values,
+ 'level': 2,
+ 'trust': partner.trust if partner else None,
+ 'caret_options': 'ec_sales',
+ }
+
+ def _get_report_line_total(self, report, options, totals_by_column_group):
+ """
+ Convert the total values to a report line.
+ :param dict options: Report options
+ :param dict totals_by_column_group: Dictionary of values for the total line
+ :return dict: Return a dict with the values for the report line.
+ """
+ column_values = []
+ for column in options['columns']:
+ col_value = totals_by_column_group[column['column_group_key']].get(column['expression_label'])
+ col_value = col_value if column['figure_type'] == 'monetary' else ''
+
+ column_values.append(report._build_column_dict(col_value, column, options=options))
+
+ return {
+ 'id': report._get_generic_line_id(None, None, markup='total'),
+ 'name': _('Total'),
+ 'class': 'total',
+ 'level': 1,
+ 'columns': column_values,
+ }
+
+ def _query_partners(self, report, options, warnings=None):
+ ''' Execute the queries, perform all the computation, then
+ returns a lists of tuple (partner, fetched_values) sorted by the table's model _order:
+ - partner is a res.parter record.
+ - fetched_values is a dictionary containing:
+ - sums by operation type: {'goods': float,
+ 'triangular': float,
+ 'services': float,
+
+ - tax identifiers: 'tax_element_id': list[int], > the tag_id in almost every case
+ 'sales_type_code': list[str],
+
+ - partner identifier elements: 'vat_number': str,
+ 'full_vat_number': str,
+ 'country_code': str}
+
+ :param options: The report options.
+ :return: (accounts_values, taxes_results)
+ '''
+ groupby_partners = {}
+ vat_set = set()
+
+ def assign_sum(row):
+ """
+ Assign corresponding values from the SQL querry row to the groupby_partners dictionary.
+ If the line balance isn't 0, find the tax tag_id and check in which column/report line the SQL line balance
+ should be displayed.
+
+ The tricky part is to allow for the report to be displayed in vertical or horizontal format.
+ In vertical, you have up to 3 lines per partner (one for each operation type).
+ In horizontal, you have one line with 3 columns per partner (one for each operation type).
+
+ Add then the more straightforward data (vat number, country code, ...)
+ :param dict row:
+ """
+ if not company_currency.is_zero(row['balance']):
+ vat = row['vat_number'] or ''
+ vat_country_code = vat[:2] if vat[:2].isalpha() else None
+ duplicated_vat = vat and vat in vat_set and row['groupby'] not in groupby_partners
+ if vat:
+ vat_set.add(vat)
+
+ groupby_partners.setdefault(row['groupby'], defaultdict(lambda: defaultdict(float)))
+ groupby_partners_keyed = groupby_partners[row['groupby']][row['column_group_key']]
+ for key in options['sales_report_taxes']:
+ # options['sales_report_taxes'][key] could be either a list, set, tuple or boolean, in case of boolean
+ # the in operator would traceback
+ if not isinstance(options['sales_report_taxes'][key], bool) and row['tax_element_id'] in options['sales_report_taxes'][key]:
+ groupby_partners_keyed[key] += row['balance']
+
+ groupby_partners_keyed.setdefault('tax_element_id', []).append(row['tax_element_id'])
+ groupby_partners_keyed.setdefault('sales_type_code', []).append(row['sales_type_code'])
+
+ groupby_partners_keyed.setdefault('vat_number', vat if not vat_country_code else vat[2:])
+ groupby_partners_keyed.setdefault('full_vat_number', vat)
+ groupby_partners_keyed.setdefault('country_code', vat_country_code or row.get('country_code'))
+
+ if warnings is not None:
+ if row['country_code'] not in self._get_ec_country_codes(options):
+ warnings['odex30_account_reports.sales_report_warning_non_ec_country'] = {'alert_type': 'warning'}
+ elif not row.get('vat_number'):
+ warnings['odex30_account_reports.sales_report_warning_missing_vat'] = {'alert_type': 'warning'}
+ if row.get('same_country') and row['country_code']:
+ warnings['odex30_account_reports.sales_report_warning_same_country'] = {'alert_type': 'warning'}
+ if duplicated_vat:
+ if warnings.get('odex30_account_reports.sales_report_warning_duplicated_vat'):
+ warnings['odex30_account_reports.sales_report_warning_duplicated_vat']['duplicated_partners_vat'].append(vat)
+ else:
+ warnings['odex30_account_reports.sales_report_warning_duplicated_vat'] = {'alert_type': 'warning', 'duplicated_partners_vat': [vat]}
+
+ company_currency = self.env.company.currency_id
+
+ # Execute the queries and dispatch the results.
+ query = self._get_query_sums(report, options)
+ self._cr.execute(query)
+
+ dictfetchall = self._cr.dictfetchall()
+ for res in dictfetchall:
+ assign_sum(res)
+
+ if groupby_partners:
+ partners = self.env['res.partner'].with_context(active_test=False).browse(groupby_partners.keys())
+ else:
+ partners = self.env['res.partner']
+
+ return [(partner, groupby_partners[partner.id]) for partner in partners.sorted()]
+
+ def _get_query_sums(self, report, options) -> SQL:
+ ''' Construct a query retrieving all the aggregated sums to build the report. It includes:
+ - sums for all partners.
+ - sums for the initial balances.
+ :param options: The report options.
+ :return: query as SQL object
+ '''
+ queries = []
+ # Create the currency table.
+ allowed_ids = self._get_tag_ids_filtered(options)
+
+ # In the case of the generic report, we don't have a country defined. So no reliable tax report whose
+ # tag_ids can be used. So we have a fallback to tax_ids.
+
+ if options.get('sales_report_taxes', {}).get('use_taxes_instead_of_tags'):
+ tax_elem_table = SQL('account_tax')
+ tax_elem_table_id = SQL('account_tax_id')
+ aml_rel_table = SQL('account_move_line_account_tax_rel')
+ tax_elem_table_name = self.env['account.tax']._field_to_sql('account_tax', 'name')
+ else:
+ tax_elem_table = SQL('account_account_tag')
+ tax_elem_table_id = SQL('account_account_tag_id')
+ aml_rel_table = SQL('account_account_tag_account_move_line_rel')
+ tax_elem_table_name = self.env['account.account.tag']._field_to_sql('account_account_tag', 'name')
+
+ for column_group_key, column_group_options in report._split_options_per_column_group(options).items():
+ query = report._get_report_query(column_group_options, 'strict_range')
+ if allowed_ids:
+ query.add_where(SQL('%s.id IN %s', tax_elem_table, tuple(allowed_ids)))
+ queries.append(SQL(
+ """
+ SELECT
+ %(column_group_key)s AS column_group_key,
+ account_move_line.partner_id AS groupby,
+ res_partner.vat AS vat_number,
+ res_country.code AS country_code,
+ -SUM(%(balance_select)s) AS balance,
+ %(tax_elem_table_name)s AS sales_type_code,
+ %(tax_elem_table)s.id AS tax_element_id,
+ (comp_partner.country_id = res_partner.country_id) AS same_country
+ FROM %(table_references)s
+ %(currency_table_join)s
+ JOIN %(aml_rel_table)s ON %(aml_rel_table)s.account_move_line_id = account_move_line.id
+ JOIN %(tax_elem_table)s ON %(aml_rel_table)s.%(tax_elem_table_id)s = %(tax_elem_table)s.id
+ JOIN res_partner ON account_move_line.partner_id = res_partner.id
+ JOIN res_country ON res_partner.country_id = res_country.id
+ JOIN res_company ON res_company.id = account_move_line.company_id
+ JOIN res_partner comp_partner ON comp_partner.id = res_company.partner_id
+ WHERE %(search_condition)s
+ GROUP BY %(tax_elem_table)s.id, %(tax_elem_table)s.name, account_move_line.partner_id,
+ res_partner.vat, res_country.code, comp_partner.country_id, res_partner.country_id
+ """,
+ column_group_key=column_group_key,
+ tax_elem_table_name=tax_elem_table_name,
+ tax_elem_table=tax_elem_table,
+ table_references=query.from_clause,
+ balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")),
+ currency_table_join=report._currency_table_aml_join(column_group_options),
+ aml_rel_table=aml_rel_table,
+ tax_elem_table_id=tax_elem_table_id,
+ search_condition=query.where_clause,
+ ))
+ return SQL(' UNION ALL ').join(queries)
+
+ @api.model
+ def _get_tag_ids_filtered(self, options):
+ """
+ Helper function to get all the tag_ids concerned by the report for the given options.
+ :param dict options: Report options
+ :return tuple: tag_ids untyped after filtering
+ """
+ allowed_taxes = set()
+ for operation_type in options.get('ec_tax_filter_selection', []):
+ if operation_type.get('selected'):
+ allowed_taxes.update(options['sales_report_taxes'][operation_type.get('id')])
+ return allowed_taxes
+
+ @api.model
+ def _get_ec_country_codes(self, options):
+ """
+ Return the list of country codes for the EC countries.
+ :param dict options: Report options
+ :return set: List of country codes for a given date (UK case)
+ """
+ rslt = {'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU',
+ 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'XI'}
+
+ # GB left the EU on January 1st 2021. But before this date, it's still to be considered as a EC country
+ if fields.Date.from_string(options['date']['date_from']) < fields.Date.from_string('2021-01-01'):
+ rslt.add('GB')
+ # Monaco is treated as part of France for VAT purposes (but should not be displayed within FR context)
+ if self.env.company.account_fiscal_country_id.code != 'FR':
+ rslt.add('MC')
+
+ return rslt
+
+ def get_warning_act_window(self, options, params):
+ act_window = {'type': 'ir.actions.act_window', 'context': {}}
+ if params['type'] == 'no_vat':
+ aml_domains = [
+ ('partner_id.vat', '=', None),
+ ('partner_id.country_id.code', 'in', tuple(self._get_ec_country_codes(options))),
+ ]
+ act_window.update({
+ 'name': _("Entries with partners with no VAT"),
+ 'context': {'search_default_group_by_partner': 1, 'expand': 1}
+ })
+ elif params['type'] == 'non_ec_country':
+ aml_domains = [('partner_id.country_id.code', 'not in', tuple(self._get_ec_country_codes(options)))]
+ act_window['name'] = _("EC tax on non EC countries")
+ elif params['type'] == 'duplicated_vat':
+ return self._get_duplicated_vat_partners(tuple(params['duplicated_partners_vat']))
+ else:
+ aml_domains = [('partner_id.country_id.code', '=', options.get('same_country_warning'))]
+ act_window['name'] = _("EC tax on same country")
+ use_taxes_instead_of_tags = options.get('sales_report_taxes', {}).get('use_taxes_instead_of_tags')
+ tax_or_tag_field = 'tax_ids.id' if use_taxes_instead_of_tags else 'tax_tag_ids.id'
+ amls = self.env['account.move.line'].search([
+ *aml_domains,
+ *self.env['account.report']._get_options_date_domain(options, 'strict_range'),
+ (tax_or_tag_field, 'in', tuple(self._get_tag_ids_filtered(options)))
+ ])
+
+ if params['model'] == 'move':
+ act_window.update({
+ 'views': [[self.env.ref('account.view_move_tree').id, 'list'], (False, 'form')],
+ 'res_model': 'account.move',
+ 'domain': [('id', 'in', amls.move_id.ids)],
+ })
+ else:
+ act_window.update({
+ 'views': [(False, 'list'), (False, 'form')],
+ 'res_model': 'res.partner',
+ 'domain': [('id', 'in', amls.move_id.partner_id.ids)],
+ })
+
+ return act_window
+
+ def _get_duplicated_vat_partners(self, duplicated_partners_vat):
+ view_ref = self.env.ref('odex30_account_reports.duplicated_vat_partner_tree_view', raise_if_not_found=False)
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Partners with duplicated VAT numbers'),
+ 'context': {'group_by': 'vat', 'expand': 1, 'duplicated_partners_vat': duplicated_partners_vat},
+ 'views': [(view_ref and view_ref.id or False, 'list'), (False, 'form')],
+ 'res_model': 'res.partner',
+ 'domain': [('vat', 'in', duplicated_partners_vat)],
+ }
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_tax.py b/dev_odex30_accounting/odex30_account_reports/models/account_tax.py
new file mode 100644
index 0000000..b116dc5
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_tax.py
@@ -0,0 +1,158 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, models, fields, Command, _
+from odoo.exceptions import ValidationError
+
+
+class AccountTaxUnit(models.Model):
+ _name = "account.tax.unit"
+ _description = "Tax Unit"
+
+ name = fields.Char(string="Name", required=True)
+ country_id = fields.Many2one(string="Country", comodel_name='res.country', required=True, help="The country in which this tax unit is used to group your companies' tax reports declaration.")
+ vat = fields.Char(string="Tax ID", required=True, help="The identifier to be used when submitting a report for this unit.")
+ company_ids = fields.Many2many(string="Companies", comodel_name='res.company', required=True, help="Members of this unit")
+ main_company_id = fields.Many2one(string="Main Company", comodel_name='res.company', required=True, help="Main company of this unit; the one actually reporting and paying the taxes.")
+ fpos_synced = fields.Boolean(string="Fiscal Positions Synchronised", compute='_compute_fiscal_position_completion', help="Technical field indicating whether Fiscal Positions exist for all companies in the unit")
+
+ def create(self, vals_list):
+ res = super().create(vals_list)
+
+ horizontal_groups = self.env['account.report.horizontal.group'].create([
+ {
+ 'name': tax_unit.name,
+ 'rule_ids': [
+ Command.create({
+ 'field_name': 'company_id',
+ 'domain': f"[('account_tax_unit_ids', 'in', {tax_unit.id})]",
+ }),
+ ],
+ }
+ for tax_unit in res
+ ])
+
+ generic_tax_report = self.env.ref('account.generic_tax_report')
+ generic_tax_report.horizontal_group_ids |= horizontal_groups
+
+ generic_tax_report_account_tax = self.env.ref('account.generic_tax_report_account_tax')
+ generic_tax_report_account_tax.horizontal_group_ids |= horizontal_groups
+
+ generic_tax_report_tax_account = self.env.ref('account.generic_tax_report_tax_account')
+ generic_tax_report_tax_account.horizontal_group_ids |= horizontal_groups
+
+ generic_ec_sales_report = self.env.ref('odex30_account_reports.generic_ec_sales_report')
+ generic_ec_sales_report.horizontal_group_ids |= horizontal_groups
+
+ for tax_unit in res:
+ generic_tax_report.variant_report_ids.filtered(lambda variant: variant.country_id == tax_unit.country_id).write(
+ {
+ 'horizontal_group_ids': [Command.link(group.id) for group in horizontal_groups],
+ }
+ )
+
+ return res
+
+ @api.depends('company_ids')
+ def _compute_fiscal_position_completion(self):
+ for unit in self:
+ synced = True
+ for company in unit.company_ids:
+ origin_company = company._origin if isinstance(company.id, models.NewId) else company
+ fp = unit._get_tax_unit_fiscal_positions(companies=origin_company)
+ all_partners_with_fp = self.env['res.company'].search([]).with_company(origin_company).partner_id\
+ .filtered(lambda p: p.property_account_position_id == fp) if fp else self.env['res.partner']
+ synced = all_partners_with_fp == (unit.company_ids - origin_company).partner_id
+ if not synced:
+ break
+ unit.fpos_synced = synced
+
+ def _get_tax_unit_fiscal_positions(self, companies, create_or_refresh=False):
+ """
+ Retrieves or creates fiscal positions for all companies specified.
+ Each Fiscal Position contains all the taxes of the company mapped to no tax
+
+ @param {recordset} companies: companies for which to find/create fiscal positions
+ @param {boolean} create_or_refresh: a boolean indicating whether the fiscal positions should be created if not found
+ @return {recordset} all the fiscal positions found/created for the companies requested.
+ """
+ fiscal_positions = self.env['account.fiscal.position'].with_context(allowed_company_ids=self.env.user.company_ids.ids)
+ for unit in self:
+ for company in companies:
+ fp_identifier = 'account.tax_unit_%s_fp_%s' % (unit.id, company.id)
+ existing_fp = self.env.ref(fp_identifier, raise_if_not_found=False)
+ if create_or_refresh:
+ taxes_to_map = self.env['account.tax'].with_context(
+ allowed_company_ids=self.env.user.company_ids.ids,
+ ).search(self.env['account.tax']._check_company_domain(company))
+ data = {
+ 'xml_id': fp_identifier,
+ 'values': {
+ 'name': unit.name,
+ 'company_id': company.id,
+ 'tax_ids': [Command.clear()] + [Command.create({'tax_src_id': tax.id}) for tax in taxes_to_map]
+ }
+ }
+ existing_fp = fiscal_positions._load_records([data])
+ if existing_fp:
+ fiscal_positions += existing_fp
+ return fiscal_positions
+
+ def action_sync_unit_fiscal_positions(self):
+ self._get_tax_unit_fiscal_positions(companies=self.env['res.company'].search([])).unlink()
+ for unit in self:
+ for company in unit.company_ids:
+ fp = unit._get_tax_unit_fiscal_positions(companies=company, create_or_refresh=True)
+ (unit.company_ids - company).with_company(company).partner_id.property_account_position_id = fp
+
+ def unlink(self):
+ # EXTENDS base
+ self._get_tax_unit_fiscal_positions(companies=self.env['res.company'].search([])).unlink()
+ return super().unlink()
+
+ @api.constrains('country_id', 'company_ids')
+ def _validate_companies_country(self):
+ for record in self:
+ currencies = set()
+ for company in record.company_ids:
+ currencies.add(company.currency_id)
+
+ if any(unit != record and unit.country_id == record.country_id for unit in company.account_tax_unit_ids):
+ raise ValidationError(_("Company %(company)s already belongs to a tax unit in %(country)s. A company can at most be part of one tax unit per country.", company=company.name, country=record.country_id.name))
+
+ if len(currencies) > 1:
+ raise ValidationError(_("A tax unit can only be created between companies sharing the same main currency."))
+
+ @api.constrains('company_ids', 'main_company_id')
+ def _validate_main_company(self):
+ for record in self:
+ if record.main_company_id not in record.company_ids:
+ raise ValidationError(_("The main company of a tax unit has to be part of it."))
+
+ @api.constrains('company_ids')
+ def _validate_companies(self):
+ for record in self:
+ if len(record.company_ids) < 2:
+ raise ValidationError(_("A tax unit must contain a minimum of two companies. You might want to delete the unit."))
+
+ @api.constrains('country_id', 'vat')
+ def _validate_vat(self):
+ for record in self:
+ if not record.vat:
+ continue
+
+ checked_country_code = self.env['res.partner']._run_vat_test(record.vat, record.country_id)
+
+ if checked_country_code and checked_country_code != record.country_id.code.lower():
+ raise ValidationError(_("The country detected for this VAT number does not match the one set on this Tax Unit."))
+
+ if not checked_country_code:
+ tu_label = _("tax unit [%s]", record.name)
+ error_message = self.env['res.partner']._build_vat_error_message(record.country_id.code.lower(), record.vat, tu_label)
+ raise ValidationError(error_message)
+
+ @api.onchange('company_ids')
+ def _onchange_company_ids(self):
+ if self.main_company_id not in self.company_ids and self.company_ids:
+ self.main_company_id = self.company_ids[0]._origin
+ elif not self.company_ids:
+ self.main_company_id = False
diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_trial_balance_report.py b/dev_odex30_accounting/odex30_account_reports/models/account_trial_balance_report.py
new file mode 100644
index 0000000..5f9bb16
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/account_trial_balance_report.py
@@ -0,0 +1,195 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, models, _, fields
+from odoo.tools import float_compare
+from odoo.tools.misc import DEFAULT_SERVER_DATE_FORMAT
+
+
+class TrialBalanceCustomHandler(models.AbstractModel):
+ _name = 'account.trial.balance.report.handler'
+ _inherit = 'account.report.custom.handler'
+ _description = 'Trial Balance Custom Handler'
+
+ def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None):
+ def _update_column(line, column_key, new_value):
+ line['columns'][column_key]['no_format'] = new_value
+ line['columns'][column_key]['is_zero'] = self.env.company.currency_id.is_zero(new_value)
+
+ def _update_balance_columns(line, debit_column_key, credit_column_key, balance_column_key=None):
+ debit_value = line['columns'][debit_column_key]['no_format'] if debit_column_key is not None else False
+ credit_value = line['columns'][credit_column_key]['no_format'] if credit_column_key is not None else False
+
+ if debit_value and credit_value:
+ new_debit_value = 0.0
+ new_credit_value = 0.0
+
+ if self.env.company.currency_id.compare_amounts(debit_value, credit_value) == 1:
+ new_debit_value = debit_value - credit_value
+ else:
+ new_credit_value = (debit_value - credit_value) * -1
+
+ _update_column(line, debit_column_key, new_debit_value)
+ _update_column(line, credit_column_key, new_credit_value)
+
+ if balance_column_key is not None:
+ _update_column(line, balance_column_key, debit_value - credit_value)
+
+ def is_end_balance_column(column):
+ return options['column_groups'][column['column_group_key']].get('forced_options').get('is_end_balance')
+
+ lines = [line[1] for line in self.env['account.general.ledger.report.handler']._dynamic_lines_generator(report, options, all_column_groups_expression_totals, warnings=warnings)]
+
+ # We need to find the index of debit and credit columns for initial and end balance in case of extra custom columns
+ init_balance_debit_index = next((index for index, column in enumerate(options['columns']) if column.get('expression_label') == 'debit'), None)
+ init_balance_credit_index = next((index for index, column in enumerate(options['columns']) if column.get('expression_label') == 'credit'), None)
+
+ end_balance_debit_index = next((index for index, column in enumerate(options['columns']) if column.get('expression_label') == 'debit' and is_end_balance_column(column)), None)
+ end_balance_credit_index = next((index for index, column in enumerate(options['columns']) if column.get('expression_label') == 'credit' and is_end_balance_column(column)), None)
+ end_balance_balance_index = next((index for index, column in enumerate(options['columns']) if column.get('expression_label') == 'balance' and is_end_balance_column(column)), None)
+
+ currency = self.env.company.currency_id
+ for line in lines[:-1]:
+ # Initial balance
+ _update_balance_columns(line, init_balance_debit_index, init_balance_credit_index)
+ _update_balance_columns(line, end_balance_debit_index, end_balance_credit_index, end_balance_balance_index)
+
+ line.pop('expand_function', None)
+ line.pop('groupby', None)
+ line.update({
+ 'unfoldable': False,
+ 'unfolded': False,
+ })
+
+ res_model = report._get_model_info_from_id(line['id'])[0]
+ if res_model == 'account.account':
+ line['caret_options'] = 'trial_balance'
+
+ # Total line
+ if lines:
+ total_line = lines[-1]
+
+ for index in (init_balance_debit_index, init_balance_credit_index, end_balance_debit_index, end_balance_credit_index):
+ if index is not None:
+ total_line['columns'][index]['no_format'] = sum(currency.round(line['columns'][index]['no_format']) for line in lines[:-1] if report._get_model_info_from_id(line['id'])[0] == 'account.account')
+ total_line['columns'][index]['blank_if_zero'] = False
+
+ return [(0, line) for line in lines]
+
+ def _caret_options_initializer(self):
+ return {
+ 'trial_balance': [
+ {'name': _("General Ledger"), 'action': 'caret_option_open_general_ledger'},
+ {'name': _("Journal Items"), 'action': 'open_journal_items'},
+ ],
+ }
+
+ def _get_column_group_creation_data(self, report, options, previous_options=None):
+ """
+ Return tuple of tuples containing a reference to the column_group creation function and on which side ('left' | 'right') of the report the column_group goes
+ """
+ return (
+ (self._create_column_group_initial_balance, 'left'),
+ (self._create_column_group_end_balance, 'right'),
+ )
+
+ @api.model
+ def _create_and_append_column_group(self, report, options, header_name, forced_options, side_to_append, group_vals, exclude_initial_balance=False, append_col_groups=True):
+ header_element = [{'name': header_name, 'forced_options': forced_options}]
+ column_headers = [header_element, *options['column_headers'][1:]]
+ column_group_vals = report._generate_columns_group_vals_recursively(column_headers, group_vals)
+
+ if exclude_initial_balance:
+ # This column group must not include initial balance; we use a special option key for that in general ledger
+ for column_group in column_group_vals:
+ column_group['forced_options']['general_ledger_strict_range'] = True
+
+ columns, column_groups = report._build_columns_from_column_group_vals(forced_options, column_group_vals)
+
+ side_to_append['column_headers'] += header_element
+ if append_col_groups:
+ side_to_append['column_groups'] |= column_groups
+ side_to_append['columns'] += columns
+
+ def _custom_options_initializer(self, report, options, previous_options):
+ """ Modifies the provided options to add a column group for initial balance and end balance, as well as the appropriate columns.
+ """
+ default_group_vals = {'horizontal_groupby_element': {}, 'forced_options': {}}
+ left_side = {'column_headers': [], 'column_groups': {}, 'columns': []}
+ right_side = {'column_headers': [], 'column_groups': {}, 'columns': []}
+
+ # Columns between initial and end balance must not include initial balance; we use a special option key for that in general ledger
+ for column_group in options['column_groups'].values():
+ column_group['forced_options']['general_ledger_strict_range'] = True
+
+ if options.get('comparison') and not options['comparison'].get('periods'):
+ options['comparison']['period_order'] = 'ascending'
+
+ # Create column groups
+ for function, side in self._get_column_group_creation_data(report, options, previous_options):
+ function(report, options, previous_options, default_group_vals, left_side if side == 'left' else right_side)
+
+ # Update options
+ options['column_headers'][0] = left_side['column_headers'] + options['column_headers'][0] + right_side['column_headers']
+ options['column_groups'].update(left_side['column_groups'])
+ options['column_groups'].update(right_side['column_groups'])
+ options['columns'] = left_side['columns'] + options['columns'] + right_side['columns']
+ options['ignore_totals_below_sections'] = True # So that GL does not compute them
+
+ # All the periods displayed between initial and end balance need to use the same rates, so we manually change the period key.
+ # account.report will then compute the currency table periods accordingly
+ middle_periods_period_key = '_trial_balance_middle_periods'
+ for col_group in options['column_groups'].values():
+ col_group_date = col_group['forced_options'].get('date')
+ if col_group_date:
+ col_group_date['currency_table_period_key'] = middle_periods_period_key
+
+ report._init_options_order_column(options, previous_options)
+
+ def _custom_line_postprocessor(self, report, options, lines):
+ # If the hierarchy is enabled, ensure to add the o_account_coa_column_contrast class to the hierarchy lines
+ if options.get('hierarchy'):
+ for line in lines:
+ model, dummy = report._get_model_info_from_id(line['id'])
+ if model == 'account.group':
+ line_classes = line.get('class', '')
+ line['class'] = line_classes + ' o_account_coa_column_contrast_hierarchy'
+
+ return lines
+
+ def _create_column_group_initial_balance(self, report, options, previous_options, default_group_vals, side_to_append):
+ initial_balance_options = self.env['account.general.ledger.report.handler']._get_options_initial_balance(options)
+ initial_forced_options = {
+ 'date': initial_balance_options['date'],
+ 'include_current_year_in_unaff_earnings': initial_balance_options['include_current_year_in_unaff_earnings'],
+ 'no_impact_on_currency_table': True,
+ }
+
+ self._create_and_append_column_group(
+ report,
+ options,
+ _("Initial Balance"),
+ initial_forced_options,
+ side_to_append,
+ default_group_vals,
+ )
+
+ def _create_column_group_end_balance(self, report, options, previous_options, default_group_vals, side_to_append):
+ end_date_to = options['date']['date_to']
+ end_date_from = options['date']['date_from']
+ end_forced_options = {
+ 'date': report._get_dates_period(
+ fields.Date.from_string(end_date_from),
+ fields.Date.from_string(end_date_to),
+ 'range',
+ ),
+ 'is_end_balance': True,
+ }
+
+ self._create_and_append_column_group(
+ report,
+ options,
+ _("End Balance"),
+ end_forced_options,
+ side_to_append,
+ default_group_vals,
+ )
diff --git a/dev_odex30_accounting/odex30_account_reports/models/balance_sheet.py b/dev_odex30_accounting/odex30_account_reports/models/balance_sheet.py
new file mode 100644
index 0000000..189098a
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/balance_sheet.py
@@ -0,0 +1,11 @@
+from odoo import models
+
+
+class BalanceSheetCustomHandler(models.AbstractModel):
+ _name = 'account.balance.sheet.report.handler'
+ _inherit = 'account.report.custom.handler'
+ _description = "Balance Sheet Custom Handler"
+
+ def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings):
+ if options['currency_table']['type'] == 'cta':
+ warnings['odex30_account_reports.common_possibly_unbalanced_because_cta'] = {}
diff --git a/dev_odex30_accounting/odex30_account_reports/models/bank_reconciliation_report.py b/dev_odex30_accounting/odex30_account_reports/models/bank_reconciliation_report.py
new file mode 100644
index 0000000..e2ad93d
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/bank_reconciliation_report.py
@@ -0,0 +1,620 @@
+from datetime import date
+import logging
+from odoo import models, fields, _
+from odoo.exceptions import UserError
+from odoo.tools import SQL
+from odoo.osv import expression
+
+_logger = logging.getLogger(__name__)
+
+
+class BankReconciliationReportCustomHandler(models.AbstractModel):
+ _name = 'account.bank.reconciliation.report.handler'
+ _inherit = 'account.report.custom.handler'
+ _description = 'Bank Reconciliation Report Custom Handler'
+
+ ######################
+ # Options
+ ######################
+ def _custom_options_initializer(self, report, options, previous_options):
+ super()._custom_options_initializer(report, options, previous_options=previous_options)
+
+ # Options is needed otherwise some elements added in the post processor go on the total line
+ options['ignore_totals_below_sections'] = True
+ options['no_xlsx_currency_code_columns'] = True
+ if 'active_id' in self._context and self._context.get('active_model') == 'account.journal':
+ options['bank_reconciliation_report_journal_id'] = self._context['active_id']
+ elif 'bank_reconciliation_report_journal_id' in previous_options:
+ options['bank_reconciliation_report_journal_id'] = previous_options['bank_reconciliation_report_journal_id']
+ else:
+ # This should never happen except in some test cases
+ options['bank_reconciliation_report_journal_id'] = self.env['account.journal'].search([('type', '=', 'bank')], limit=1).id
+
+ # Remove multi-currency columns if needed
+ is_multi_currency = self.env.user.has_group('base.group_multi_currency') and self.env.user.has_group('base.group_no_one')
+ if not is_multi_currency:
+ options['columns'] = [
+ column for column in options['columns']
+ if column['expression_label'] not in ('amount_currency', 'currency')
+ ]
+
+ ######################
+ # Getter
+ ######################
+ def _get_bank_journal_and_currencies(self, options):
+ journal = self.env['account.journal'].browse(options.get('bank_reconciliation_report_journal_id'))
+ company_currency = journal.company_id.currency_id
+ journal_currency = journal.currency_id or company_currency
+ return journal, journal_currency, company_currency
+
+ ######################
+ # Return function
+ ######################
+ def _build_custom_engine_result(self, date=None, label=None, amount_currency=None, amount_currency_currency_id=None, currency=None, amount=0, amount_currency_id=None, has_sublines=False):
+ return {
+ 'date': date,
+ 'label': label,
+ 'amount_currency': amount_currency,
+ 'amount_currency_currency_id': amount_currency_currency_id,
+ 'currency': currency,
+ 'amount': amount,
+ 'amount_currency_id': amount_currency_id,
+ 'has_sublines': has_sublines,
+ }
+
+ ######################
+ # Engine
+ ######################
+ def _report_custom_engine_forced_currency_amount(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ _journal, journal_currency, _company_currency = self._get_bank_journal_and_currencies(options)
+ return self._build_custom_engine_result(amount_currency_id=journal_currency.id)
+
+ def _report_custom_engine_unreconciled_last_statement_receipts(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ return self._bank_reconciliation_report_custom_engine_common(options, 'receipts', current_groupby, True)
+
+ def _report_custom_engine_unreconciled_last_statement_payments(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ return self._bank_reconciliation_report_custom_engine_common(options, 'payments', current_groupby, True)
+
+ def _report_custom_engine_unreconciled_receipts(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ return self._bank_reconciliation_report_custom_engine_common(options, 'receipts', current_groupby, False)
+
+ def _report_custom_engine_unreconciled_payments(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ return self._bank_reconciliation_report_custom_engine_common(options, 'payments', current_groupby, False)
+
+ def _report_custom_engine_outstanding_receipts(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ return self._bank_reconciliation_report_custom_engine_outstanding_common(options, 'receipts', current_groupby)
+
+ def _report_custom_engine_outstanding_payments(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ return self._bank_reconciliation_report_custom_engine_outstanding_common(options, 'payments', current_groupby)
+
+ def _report_custom_engine_misc_operations(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ report = self.env['account.report'].browse(options['report_id'])
+ report._check_groupby_fields([current_groupby] if current_groupby else [])
+
+ journal, journal_currency, _company_currency = self._get_bank_journal_and_currencies(options)
+ exchange_journal = journal.company_id.currency_exchange_journal_id
+
+ bank_miscellaneous_domain = self._get_bank_miscellaneous_move_lines_domain(options, journal)
+ bank_miscellaneous_domain = expression.AND([
+ bank_miscellaneous_domain,
+ [('journal_id', '!=', exchange_journal.id)]
+ ])
+
+ base_query = report._get_report_query(options, 'strict_range', domain=bank_miscellaneous_domain or [])
+
+ groupby_field_sql = self.env['account.move.line']._field_to_sql("account_move_line", current_groupby, base_query) if current_groupby else None
+ query_sql = SQL(
+ """
+ SELECT
+ %(select_from_groupby)s,
+ COALESCE(SUM(COALESCE(NULLIF(account_move_line.amount_currency, 0), account_move_line.balance)), 0)
+ FROM %(table_references)s
+ WHERE %(search_condition)s
+ %(groupby_sql)s
+ """,
+ select_from_groupby=groupby_field_sql,
+ table_references=base_query.from_clause,
+ search_condition=base_query.where_clause,
+ groupby_sql=SQL("GROUP BY %s", groupby_field_sql) if groupby_field_sql else SQL(),
+ )
+
+ self._cr.execute(query_sql)
+ query_res_lines = self._cr.fetchall()
+
+ if not current_groupby:
+ return self._build_custom_engine_result(amount=query_res_lines[-1][1], amount_currency_id=journal_currency.id)
+ else:
+ return [
+ (grouping_key, self._build_custom_engine_result(amount=amount, amount_currency_id=journal_currency.id))
+ for grouping_key, amount in query_res_lines
+ ]
+
+ def _report_custom_engine_last_statement_balance_amount(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ if current_groupby:
+ raise UserError(_("Custom engine _report_custom_engine_last_statement_balance_amount does not support groupby"))
+
+ journal, journal_currency, _company_currency = self._get_bank_journal_and_currencies(options)
+ last_statement = self._get_last_bank_statement(journal, options)
+
+ return self._build_custom_engine_result(amount=last_statement.balance_end_real, amount_currency_id=journal_currency.id)
+
+ def _report_custom_engine_transaction_without_statement_amount(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ return self._bank_reconciliation_report_custom_engine_common(options, 'all', current_groupby, False, unreconciled=False)
+
+ def _bank_reconciliation_report_custom_engine_common(self, options, internal_type, current_groupby, from_last_statement, unreconciled=True):
+ """
+ Retrieve entries for bank reconciliation based on specified parameters.
+ Parameters:
+ - options (dict): A dictionary containing options of the report.
+ - internal_type (str): The internal type used for classification (e.g., receipt, payment). For the receipt
+ we will query the entries with a positive amounts and for the payment
+ the negative amounts.
+ If the internal type is another thing that receipt or payment it will get all the
+ entries position or negative
+ - current_groupby (str): The current grouping criteria.
+ - last_statement (bool, optional): If True, query entries from the last bank statement.
+ Otherwise, query entries that are not part of the last bank
+ statement.
+ - unreconciled (bool, optional): If True, query the unreconciled entries only
+
+ """
+ journal, journal_currency, _company_currency = self._get_bank_journal_and_currencies(options)
+ if not journal:
+ return self._build_custom_engine_result()
+
+ report = self.env['account.report'].browse(options['report_id'])
+ report._check_groupby_fields([current_groupby] if current_groupby else [])
+
+ def build_result_dict(query_res_lines):
+ # The query should find exactly one account move line per bank statement line
+ if current_groupby == 'id':
+ res = query_res_lines[0]
+ foreign_currency = self.env['res.currency'].browse(res['foreign_currency_id'])
+ rate = 1 # journal_currency / foreign_currency
+ if foreign_currency:
+ rate = (res['amount'] / res['amount_currency']) if res['amount_currency'] else 0
+
+ return self._build_custom_engine_result(
+ date=res['date'] if res['date'] else None,
+ label=res['payment_ref'] or res['ref'] or '/',
+ amount_currency=-res['amount_residual'] if res['foreign_currency_id'] else None,
+ amount_currency_currency_id=foreign_currency.id if res['foreign_currency_id'] else None,
+ currency=foreign_currency.display_name if res['foreign_currency_id'] else None,
+ amount=-res['amount_residual'] * rate if res['amount_residual'] else None,
+ amount_currency_id=journal_currency.id,
+ )
+ else:
+ amount = 0
+ for res in query_res_lines:
+ rate = 1 # journal_currency / foreign_currency
+ if res['foreign_currency_id']:
+ rate = (res['amount'] / res['amount_currency']) if res['amount_currency'] else 0
+ amount += -res.get('amount_residual', 0) * rate if unreconciled else res.get('amount', 0)
+
+ return self._build_custom_engine_result(
+ amount=amount,
+ amount_currency_id=journal_currency.id,
+ has_sublines=bool(len(query_res_lines)),
+ )
+
+ query = report._get_report_query(options, 'strict_range', domain=[
+ ('journal_id', '=', journal.id),
+ ('account_id', '=', journal.default_account_id.id), # There should be only 1 line per move with that account
+ ])
+
+ if from_last_statement:
+ last_statement_id = self._get_last_bank_statement(journal, options).id
+ if last_statement_id:
+ last_statement_id_condition = SQL("st_line.statement_id = %s", last_statement_id)
+ else:
+ # If there is no last statement, the last statement section must be empty and the other must have all
+ # transaction
+ return self._compute_result([], current_groupby, build_result_dict)
+ else:
+ last_statement_id_condition = SQL("st_line.statement_id IS NULL")
+
+ if internal_type == 'receipts':
+ st_line_amount_condition = SQL("AND st_line.amount > 0")
+ elif internal_type == 'payments':
+ st_line_amount_condition = SQL("AND st_line.amount < 0")
+ else:
+ # For the Transaction without statement, the internal type is 'all'
+ st_line_amount_condition = SQL("")
+
+ groupby_field_sql = self.env['account.move.line']._field_to_sql("account_move_line", current_groupby, query) if current_groupby else SQL('NULL')
+ # Build query
+ query = SQL(
+ """
+ SELECT %(select_from_groupby)s,
+ st_line.id,
+ move.name,
+ move.ref,
+ move.date,
+ st_line.payment_ref,
+ st_line.amount,
+ st_line.amount_residual,
+ st_line.amount_currency,
+ st_line.foreign_currency_id
+ FROM %(table_references)s
+ JOIN account_bank_statement_line st_line ON st_line.move_id = account_move_line.move_id
+ JOIN account_move move ON move.id = st_line.move_id
+ WHERE %(search_condition)s
+ %(is_unreconciled)s
+ %(st_line_amount_condition)s
+ AND %(last_statement_id_condition)s
+ GROUP BY %(group_by)s,
+ st_line.id,
+ move.id
+ """,
+ select_from_groupby=SQL("%s AS grouping_key", groupby_field_sql),
+ table_references=query.from_clause,
+ search_condition=query.where_clause,
+ is_receipt=SQL("st_line.amount > 0") if internal_type == "receipts" else SQL("st_line.amount < 0"),
+ is_unreconciled=SQL("AND NOT st_line.is_reconciled") if unreconciled else SQL(""),
+ st_line_amount_condition=st_line_amount_condition,
+ last_statement_id_condition=last_statement_id_condition,
+ group_by=groupby_field_sql if current_groupby else SQL('st_line.id'), # Same key in the groupby because we can't put a null key in a group by
+ )
+
+ self._cr.execute(query)
+ query_res_lines = self._cr.dictfetchall()
+
+ return self._compute_result(query_res_lines, current_groupby, build_result_dict)
+
+ def _bank_reconciliation_report_custom_engine_outstanding_common(self, options, internal_type, current_groupby):
+ """
+ This engine retrieves the data of all recorded payments/receipts that have not been matched with a bank
+ statement yet
+ """
+ journal, journal_currency, company_currency = self._get_bank_journal_and_currencies(options)
+ if not journal:
+ return self._build_custom_engine_result()
+
+ report = self.env['account.report'].browse(options['report_id'])
+ report._check_groupby_fields([current_groupby] if current_groupby else [])
+
+ def build_result_dict(query_res_lines):
+ if current_groupby == 'id':
+ res = query_res_lines[0]
+ convert = not (journal_currency and res['currency_id'] == journal_currency.id)
+ amount_currency = res['amount_residual_currency'] if res['is_account_reconcile'] else res['amount_currency']
+ balance = res['amount_residual'] if res['is_account_reconcile'] else res['balance']
+ foreign_currency = self.env['res.currency'].browse(res['currency_id'])
+
+ return self._build_custom_engine_result(
+ date=res['date'] if res['date'] else None,
+ label=res['ref'] if res['ref'] else None,
+ amount_currency=amount_currency if convert else None,
+ amount_currency_currency_id=foreign_currency.id if convert else None,
+ currency=foreign_currency.display_name if convert else None,
+ amount=company_currency._convert(balance, journal_currency, journal.company_id, options['date']['date_to']) if convert else amount_currency,
+ amount_currency_id=journal_currency.id,
+ )
+ else:
+ amount = 0
+ for res in query_res_lines:
+ convert = not (journal_currency and res['currency_id'] == journal_currency.id)
+ if convert:
+ balance = res['amount_residual'] if res['is_account_reconcile'] else res['balance']
+ amount += company_currency._convert(balance, journal_currency, journal.company_id, options['date']['date_to'])
+ else:
+ amount += res['amount_residual_currency'] if res['is_account_reconcile'] else res['amount_currency']
+
+ return self._build_custom_engine_result(
+ amount=amount,
+ amount_currency_id=journal_currency.id,
+ has_sublines=bool(len(query_res_lines)),
+ )
+
+ accounts = journal._get_journal_inbound_outstanding_payment_accounts() + journal._get_journal_outbound_outstanding_payment_accounts()
+
+ query = report._get_report_query(options, 'from_beginning', domain=[
+ ('journal_id', '=', journal.id),
+ ('account_id', 'in', accounts.ids),
+ ('full_reconcile_id', '=', False),
+ ('amount_residual_currency', '!=', 0.0)
+ ])
+
+ # Build query
+ groupby_field_sql = self.env['account.move.line']._field_to_sql("account_move_line", current_groupby, query) if current_groupby else SQL('NULL')
+ query = SQL(
+ """
+ SELECT %(select_from_groupby)s,
+ account_move_line.account_id,
+ account_move_line.payment_id,
+ account_move_line.move_id,
+ account_move_line.currency_id,
+ account_move_line.move_name AS name,
+ account_move_line.ref,
+ account_move_line.date,
+ account.reconcile AS is_account_reconcile,
+ SUM(account_move_line.amount_residual) AS amount_residual,
+ SUM(account_move_line.balance) AS balance,
+ SUM(account_move_line.amount_residual_currency) AS amount_residual_currency,
+ SUM(account_move_line.amount_currency) AS amount_currency
+ FROM %(table_references)s
+ JOIN account_account account ON account.id = account_move_line.account_id
+ WHERE %(search_condition)s
+ AND %(is_receipt)s
+ GROUP BY %(group_by)s,
+ account_move_line.account_id,
+ account_move_line.payment_id,
+ account_move_line.move_id,
+ account_move_line.currency_id,
+ account_move_line.move_name,
+ account_move_line.ref,
+ account_move_line.date,
+ account.reconcile
+ """,
+ select_from_groupby=SQL("%s AS grouping_key", groupby_field_sql),
+ table_references=query.from_clause,
+ search_condition=query.where_clause,
+ is_receipt=SQL("account_move_line.balance > 0") if internal_type == "receipts" else SQL("account_move_line.balance < 0"),
+ group_by=groupby_field_sql if current_groupby else SQL('account_move_line.account_id'), # Same key in the groupby because we can't put a null key in a group by
+ )
+ self._cr.execute(query)
+ query_res_lines = self._cr.dictfetchall()
+
+ return self._compute_result(query_res_lines, current_groupby, build_result_dict)
+
+ def _compute_result(self, query_res_lines, current_groupby, build_result_dict):
+ if not current_groupby:
+ return build_result_dict(query_res_lines)
+ else:
+ rslt = []
+
+ all_res_per_grouping_key = {}
+ for query_res in query_res_lines:
+ grouping_key = query_res['grouping_key']
+ all_res_per_grouping_key.setdefault(grouping_key, []).append(query_res)
+
+ for grouping_key, query_res_lines in all_res_per_grouping_key.items():
+ rslt.append((grouping_key, build_result_dict(query_res_lines)))
+
+ return rslt
+
+ def _custom_line_postprocessor(self, report, options, lines):
+ lines = super()._custom_line_postprocessor(report, options, lines)
+ journal, _journal_currency, _company_currency = self._get_bank_journal_and_currencies(options)
+ if not journal:
+ return lines
+
+ last_statement = self._get_last_bank_statement(journal, options)
+
+ for line in lines:
+ line_id = report._get_res_id_from_line_id(line['id'], 'account.report.line')
+ code = self.env['account.report.line'].browse(line_id).code
+
+ if code == "balance_bank":
+ line['name'] = _("Balance of '%s'", journal.default_account_id.display_name)
+
+ if code == "last_statement_balance":
+ line['class'] = 'o_bold_tr'
+ if last_statement:
+ line['columns'][1].update({
+ 'name': last_statement.display_name,
+ 'auditable': True,
+ })
+
+ if code == "transaction_without_statement":
+ line['class'] = 'o_bold_tr'
+
+ if code == "misc_operations":
+ line['class'] = 'o_bold_tr'
+
+ # Check if it's a leaf node
+ model, _model_id = report._get_model_info_from_id(line['id'])
+ if model == "account.move.line":
+ line_name = line['name'].split()
+ line['name'] = line_name[0] # This will give just the name without the ref or label
+
+ return lines
+
+ def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings):
+ journal, journal_currency, _company_currency = self._get_bank_journal_and_currencies(options)
+ inconsistent_statement = self._get_inconsistent_statements(options, journal).ids
+ bank_miscellaneous_domain = self._get_bank_miscellaneous_move_lines_domain(options, journal)
+ has_bank_miscellaneous_move_lines = bank_miscellaneous_domain and bool(self.env['account.move.line'].search_count(bank_miscellaneous_domain, limit=1))
+ last_statement, balance_gl, balance_end, unexplained_difference, general_ledger_not_matching = self._compute_journal_balances(report, options, journal, journal_currency)
+
+ if warnings is not None:
+ if last_statement and general_ledger_not_matching:
+ warnings['odex30_account_reports.journal_balance'] = {
+ 'alert_type': 'warning',
+ 'general_ledger_amount': balance_gl,
+ 'last_bank_statement_amount': balance_end,
+ 'unexplained_difference': unexplained_difference,
+ }
+ if inconsistent_statement:
+ warnings['odex30_account_reports.inconsistent_statement_warning'] = {'alert_type': 'warning', 'args': inconsistent_statement}
+ if has_bank_miscellaneous_move_lines:
+ warnings['odex30_account_reports.has_bank_miscellaneous_move_lines'] = {'alert_type': 'warning', 'args': journal.default_account_id.display_name}
+
+ def _compute_journal_balances(self, report, options, journal, journal_currency):
+ """
+ This function compute all necessary information for the warning 'odex30_account_reports.journal_balance'
+ :param report: The bank reconciliation report.
+ :param options: The report options.
+ :param journal: The journal used.
+ """
+ # Get domain and balances
+ domain = report._get_options_domain(options, 'from_beginning')
+ balance_gl = journal._get_journal_bank_account_balance(domain=domain)[0]
+ last_statement, balance_end, difference, general_ledger_not_matching = self._compute_balances(options, journal, balance_gl, journal_currency)
+
+ # Format values
+ balance_gl = report.format_value(options, balance_gl, format_params={'currency_id': journal_currency.id}, figure_type='monetary')
+ balance_end = report.format_value(options, balance_end, format_params={'currency_id': journal_currency.id}, figure_type='monetary')
+ difference = report.format_value(options, difference, format_params={'currency_id': journal_currency.id}, figure_type='monetary')
+
+ return last_statement, balance_gl, balance_end, difference, general_ledger_not_matching
+
+ def _compute_balances(self, options, journal, balance_gl, report_currency):
+ """
+ This function will compute the balance of the last statement and the unexplained difference.
+ :param options: The report options.
+ :param journal: The journal used.
+ :param balance_gl: The balance of the general ledger.
+ :param report_currency: The currency of the report.
+ """
+ report_date = fields.Date.from_string(options['date']['date_to'])
+ last_statement = self._get_last_bank_statement(journal, options)
+ balance_end = 0
+ difference = 0
+ general_ledger_not_matching = False
+
+ if last_statement:
+ lines_before_date_to = last_statement.line_ids.filtered(lambda line: line.date <= report_date)
+ balance_end = last_statement.balance_start + sum(lines_before_date_to.mapped('amount'))
+ difference = balance_gl - balance_end
+ general_ledger_not_matching = not report_currency.is_zero(difference)
+
+ return last_statement, balance_end, difference, general_ledger_not_matching
+
+ def _get_last_bank_statement(self, journal, options):
+ """
+ Retrieve the last bank statement created using this journal.
+ :param journal: The journal used.
+ :param domain: An additional domain to be applied on the account.bank.statement model.
+ :return: An account.bank.statement record or an empty recordset.
+ """
+ report_date = fields.Date.from_string(options['date']['date_to'])
+ last_statement_domain = [('journal_id', '=', journal.id), ('statement_id', '!=', False), ('date', '<=', report_date)]
+ last_st_line = self.env['account.bank.statement.line'].search(last_statement_domain, order='date desc, id desc', limit=1)
+ return last_st_line.statement_id
+
+ def _get_inconsistent_statements(self, options, journal):
+ """
+ Retrieve the account.bank.statements records on the range of the options date having different starting
+ balance regarding its previous statement.
+ :param options: The report options.
+ :param journal: The account.journal from which this report has been opened.
+ :return: An account.bank.statements recordset.
+ """
+ return self.env['account.bank.statement'].search([
+ ('journal_id', '=', journal.id),
+ ('date', '<=', options['date']['date_to']),
+ ('is_valid', '=', False),
+ ])
+
+ def _get_bank_miscellaneous_move_lines_domain(self, options, journal):
+ """
+ Get the domain to be used to retrieve the journal items affecting the bank accounts but not linked to
+ a statement line. (Limited in a year)
+ :param options: The report options.
+ :param journal: The account.journal from which this report has been opened.
+ :return: A domain to search on the account.move.line model.
+
+ """
+ if not journal.default_account_id:
+ return None
+
+ report = self.env['account.report'].browse(options['report_id'])
+ domain = [
+ ('account_id', '=', journal.default_account_id.id),
+ ('statement_line_id', '=', False),
+ *report._get_options_domain(options, 'from_beginning'),
+ ]
+
+ fiscal_lock_date = journal.company_id._get_user_fiscal_lock_date(journal)
+ if fiscal_lock_date != date.min:
+ domain.append(('date', '>', fiscal_lock_date))
+
+ if journal.company_id.account_opening_move_id:
+ domain.append(('move_id', '!=', journal.company_id.account_opening_move_id.id))
+
+ return domain
+
+ ################
+ # Audit
+ ################
+ def action_audit_cell(self, options, params):
+ report_line = self.env['account.report.line'].browse(params['report_line_id'])
+ if report_line.code == "balance_bank":
+ return self.action_redirect_to_general_ledger(options)
+ elif report_line.code == "misc_operations":
+ return self.open_bank_miscellaneous_move_lines(options)
+ elif report_line.code == "last_statement_balance":
+ return self.action_redirect_to_bank_statement_widget(options)
+ else:
+ return report_line.report_id.action_audit_cell(options, params)
+
+ ################
+ # ACTIONS
+ ################
+ def action_redirect_to_general_ledger(self, options):
+ """
+ Action to redirect to the general ledger
+ :param options: The report options.
+ :return: Actions to the report
+ """
+ general_ledger_action = self.env['ir.actions.actions']._for_xml_id('odex30_account_reports.action_account_report_general_ledger')
+ general_ledger_action['params'] = {
+ 'options': options,
+ 'ignore_session': True,
+ }
+
+ return general_ledger_action
+
+ def action_redirect_to_bank_statement_widget(self, options):
+ """
+ Redirect the user to the requested bank statement, if empty displays all bank transactions of the journal.
+ :param options: The report options.
+ :param params: The action params containing at least 'statement_id', can be false.
+ :return: A dictionary representing an ir.actions.act_window.
+ """
+ journal = self.env['account.journal'].browse(options.get('bank_reconciliation_report_journal_id'))
+ last_statement = self._get_last_bank_statement(journal, options)
+ return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
+ default_context={'create': False, 'search_default_statement_id': last_statement.id},
+ name=last_statement.display_name,
+ )
+
+ def open_bank_miscellaneous_move_lines(self, options):
+ """
+ An action opening the account.move.line list view affecting the bank account balance but not linked to
+ a bank statement line.
+ :param options: The report options.
+ :param params: -Not used-.
+ :return: An action redirecting to the list view of journal items.
+ """
+ journal = self.env['account.journal'].browse(options['bank_reconciliation_report_journal_id'])
+
+ return {
+ 'name': _('Journal Items'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.move.line',
+ 'view_type': 'list',
+ 'view_mode': 'list',
+ 'target': 'current',
+ 'views': [(self.env.ref('account.view_move_line_tree').id, 'list')],
+ 'domain': self.env['account.bank.reconciliation.report.handler']._get_bank_miscellaneous_move_lines_domain(options, journal),
+ }
+
+ def bank_reconciliation_report_open_inconsistent_statements(self, options, params=None):
+ """
+ An action opening the account.bank.statement view (form or list) depending the 'inconsistent_statement_ids'
+ key set on the options.
+ :param options: The report options.
+ :param params: -Not used-.
+ :return: An action redirecting to a view of statements.
+ """
+ inconsistent_statement_ids = params['args']
+ action = {
+ 'name': _("Inconsistent Statements"),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.bank.statement',
+ }
+ if len(inconsistent_statement_ids) == 1:
+ action.update({
+ 'view_mode': 'form',
+ 'res_id': inconsistent_statement_ids[0],
+ 'views': [(False, 'form')],
+ })
+ else:
+ action.update({
+ 'view_mode': 'list',
+ 'domain': [('id', 'in', inconsistent_statement_ids)],
+ 'views': [(False, 'list')],
+ })
+ return action
diff --git a/dev_odex30_accounting/odex30_account_reports/models/budget.py b/dev_odex30_accounting/odex30_account_reports/models/budget.py
new file mode 100644
index 0000000..ddf15a8
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/budget.py
@@ -0,0 +1,116 @@
+from itertools import zip_longest
+from dateutil.relativedelta import relativedelta
+
+from odoo import api, Command, fields, models, _
+from odoo.exceptions import ValidationError
+from odoo.tools import date_utils, float_is_zero, float_round
+
+
+class AccountReportBudget(models.Model):
+ _name = 'account.report.budget'
+ _description = "Accounting Report Budget"
+ _order = 'sequence, id'
+
+ sequence = fields.Integer(string="Sequence")
+ name = fields.Char(string="Name", required=True)
+ item_ids = fields.One2many(string="Items", comodel_name='account.report.budget.item', inverse_name='budget_id')
+ company_id = fields.Many2one(string="Company", comodel_name='res.company', required=True, default=lambda x: x.env.company)
+
+ @api.constrains('name')
+ def _contrains_name(self):
+ for budget in self:
+ if not budget.name:
+ raise ValidationError(_("Please enter a valid budget name."))
+
+ @api.model_create_multi
+ def create(self, create_values):
+ for values in create_values:
+ if name := values.get('name'):
+ values['name'] = name.strip()
+ return super().create(create_values)
+
+ def _create_or_update_budget_items(self, value_to_set, account_id, rounding, date_from, date_to):
+ """ This method will create / update several budget items following the number
+ of months between date_from(include) and date_to(include).
+
+ :param value_to_set: The value written by the user in the report cell.
+ :param account_id: The related account id.
+ :param rounding: The rounding for the decimal precision.
+ :param date_from: The start date for the budget item creation.
+ :param date_to: The end date for the budget item creation.
+ """
+ self.ensure_one()
+
+ date_from, date_to = fields.Date.to_date(date_from), fields.Date.to_date(date_to)
+ if date_from != date_utils.start_of(date_from, 'month'):
+ date_from = (date_from.replace(day=1) + relativedelta(months=1))
+ existing_budget_items = self.env['account.report.budget.item'].search_fetch([
+ ('budget_id', '=', self.id),
+ ('account_id', '=', account_id),
+ ('date', '<=', date_to),
+ ('date', '>=', date_from),
+ ], ['id', 'amount'])
+ existing_budget_items_by_date = {item.date: item for item in existing_budget_items}
+ total_amount = sum(existing_budget_items.mapped('amount'))
+
+ value_to_compute = value_to_set - total_amount
+ if float_is_zero(value_to_compute, precision_digits=rounding):
+ # In case the computed amount equals 0, we do an early return as
+ # it's not necessary to create new budget item
+ return
+
+ start_month_dates = [
+ date_utils.start_of(date, 'month')
+ for date in date_utils.date_range(date_from, date_to)
+ ]
+
+ # Fill a list with the same amounts for each month
+ amounts = [float_round(value_to_compute / len(start_month_dates), precision_digits=rounding, rounding_method='DOWN')] * len(start_month_dates)
+ # Add the remainder in the last amount
+ amounts[-1] += float_round(value_to_compute - sum(amounts), precision_digits=rounding)
+
+ budget_items_commands = []
+ for start_month_date, amount in zip_longest(start_month_dates, amounts):
+ existing_budget_item = existing_budget_items_by_date.get(start_month_date)
+ if existing_budget_item:
+ budget_items_commands.append(Command.update(existing_budget_item.id, {
+ 'amount': existing_budget_item.amount + amount,
+ }))
+ else:
+ budget_items_commands.append(Command.create({
+ 'account_id': account_id,
+ 'amount': amount,
+ 'date': start_month_date,
+ }))
+
+ if budget_items_commands:
+ self.item_ids = budget_items_commands
+ # Make sure that the model is flushed before continuing the code and fetching these new items
+ self.env['account.report.budget.item'].flush_model()
+
+ def copy_data(self, default=None):
+ vals_list = super().copy_data(default=default)
+ return [dict(vals, name=self.env._("%s (copy)", budget.name)) for budget, vals in zip(self, vals_list)]
+
+ def copy(self, default=None):
+ new_budgets = super().copy(default)
+ for old_budget, new_budget in zip(self, new_budgets):
+ for item in old_budget.item_ids:
+ item.copy({
+ 'budget_id': new_budget.id,
+ 'account_id': item.account_id.id,
+ 'amount': item.amount,
+ 'date': item.date,
+ })
+
+ return new_budgets
+
+
+class AccountReportBudgetItem(models.Model):
+ _name = 'account.report.budget.item'
+ _description = "Accounting Report Budget Item"
+
+ budget_id = fields.Many2one(string="Budget", comodel_name='account.report.budget', required=True, ondelete='cascade')
+ account_id = fields.Many2one(string="Account", comodel_name='account.account', required=True)
+ amount = fields.Float(string="Amount", default=0)
+ date = fields.Date(required=True)
diff --git a/dev_odex30_accounting/odex30_account_reports/models/chart_template.py b/dev_odex30_accounting/odex30_account_reports/models/chart_template.py
new file mode 100644
index 0000000..0a9728b
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/chart_template.py
@@ -0,0 +1,39 @@
+# coding: utf-8
+from odoo import fields, models, _
+from odoo.exceptions import ValidationError
+
+
+class AccountChartTemplate(models.AbstractModel):
+ _inherit = 'account.chart.template'
+
+ def _post_load_data(self, template_code, company, template_data):
+ super()._post_load_data(template_code, company, template_data)
+
+ company = company or self.env.company
+ default_misc_journal = self.env['account.journal'].search([
+ *self.env['account.journal']._check_company_domain(company),
+ ('type', '=', 'general')
+ ], limit=1)
+ if not default_misc_journal:
+ raise ValidationError(_("No default miscellaneous journal could be found for the active company"))
+
+ company.update({
+ 'totals_below_sections': company.anglo_saxon_accounting,
+ 'account_tax_periodicity_journal_id': default_misc_journal,
+ 'account_tax_periodicity_reminder_day': 7,
+ })
+ default_misc_journal.show_on_dashboard = True
+
+ generic_tax_report = self.env.ref('account.generic_tax_report')
+ tax_report = self.env['account.report'].search([
+ ('availability_condition', '=', 'country'),
+ ('country_id', '=', company.country_id.id),
+ ('root_report_id', '=', generic_tax_report.id),
+ ], limit=1)
+ if not tax_report:
+ tax_report = generic_tax_report
+
+ _dummy, period_end = company._get_tax_closing_period_boundaries(fields.Date.today(), tax_report)
+ activity = company._get_tax_closing_reminder_activity(tax_report.id, period_end)
+ if not activity:
+ company._generate_tax_closing_reminder_activity(tax_report, period_end)
diff --git a/dev_odex30_accounting/odex30_account_reports/models/executive_summary_report.py b/dev_odex30_accounting/odex30_account_reports/models/executive_summary_report.py
new file mode 100644
index 0000000..dd409a4
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/executive_summary_report.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+
+from odoo import fields, models
+from odoo.exceptions import UserError
+
+class ExecutiveSummaryReport(models.Model):
+ _inherit = 'account.report'
+
+ def _report_custom_engine_executive_summary_ndays(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ if current_groupby or next_groupby:
+ raise UserError("NDays expressions of executive summary report don't support the 'group by' feature.")
+
+ date_diff = fields.Date.from_string(options['date']['date_to']) - fields.Date.from_string(options['date']['date_from'])
+ return {'result': date_diff.days}
diff --git a/dev_odex30_accounting/odex30_account_reports/models/ir_actions.py b/dev_odex30_accounting/odex30_account_reports/models/ir_actions.py
new file mode 100644
index 0000000..cfe18b9
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/ir_actions.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+
+from odoo import models
+
+class IrActionsAccountReportDownload(models.AbstractModel):
+
+ _name = 'ir_actions_account_report_download'
+ _description = 'Technical model for accounting report downloads'
+
+ def _get_readable_fields(self):
+
+ return self.env['ir.actions.actions']._get_readable_fields() | {'data'}
diff --git a/dev_odex30_accounting/odex30_account_reports/models/mail_activity.py b/dev_odex30_accounting/odex30_account_reports/models/mail_activity.py
new file mode 100644
index 0000000..8ca2a7f
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/mail_activity.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+
+from odoo import fields, models, _
+
+
+class AccountTaxReportActivity(models.Model):
+ _inherit = "mail.activity"
+
+ account_tax_closing_params = fields.Json(string="Tax closing additional params")
+
+ def action_open_tax_activity(self):
+ self.ensure_one()
+ if self.activity_type_id == self.env.ref('odex30_account_reports.mail_activity_type_tax_report_to_pay'):
+ move = self.env['account.move'].browse(self.res_id)
+ return move._action_tax_to_pay_wizard()
+ elif self.activity_type_id == self.env.ref('odex30_account_reports.mail_activity_type_tax_report_to_be_sent'):
+ move = self.env['account.move'].browse(self.res_id)
+ return move._action_tax_to_send()
+
+ if self.activity_type_id == self.env.ref('odex30_account_reports.mail_activity_type_tax_report_error'):
+ move = self.env['account.move'].browse(self.res_id)
+ return move._action_tax_report_error()
+
+ journal = self.env['account.journal'].browse(self.res_id)
+ options = {}
+ if self.account_tax_closing_params:
+ options = self.env['account.move']._get_tax_closing_report_options(
+ journal.company_id,
+ self.env['account.fiscal.position'].browse(self.account_tax_closing_params['fpos_id']) if self.account_tax_closing_params['fpos_id'] else False,
+ self.env['account.report'].browse(self.account_tax_closing_params['report_id']),
+ fields.Date.from_string(self.account_tax_closing_params['tax_closing_end_date'])
+ )
+ action = self.env["ir.actions.actions"]._for_xml_id("odex30_account_reports.action_account_report_gt")
+ action.update({'params': {'options': options, 'ignore_session': True}})
+ return action
diff --git a/dev_odex30_accounting/odex30_account_reports/models/mail_activity_type.py b/dev_odex30_accounting/odex30_account_reports/models/mail_activity_type.py
new file mode 100644
index 0000000..d02c57e
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/mail_activity_type.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+
+class AccountTaxReportActivityType(models.Model):
+ _inherit = "mail.activity.type"
+
+ category = fields.Selection(selection_add=[('tax_report', 'Tax report')])
+
+ @api.model
+ def _get_model_info_by_xmlid(self):
+ info = super()._get_model_info_by_xmlid()
+ info['odex30_account_reports.tax_closing_activity_type'] = {
+ 'res_model': 'account.journal',
+ 'unlink': False,
+ }
+ info['odex30_account_reports.mail_activity_type_tax_report_to_pay'] = {
+ 'res_model': 'account.move',
+ 'unlink': False,
+ }
+ info['odex30_account_reports.mail_activity_type_tax_report_to_be_sent'] = {
+ 'res_model': 'account.move',
+ 'unlink': False,
+ }
+ return info
diff --git a/dev_odex30_accounting/odex30_account_reports/models/res_company.py b/dev_odex30_accounting/odex30_account_reports/models/res_company.py
new file mode 100644
index 0000000..fd3dd36
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/res_company.py
@@ -0,0 +1,465 @@
+# -*- coding: utf-8 -*-
+
+import datetime
+from dateutil.relativedelta import relativedelta
+import itertools
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+from odoo.tools import date_utils
+from odoo.tools.misc import format_date
+
+
+class ResCompany(models.Model):
+ _inherit = "res.company"
+
+ totals_below_sections = fields.Boolean(
+ string='Add totals below sections',
+ help='When ticked, totals and subtotals appear below the sections of the report.')
+ account_tax_periodicity = fields.Selection([
+ ('year', 'annually'),
+ ('semester', 'semi-annually'),
+ ('4_months', 'every 4 months'),
+ ('trimester', 'quarterly'),
+ ('2_months', 'every 2 months'),
+ ('monthly', 'monthly')], string="Delay units", help="Periodicity", default='monthly', required=True)
+ account_tax_periodicity_reminder_day = fields.Integer(string='Start from', default=7, required=True)
+ account_tax_periodicity_journal_id = fields.Many2one('account.journal', string='Journal', domain=[('type', '=', 'general')], check_company=True)
+ account_revaluation_journal_id = fields.Many2one('account.journal', domain=[('type', '=', 'general')], check_company=True)
+ account_revaluation_expense_provision_account_id = fields.Many2one('account.account', string='Expense Provision Account', check_company=True)
+ account_revaluation_income_provision_account_id = fields.Many2one('account.account', string='Income Provision Account', check_company=True)
+ account_tax_unit_ids = fields.Many2many(string="Tax Units", comodel_name='account.tax.unit', help="The tax units this company belongs to.")
+ account_representative_id = fields.Many2one('res.partner', string='Accounting Firm',
+ help="Specify an Accounting Firm that will act as a representative when exporting reports.")
+ account_display_representative_field = fields.Boolean(compute='_compute_account_display_representative_field')
+
+ @api.depends('account_fiscal_country_id.code')
+ def _compute_account_display_representative_field(self):
+ country_set = self._get_countries_allowing_tax_representative()
+ for record in self:
+ record.account_display_representative_field = record.account_fiscal_country_id.code in country_set
+
+ def _get_countries_allowing_tax_representative(self):
+ """ Returns a set containing the country codes of the countries for which
+ it is possible to use a representative to submit the tax report.
+ This function is a hook that needs to be overridden in localisation modules.
+ """
+ return set()
+
+ def _get_default_misc_journal(self):
+ """ Returns a default 'miscellanous' journal to use for
+ account_tax_periodicity_journal_id field. This is useful in case a
+ CoA was already installed on the company at the time the module
+ is installed, so that the field is set automatically when added."""
+ return self.env['account.journal'].search([
+ *self.env['account.journal']._check_company_domain(self),
+ ('type', '=', 'general'),
+ ], limit=1)
+
+ def _get_tax_closing_journal(self):
+ journals = self.env['account.journal']
+ for company in self:
+ journals |= company.account_tax_periodicity_journal_id or company._get_default_misc_journal()
+
+ return journals
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ companies = super().create(vals_list)
+ companies._initiate_account_onboardings()
+ return companies
+
+ def write(self, values):
+ tax_closing_update_dependencies = ('account_tax_periodicity', 'account_tax_periodicity_journal_id.id')
+ to_update = self.env['res.company']
+ for company in self:
+ if company._get_tax_closing_journal():
+ need_tax_closing_update = any(
+ update_dep in values and company.mapped(update_dep)[0] != values[update_dep]
+ for update_dep in tax_closing_update_dependencies
+ )
+
+ if need_tax_closing_update:
+ to_update += company
+
+ res = super().write(values)
+
+ # Early return
+ if not to_update:
+ return res
+
+ to_reset_closing_moves = self.env['account.move'].sudo().search([
+ ('company_id', 'in', to_update.ids),
+ ('tax_closing_report_id', '!=', False),
+ ('state', '=', 'draft'),
+ ])
+ to_reset_closing_moves.button_cancel()
+ misc_journals = self.env['account.journal'].sudo().search([
+ *self.env['account.journal']._check_company_domain(to_update),
+ ('type', '=', 'general'),
+ ])
+ to_reset_closing_reminder_activities = self.env['mail.activity'].sudo().search([
+ ('res_id', 'in', misc_journals.ids),
+ ('res_model_id', '=', self.env['ir.model']._get_id('account.journal')),
+ ('activity_type_id', '=', self.env.ref('odex30_account_reports.tax_closing_activity_type').id),
+ ('active', '=', True),
+ ])
+ to_reset_closing_reminder_activities.action_cancel()
+ generic_tax_report = self.env.ref('account.generic_tax_report')
+
+ # Create a new reminder
+ # The user is unlikely to change the periodicity often and for multiple companies at once
+ # So it is fair enough to make this that way as we are obliged to get the tax report for each company
+ # And then loop over all the reports to get their period boudaries and look for activity
+ for company in to_update:
+ tax_reports = self.env['account.report'].search([
+ ('availability_condition', '=', 'country'),
+ ('country_id', 'in', company.account_enabled_tax_country_ids.ids),
+ ('root_report_id', '=', generic_tax_report.id),
+ ])
+ if not tax_reports.filtered(lambda x: x.country_id == company.account_fiscal_country_id):
+ tax_reports += generic_tax_report
+
+ for tax_report in tax_reports:
+ period_start, period_end = company._get_tax_closing_period_boundaries(fields.Date.today(), tax_report)
+ activity = company._get_tax_closing_reminder_activity(tax_report.id, period_end)
+ if not activity and self.env['account.move'].search_count([
+ ('date', '<=', period_end),
+ ('date', '>=', period_start),
+ ('tax_closing_report_id', '=', tax_report.id),
+ ('company_id', '=', company.id),
+ ('state', '=', 'posted')
+ ]) == 0:
+ company._generate_tax_closing_reminder_activity(tax_report, period_end)
+
+ hidden_tax_journals = self._get_tax_closing_journal().sudo().filtered(lambda j: not j.show_on_dashboard)
+ if hidden_tax_journals:
+ hidden_tax_journals.show_on_dashboard = True
+
+ return res
+
+ def _get_closing_report_for_tax_closing_move(self, report, fpos):
+ closing_report = report
+
+ if not closing_report.country_id and closing_report.root_report_id:
+ # Fallback to root report if we're using a non-localized variant (typically the grouped tax reports)
+ closing_report = closing_report.root_report_id
+
+ target_country = (fpos and fpos.country_id) or self.env.company.account_fiscal_country_id
+ country_variants = [variant for variant in (closing_report.root_report_id or closing_report).variant_report_ids if variant.country_id == target_country]
+ if len(country_variants) > 1:
+ # More than one national variant available: use the generic tax report
+ closing_report = self.env.ref('account.generic_tax_report')
+ elif country_variants and closing_report.country_id != target_country:
+ # Only one national variant available: select it
+ closing_report = country_variants[0]
+
+ return closing_report
+
+ def _get_and_update_tax_closing_moves(self, in_period_date, report, fiscal_positions=None, include_domestic=False):
+ """ Searches for tax closing moves. If some are missing for the provided parameters,
+ they are created in draft state. Also, existing moves get updated in case of configuration changes
+ (closing journal or periodicity, for example). Note the content of these moves stays untouched.
+
+ :param in_period_date: A date within the tax closing period we want the closing for.
+ :param fiscal_positions: The fiscal positions we want to generate the closing for (as a recordset).
+ :param include_domestic: Whether or not the domestic closing (i.e. the one without any fiscal_position_id) must be included
+
+ :return: The closing moves, as a recordset.
+ """
+ self.ensure_one()
+
+ if not fiscal_positions:
+ fiscal_positions = []
+
+ # Compute period dates depending on the date
+ tax_closing_journal = self._get_tax_closing_journal()
+
+ all_closing_moves = self.env['account.move']
+ for fpos in itertools.chain(fiscal_positions, [False] if include_domestic else []):
+ closing_report = self._get_closing_report_for_tax_closing_move(report, fpos)
+
+ period_start, period_end = self._get_tax_closing_period_boundaries(in_period_date, closing_report)
+ periodicity = self._get_tax_periodicity(closing_report)
+
+ fpos_id = fpos.id if fpos else False
+ tax_closing_move = self.env['account.move'].search([
+ ('state', '=', 'draft'),
+ ('company_id', '=', self.id),
+ ('tax_closing_report_id', '=', closing_report.id),
+ ('date', '>=', period_start),
+ ('date', '<=', period_end),
+ ('fiscal_position_id', '=', fpos.id if fpos else None),
+ ])
+
+ # This should never happen, but can be caused by wrong manual operations
+ if len(tax_closing_move) > 1:
+ if fpos:
+ error = _("Multiple draft tax closing entries exist for fiscal position %(position)s after %(period_start)s. There should be at most one. \n %(closing_entries)s",
+ position=fpos.name, period_start=period_start, closing_entries=tax_closing_move.mapped('display_name'))
+
+ else:
+ error = _("Multiple draft tax closing entries exist for your domestic region after %(period_start)s. There should be at most one. \n %(closing_entries)s",
+ period_start=period_start, closing_entries=tax_closing_move.mapped('display_name'))
+
+ raise UserError(error)
+
+ # Compute tax closing description
+ ref = _("%(report_label)s: %(period)s", report_label=self._get_tax_closing_report_display_name(closing_report), period=self._get_tax_closing_move_description(periodicity, period_start, period_end, fpos, closing_report))
+
+ # Values for update/creation of closing move
+ closing_vals = {
+ 'company_id': self.id,# Important to specify together with the journal, for branches
+ 'journal_id': tax_closing_journal.id,
+ 'date': period_end,
+ 'tax_closing_report_id': closing_report.id,
+ 'fiscal_position_id': fpos_id,
+ 'ref': ref,
+ 'name': '/', # Explicitly set a void name so that we don't set the sequence for the journal and don't consume a sequence number
+ }
+
+ if tax_closing_move:
+ tax_closing_move.write(closing_vals)
+ else:
+ # Create a new, empty, tax closing move
+ tax_closing_move = self.env['account.move'].create(closing_vals)
+
+ # Create a reminder activity if it doesn't exist
+ activity = self._get_tax_closing_reminder_activity(closing_report.id, period_end, fpos_id)
+ tax_closing_options = tax_closing_move._get_tax_closing_report_options(tax_closing_move.company_id, tax_closing_move.fiscal_position_id, tax_closing_move.tax_closing_report_id, tax_closing_move.date)
+ if not activity and closing_report._get_sender_company_for_export(tax_closing_options) == tax_closing_move.company_id:
+ self._generate_tax_closing_reminder_activity(closing_report, period_end, fpos)
+
+ all_closing_moves += tax_closing_move
+
+ return all_closing_moves
+
+ def _get_tax_closing_report_display_name(self, report):
+ if report.get_external_id().get(report.id) in ('account.generic_tax_report', 'account.generic_tax_report_account_tax', 'account.generic_tax_report_tax_account'):
+ return _("Tax return")
+
+ return report.display_name
+
+ def _generate_tax_closing_reminder_activity(self, report, date_in_period=None, fiscal_position=None):
+ """
+ Create a reminder on the current tax_closing_journal for a certain report with a fiscal_position or not if None.
+ The reminder will target the period from which the date sits in
+ """
+ self.ensure_one()
+ if not date_in_period:
+ date_in_period = fields.Date.today()
+ # Search for an existing tax closing move
+ tax_closing_activity_type = self.env.ref('odex30_account_reports.tax_closing_activity_type')
+
+ # Tax period
+ period_start, period_end = self._get_tax_closing_period_boundaries(date_in_period, report)
+ periodicity = self._get_tax_periodicity(report)
+ activity_deadline = period_end + relativedelta(days=self.account_tax_periodicity_reminder_day)
+
+ # Reminder title
+ summary = _(
+ "%(report_label)s: %(period)s",
+ report_label=self._get_tax_closing_report_display_name(report),
+ period=self._get_tax_closing_move_description(periodicity, period_start, period_end, fiscal_position, report)
+ )
+
+ activity_user = tax_closing_activity_type.default_user_id if tax_closing_activity_type else self.env['res.users']
+ if activity_user and not (self in activity_user.company_ids and activity_user.has_group('account.group_account_manager')):
+ activity_user = self.env['res.users']
+
+ if not activity_user:
+ activity_user = self.env['res.users'].search(
+ [('company_ids', 'in', self.ids), ('groups_id', 'in', self.env.ref('account.group_account_manager').ids)],
+ limit=1, order="id ASC",
+ )
+
+ self.env['mail.activity'].with_context(mail_activity_quick_update=True).create({
+ 'res_id': self._get_tax_closing_journal().id,
+ 'res_model_id': self.env['ir.model']._get_id('account.journal'),
+ 'activity_type_id': tax_closing_activity_type.id,
+ 'date_deadline': activity_deadline,
+ 'automated': True,
+ 'summary': summary,
+ 'user_id': activity_user.id or self.env.user.id,
+ 'account_tax_closing_params': {
+ 'report_id': report.id,
+ 'tax_closing_end_date': fields.Date.to_string(period_end),
+ 'fpos_id': fiscal_position.id if fiscal_position else False,
+ },
+ })
+
+ def _get_tax_closing_reminder_activity(self, report_id, period_end, fpos_id=False):
+ self.ensure_one()
+ tax_closing_activity_type = self.env.ref('odex30_account_reports.tax_closing_activity_type')
+ return self._get_tax_closing_journal().activity_ids.filtered(
+ lambda act: act.account_tax_closing_params and (act.activity_type_id == tax_closing_activity_type and act.account_tax_closing_params['report_id'] == report_id
+ and fields.Date.from_string(act.account_tax_closing_params['tax_closing_end_date']) == period_end
+ and act.account_tax_closing_params['fpos_id'] == fpos_id)
+ )
+
+ def _get_tax_closing_move_description(self, periodicity, period_start, period_end, fiscal_position, report):
+ """ Returns a string description of the provided period dates, with the
+ given tax periodicity.
+ """
+ self.ensure_one()
+
+ foreign_vat_fpos_count = self.env['account.fiscal.position'].search_count([
+ ('company_id', '=', self.id),
+ ('foreign_vat', '!=', False)
+ ])
+ if foreign_vat_fpos_count:
+ if fiscal_position:
+ country_code = fiscal_position.country_id.code
+ state_codes = fiscal_position.mapped('state_ids.code') if fiscal_position.state_ids else []
+ else:
+ # On domestic country
+ country_code = self.account_fiscal_country_id.code
+
+ # Only consider the state in case there are foreign VAT fpos on states in this country
+ vat_fpos_with_state_count = self.env['account.fiscal.position'].search_count([
+ ('company_id', '=', self.id),
+ ('foreign_vat', '!=', False),
+ ('country_id', '=', self.account_fiscal_country_id.id),
+ ('state_ids', '!=', False),
+ ])
+ state_codes = [self.state_id.code] if self.state_id and vat_fpos_with_state_count else []
+
+ if state_codes:
+ region_string = " (%s - %s)" % (country_code, ', '.join(state_codes))
+ else:
+ region_string = " (%s)" % country_code
+ else:
+ # Don't add region information in case there is no foreign VAT fpos
+ region_string = ''
+
+ # Shift back to normal dates if we are using a start date so periods aren't broken
+ start_day, start_month = self._get_tax_closing_start_date_attributes(report)
+ if start_day != 1 or start_month != 1:
+ return f"{format_date(self.env, period_start)} - {format_date(self.env, period_end)}{region_string}"
+
+ if periodicity == 'year':
+ return f"{period_start.year}{region_string}"
+ elif periodicity == 'trimester':
+ return f"{format_date(self.env, period_start, date_format='qqq yyyy')}{region_string}"
+ elif periodicity == 'monthly':
+ return f"{format_date(self.env, period_start, date_format='LLLL yyyy')}{region_string}"
+ else:
+ return f"{format_date(self.env, period_start)} - {format_date(self.env, period_end)}{region_string}"
+
+ def _get_tax_closing_period_boundaries(self, date, report):
+ """ Returns the boundaries of the tax period containing the provided date
+ for this company, as a tuple (start, end).
+
+ This function needs to stay consitent with the one inside Javascript in the filters for the tax report
+ """
+ self.ensure_one()
+ period_months = self._get_tax_periodicity_months_delay(report)
+ start_day, start_month = self._get_tax_closing_start_date_attributes(report)
+ aligned_date = date + relativedelta(days=-(start_day - 1)) # we offset the date back from start_day amount of day - 1 so we can compute months periods aligned to the start and end of months
+ year = aligned_date.year
+ month_offset = aligned_date.month - start_month
+ period_number = (month_offset // period_months) + 1
+
+ # If the date is before the start date and start month of this year, this mean we are in the previous period
+ # So the initial_date should be one year before and the period_number should be computed in reverse because month_offset is negative
+ if date < datetime.date(date.year, start_month, start_day):
+ year -= 1
+ period_number = ((12 + month_offset) // period_months) + 1
+
+ month_delta = period_number * period_months
+
+ # We need to work with offsets because it handle automatically the end of months (28, 29, 30, 31)
+ end_date = datetime.date(year, start_month, 1) + relativedelta(months=month_delta, days=start_day - 2) # -1 because the first days is aldready counted and -1 because the first day of the next period must not be in this range
+ start_date = datetime.date(year, start_month, 1) + relativedelta(months=month_delta - period_months, day=start_day)
+
+ return start_date, end_date
+
+ def _get_available_tax_unit(self, report):
+ """
+ Must ensures that report has a country_id to search for a tax unit
+
+ :return: A recordset of available tax units for this report country_id and this company
+ """
+ self.ensure_one()
+ return self.env['account.tax.unit'].search([
+ ('company_ids', 'in', self.id),
+ ('country_id', '=', report.country_id.id),
+ ], limit=1)
+
+ def _get_tax_periodicity(self, report):
+ main_company = self
+ if report.filter_multi_company == 'tax_units' and report.country_id and (tax_unit := self._get_available_tax_unit(report)):
+ main_company = tax_unit.main_company_id
+
+ return main_company.account_tax_periodicity
+
+ def _get_tax_closing_start_date_attributes(self, report):
+ if not report.tax_closing_start_date:
+ start_year = fields.Date.start_of(fields.Date.today(), 'year')
+ return start_year.day, start_year.month
+
+ main_company = self
+ if report.filter_multi_company == 'tax_units' and report.country_id and (tax_unit := self._get_available_tax_unit(report)):
+ main_company = tax_unit.main_company_id
+
+ start_date = report.with_company(main_company).tax_closing_start_date
+
+ return start_date.day, start_date.month
+
+ def _get_tax_periodicity_months_delay(self, report):
+ """ Returns the number of months separating two tax returns with the provided periodicity
+ """
+ self.ensure_one()
+ periodicities = {
+ 'year': 12,
+ 'semester': 6,
+ '4_months': 4,
+ 'trimester': 3,
+ '2_months': 2,
+ 'monthly': 1,
+ }
+ return periodicities[self._get_tax_periodicity(report)]
+
+ def _get_branches_with_same_vat(self, accessible_only=False):
+ """ Returns all companies among self and its branch hierachy (considering children and parents) that share the same VAT number
+ as self. An empty VAT number is considered as being the same as the one of the closest parent with a VAT number.
+
+ self is always returned as the first element of the resulting recordset (so that this can safely be used to restore the active company).
+
+ Example:
+ - main company ; vat = 123
+ - branch 1
+ - branch 1_1
+ - branch 2 ; vat = 456
+ - branch 2_1 ; vat = 789
+ - branch 2_2
+
+ In this example, the following VAT numbers will be considered for each company:
+ - main company: 123
+ - branch 1: 123
+ - branch 1_1: 123
+ - branch 2: 456
+ - branch 2_1: 789
+ - branch 2_2: 456
+
+ :param accessible_only: whether the returned companies should exclude companies that are not in self.env.companies
+ """
+ self.ensure_one()
+
+ current = self.sudo()
+ same_vat_branch_ids = [current.id] # Current is always available
+ current_strict_parents = current.parent_ids - current
+ if accessible_only:
+ candidate_branches = current.root_id._accessible_branches()
+ else:
+ candidate_branches = self.env['res.company'].sudo().search([('id', 'child_of', current.root_id.ids)])
+
+ current_vat_check_set = {current.vat} if current.vat else set()
+ for branch in candidate_branches - current:
+ parents_vat_set = set(filter(None, (branch.parent_ids - current_strict_parents).mapped('vat')))
+ if parents_vat_set == current_vat_check_set:
+ # If all the branches between the active company and branch (both included) share the same VAT number as the active company,
+ # we want to add the branch to the selection.
+ same_vat_branch_ids.append(branch.id)
+
+ return self.browse(same_vat_branch_ids)
diff --git a/dev_odex30_accounting/odex30_account_reports/models/res_config_settings.py b/dev_odex30_accounting/odex30_account_reports/models/res_config_settings.py
new file mode 100644
index 0000000..f589d33
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/res_config_settings.py
@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+
+from calendar import monthrange
+
+from odoo import api, fields, models, _
+from dateutil.relativedelta import relativedelta
+from odoo.tools.misc import format_date
+from odoo.tools import date_utils
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ totals_below_sections = fields.Boolean(related='company_id.totals_below_sections', string='Add totals below sections', readonly=False,
+ help='When ticked, totals and subtotals appear below the sections of the report.')
+ account_tax_periodicity = fields.Selection(related='company_id.account_tax_periodicity', string='Periodicity', readonly=False, required=True)
+ account_tax_periodicity_reminder_day = fields.Integer(related='company_id.account_tax_periodicity_reminder_day', string='Reminder', readonly=False, required=True)
+ account_tax_periodicity_journal_id = fields.Many2one(related='company_id.account_tax_periodicity_journal_id', string='Journal', readonly=False)
+
+ account_reports_show_per_company_setting = fields.Boolean(compute="_compute_account_reports_show_per_company_setting")
+
+ def open_tax_group_list(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Tax groups',
+ 'res_model': 'account.tax.group',
+ 'view_mode': 'list',
+ 'context': {
+ 'default_country_id': self.account_fiscal_country_id.id,
+ 'search_default_country_id': self.account_fiscal_country_id.id,
+ },
+ }
+
+ @api.depends('account_tax_periodicity', 'company_id', 'fiscalyear_last_day', 'fiscalyear_last_month')
+ def _compute_account_reports_show_per_company_setting(self):
+ custom_start_country_codes = self._get_country_codes_with_another_tax_closing_start_date()
+ countries = self.env['account.fiscal.position'].search([
+ ('company_id', '=', self.env.company.id),
+ ('foreign_vat', '!=', False),
+ ]).mapped('country_id') + self.env.company.account_fiscal_country_id
+ countries_to_always_show = bool(set(countries.mapped('code')) & custom_start_country_codes)
+ for config_settings in self:
+ if countries_to_always_show:
+ config_settings.account_reports_show_per_company_setting = True
+ else:
+ max_last_day = monthrange(fields.Date.today().year, int(config_settings.fiscalyear_last_month))[1]
+ if config_settings.account_tax_periodicity == 'monthly':
+ config_settings.account_reports_show_per_company_setting = max_last_day != config_settings.fiscalyear_last_day
+ else:
+ config_settings.account_reports_show_per_company_setting = config_settings.fiscalyear_last_month != '12' or config_settings.fiscalyear_last_day != max_last_day
+
+ def open_company_dependent_report_settings(self):
+ self.ensure_one()
+ generic_tax_report = self.env.ref('account.generic_tax_report')
+ available_reports = generic_tax_report._get_variants(generic_tax_report.id)
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Configure your start dates'),
+ 'res_model': 'account.report',
+ 'domain': [('id', 'in', available_reports.ids)],
+ 'views': [(self.env.ref('odex30_account_reports.account_report_tree_configure_start_dates').id, 'list')]
+ }
+
+ def _get_country_codes_with_another_tax_closing_start_date(self):
+ """
+ To be overridden by specific countries that wants this
+
+ Used to know which countries can have specific start dates settings on reports
+
+ :returns set(str): A set of country codes from which the start date settings should be shown
+ """
+ return set()
diff --git a/dev_odex30_accounting/odex30_account_reports/models/res_partner.py b/dev_odex30_accounting/odex30_account_reports/models/res_partner.py
new file mode 100644
index 0000000..f767469
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/models/res_partner.py
@@ -0,0 +1,104 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, _
+
+
+class ResPartner(models.Model):
+ _name = 'res.partner'
+ _inherit = 'res.partner'
+
+ account_represented_company_ids = fields.One2many('res.company', 'account_representative_id')
+
+ def _get_followup_responsible(self):
+ return self.env.user
+
+ def open_partner_ledger(self):
+ # Deprecated, will be removed in master
+ action = self.env["ir.actions.actions"]._for_xml_id("odex30_account_reports.action_account_report_partner_ledger")
+ action['params'] = {
+ 'options': {'partner_ids': self.ids, 'unfold_all': len(self.ids) == 1},
+ 'ignore_session': True,
+ }
+ return action
+
+ def open_customer_statement(self):
+ if not self.env.ref('odex30_account_reports.customer_statement_report', raise_if_not_found=False):
+ return self.open_partner_ledger()
+ action = self.env["ir.actions.actions"]._for_xml_id("odex30_account_reports.action_account_report_customer_statement")
+ action['params'] = {
+ 'options': {
+ 'partner_ids': (self | self.commercial_partner_id).ids,
+ 'unfold_all': len(self.ids) == 1,
+ },
+ 'ignore_session': True,
+ }
+ return action
+
+ def open_partner(self):
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'res.partner',
+ 'res_id': self.id,
+ 'views': [[False, 'form']],
+ 'view_mode': 'form',
+ 'target': 'current',
+ }
+
+ @api.depends_context('show_more_partner_info')
+ def _compute_display_name(self):
+ if not self.env.context.get('show_more_partner_info'):
+ return super()._compute_display_name()
+ for partner in self:
+ res = ""
+ if partner.vat:
+ res += f" {partner.vat},"
+ if partner.country_id:
+ res += f" {partner.country_id.code},"
+ partner.display_name = f"{partner.name} - " + res
+
+ def _get_partner_account_report_attachment(self, report, options=None):
+ self.ensure_one()
+ if self.lang:
+ # Print the followup in the customer's language
+ report = report.with_context(lang=self.lang)
+
+ if not options:
+ options = report.get_options({
+ 'forced_companies': self.env.company.search([('id', 'child_of', self.env.context.get('allowed_company_ids', self.env.company.id))]).ids,
+ 'partner_ids': self.ids,
+ 'unfold_all': True,
+ 'unreconciled': True,
+ # The following two options are Deprecated, will be removed in master
+ 'hide_account': True,
+ 'hide_debit_credit': True,
+ 'all_entries': False,
+ })
+ attachment_file = report.export_to_pdf(options)
+ return self.env['ir.attachment'].create([
+ {
+ 'name': f"{self.name} - {attachment_file['file_name']}",
+ 'res_model': self._name,
+ 'res_id': self.id,
+ 'type': 'binary',
+ 'raw': attachment_file['file_content'],
+ 'mimetype': 'application/pdf',
+ },
+ ])
+
+ def set_commercial_partner_main(self):
+ self.ensure_one()
+
+ main_partner = self
+ duplicated_partners = self.env['res.partner'].search([
+ ('vat', '=', main_partner.vat),
+ ('id', '!=', main_partner.id)
+ ])
+ # Update commercial partner of all duplicates
+ duplicated_partners.write({
+ 'is_company': False,
+ 'parent_id': main_partner.id,
+ 'type': 'invoice',
+ })
+ duplicated_partners_vat = self._context.get('duplicated_partners_vat', [])
+ remaining_vats = [pvat for pvat in duplicated_partners_vat if pvat != main_partner.vat]
+ return self.env['account.ec.sales.report.handler']._get_duplicated_vat_partners(remaining_vats)
diff --git a/dev_odex30_accounting/odex30_account_reports/security/ir.model.access.csv b/dev_odex30_accounting/odex30_account_reports/security/ir.model.access.csv
new file mode 100644
index 0000000..beaceee
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/security/ir.model.access.csv
@@ -0,0 +1,19 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_account_report_annotation_readonly,account.account_report_annotation_readonly,model_account_report_annotation,account.group_account_readonly,1,0,0,0
+access_account_report_annotation,account.account_report_annotation,model_account_report_annotation,account.group_account_user,1,1,1,1
+access_account_report_annotation_invoice,account.account_report_annotation,model_account_report_annotation,account.group_account_invoice,1,0,0,0
+access_account_reports_export_wizard,access.account_reports.export.wizard,model_account_reports_export_wizard,account.group_account_user,1,1,1,0
+access_account_reports_export_wizard_format,access.account_reports.export.wizard.format,model_account_reports_export_wizard_format,account.group_account_user,1,1,1,0
+access_account_report_file_download_error_wizard,account.report.file.download.error.wizard,model_account_report_file_download_error_wizard,account.group_account_user,1,1,1,0
+access_account_multicurrency_revaluation_wizard,access.account.multicurrency.revaluation.wizard,model_account_multicurrency_revaluation_wizard,account.group_account_user,1,1,1,0
+access_account_tax_unit_readonly,access_account_tax_unit_readonly,model_account_tax_unit,account.group_account_readonly,1,0,0,0
+access_account_tax_unit_manager,access_account_tax_unit_manager,model_account_tax_unit,account.group_account_manager,1,1,1,1
+access_account_report_horizontal_group_readonly,account.report.horizontal.group.readonly,model_account_report_horizontal_group,account.group_account_readonly,1,0,0,0
+access_account_report_horizontal_group_ac_user,account.report.horizontal.group.ac.user,model_account_report_horizontal_group,account.group_account_manager,1,1,1,1
+access_account_report_horizontal_group_rule_readonly,account.report.horizontal.group.rule.readonly,model_account_report_horizontal_group_rule,account.group_account_readonly,1,0,0,0
+access_account_report_horizontal_group_rule_ac_user,account.report.horizontal.group.rule.ac.user,model_account_report_horizontal_group_rule,account.group_account_manager,1,1,1,1
+access_account_report_budget_readonly,account.report.budget.readonly,model_account_report_budget,account.group_account_readonly,1,0,0,0
+access_account_report_budget_ac_user,account.report.budget.ac.user,model_account_report_budget,account.group_account_manager,1,1,1,1
+access_account_report_budget_item_readonly,account.report.budget.item.readonly,model_account_report_budget_item,account.group_account_readonly,1,0,0,0
+access_account_report_budget_item_ac_user,account.report.budget.item.ac.user,model_account_report_budget_item,account.group_account_manager,1,1,1,1
+access_account_report_send,access.account.report.send,model_account_report_send,account.group_account_invoice,1,1,1,1
diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/account_report.js b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/account_report.js
new file mode 100644
index 0000000..79b5307
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/account_report.js
@@ -0,0 +1,125 @@
+/** @odoo-module */
+
+import { registry } from "@web/core/registry";
+import { useService } from "@web/core/utils/hooks";
+import { ControlPanel } from "@web/search/control_panel/control_panel";
+
+import { Component, onWillStart, useRef, useState, useSubEnv } from "@odoo/owl";
+
+import { AccountReportController } from "@odex30_account_reports/components/account_report/controller";
+import { AccountReportButtonsBar } from "@odex30_account_reports/components/account_report/buttons_bar/buttons_bar";
+import { AccountReportCogMenu } from "@odex30_account_reports/components/account_report/cog_menu/cog_menu";
+import { AccountReportEllipsis } from "@odex30_account_reports/components/account_report/ellipsis/ellipsis";
+import { AccountReportFilters } from "@odex30_account_reports/components/account_report/filters/filters";
+import { AccountReportHeader } from "@odex30_account_reports/components/account_report/header/header";
+import { AccountReportLine } from "@odex30_account_reports/components/account_report/line/line";
+import { AccountReportLineCell } from "@odex30_account_reports/components/account_report/line_cell/line_cell";
+import { AccountReportLineName } from "@odex30_account_reports/components/account_report/line_name/line_name";
+import { AccountReportSearchBar } from "@odex30_account_reports/components/account_report/search_bar/search_bar";
+import { standardActionServiceProps } from "@web/webclient/actions/action_service";
+import { useSetupAction } from "@web/search/action_hook";
+
+
+export class AccountReport extends Component {
+ static template = "odex30_account_reports.AccountReport";
+ static props = { ...standardActionServiceProps };
+ static components = {
+ ControlPanel,
+ AccountReportButtonsBar,
+ AccountReportCogMenu,
+ AccountReportSearchBar,
+ };
+
+ static customizableComponents = [
+ AccountReportEllipsis,
+ AccountReportFilters,
+ AccountReportHeader,
+ AccountReportLine,
+ AccountReportLineCell,
+ AccountReportLineName,
+ ];
+ static defaultComponentsMap = [];
+
+ setup() {
+ this.rootRef = useRef("root");
+ useSetupAction({
+ rootRef: this.rootRef,
+ getLocalState: () => {
+ return {
+ keep_journal_groups_options: true, // used when using the breadcrumb
+ };
+ }
+ })
+ if (this.props?.state?.keep_journal_groups_options !== undefined) {
+ this.props.action.keep_journal_groups_options = true;
+ }
+
+ // Can not use 'control-panel-bottom-right' slot without this, as viewSwitcherEntries doesn't exist here.
+ this.env.config.viewSwitcherEntries = [];
+
+ this.orm = useService("orm");
+ this.actionService = useService("action");
+ this.controller = useState(new AccountReportController(this.props.action));
+ this.initialQuery = this.props.action.context.default_filter_accounts || '';
+
+ for (const customizableComponent of AccountReport.customizableComponents)
+ AccountReport.defaultComponentsMap[customizableComponent.name] = customizableComponent;
+
+ onWillStart(async () => {
+ await this.controller.load(this.env);
+ });
+
+ useSubEnv({
+ controller: this.controller,
+ component: this.getComponent.bind(this),
+ template: this.getTemplate.bind(this),
+ });
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // Custom overrides
+ // -----------------------------------------------------------------------------------------------------------------
+ static registerCustomComponent(customComponent) {
+ registry.category("account_reports_custom_components").add(customComponent.template, customComponent);
+ }
+
+ get cssCustomClass() {
+ return this.controller.data.custom_display.css_custom_class || "";
+ }
+
+ getComponent(name) {
+ const customComponents = this.controller.data.custom_display.components;
+
+ if (customComponents && customComponents[name])
+ return registry.category("account_reports_custom_components").get(customComponents[name]);
+
+ return AccountReport.defaultComponentsMap[name];
+ }
+
+ getTemplate(name) {
+ const customTemplates = this.controller.data.custom_display.templates;
+
+ if (customTemplates && customTemplates[name])
+ return customTemplates[name];
+
+ return `odex30_account_reports.${ name }Customizable`;
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // Table
+ // -----------------------------------------------------------------------------------------------------------------
+ get tableClasses() {
+ let classes = "";
+
+ if (this.controller.options.columns.length > 1) {
+ classes += " striped";
+ }
+
+ if (this.controller.options['horizontal_split'])
+ classes += " w-50 mx-2";
+
+ return classes;
+ }
+}
+
+registry.category("actions").add("account_report", AccountReport);
diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/account_report.scss b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/account_report.scss
new file mode 100644
index 0000000..1c6cfe1
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/account_report.scss
@@ -0,0 +1,465 @@
+.account_report {
+ .fit-content { width: fit-content }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Control panel
+ //------------------------------------------------------------------------------------------------------------------
+ .o_control_panel_main_buttons {
+ .dropdown-item {
+ padding: 0;
+ .btn-link {
+ width: 100%;
+ text-align: left;
+ padding: 3px 20px;
+ border-radius: 0;
+ }
+ }
+ }
+
+ .o_control_panel_breadcrumbs {
+ flex-basis: min-content;
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Sections
+ //------------------------------------------------------------------------------------------------------------------
+ .section_selector {
+ display: flex;
+ gap: 4px;
+ margin: 16px 16px 8px 16px;
+ justify-content: center;
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Alert
+ //------------------------------------------------------------------------------------------------------------------
+ .warnings { margin-bottom: 1rem }
+ .alert {
+ margin-bottom: 0;
+ border-radius: 0;
+
+ a:hover { cursor:pointer }
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // No content
+ //------------------------------------------------------------------------------------------------------------------
+ .o_view_nocontent { z-index: -1 }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Table
+ //------------------------------------------------------------------------------------------------------------------
+ .table {
+ background-color: $o-view-background-color;
+ border-collapse: separate; //!\\ Allows to add padding to the table
+ border-spacing: 0; //!\\ Removes default spacing between cells due to 'border-collapse: separate'
+ font-size: 0.8rem;
+ margin: 0 auto 24px;
+ padding: 24px;
+ width: auto;
+ min-width: 800px;
+ border: 1px solid $o-gray-300;
+ border-radius: 0.25rem;
+
+ > :not(caption) > * > * { padding: 0.25rem 0.75rem } //!\\ Override of bootstrap, keep selector
+
+ > thead {
+ > tr {
+ th:first-child {
+ color: lightgrey;
+ }
+ th:not(:first-child) {
+ text-align: center;
+ vertical-align: middle;
+ }
+ }
+ > tr:not(:last-child) > th:not(:first-child) { border: 1px solid $o-gray-300 }
+ }
+
+ > tbody {
+ > tr {
+ &.unfolded { font-weight: bold }
+ > td {
+ a { cursor: pointer }
+ .clickable { color: $o-enterprise-action-color }
+ &.muted { color: var(--AccountReport-muted-data-color, $o-gray-300) }
+ &:empty::after{ content: "\00a0" } //!\\ Prevents the collapse of empty table rows
+ &:empty { line-height: 1 }
+ .btn_annotation { color: $o-enterprise-action-color }
+ }
+
+ &:not(.empty) > td { border-bottom: 1px solid var(--AccountReport-fine-line-separator-color, $o-gray-200) }
+ &.total { font-weight: bold }
+ &.o_bold_tr { font-weight: bold }
+
+ &.unfolded {
+ > td { border-bottom: 1px solid $o-gray-300 }
+ .btn_action { opacity: 1 }
+ .btn_more { opacity: 1 }
+ }
+
+ &:hover {
+ &.empty > * { --table-accent-bg: transparent }
+ .auditable {
+ color: $o-enterprise-action-color !important;
+
+ > a:hover { cursor: pointer }
+ }
+ .muted { color: $o-gray-800 }
+ .btn_action, .btn_more {
+ opacity: 1;
+ color: $o-enterprise-action-color;
+ }
+ .btn_edit { color: $o-enterprise-action-color }
+ .btn_dropdown { color: $o-enterprise-action-color }
+ .btn_foldable { color: $o-enterprise-action-color }
+ .btn_ellipsis { color: $o-enterprise-action-color }
+ .btn_annotation_go { color: $o-enterprise-action-color }
+ .btn_debug { color: $o-enterprise-action-color }
+ }
+ }
+ }
+ }
+
+ table.striped {
+ //!\\ Changes the background of every even column starting with the 3rd one
+ > thead > tr:not(:first-child) > th:nth-child(2n+3) { background: $o-gray-100 }
+ > tbody {
+ > tr:not(.line_level_0):not(.empty) > td:nth-child(2n+3) { background: $o-gray-100 }
+ > tr.line_level_0 > td:nth-child(2n+3) { background: $o-gray-300 }
+ }
+ }
+
+ thead.sticky {
+ background-color: $o-view-background-color;
+ position: sticky;
+ top: 0;
+ z-index: 999;
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Line
+ //------------------------------------------------------------------------------------------------------------------
+ .line_name {
+ vertical-align: middle;
+ > .wrapper {
+ display: flex;
+ align-items: center;
+
+ > .content {
+ display: flex;
+ align-items: center;
+ sup { top: auto }
+ }
+ }
+
+ .name { white-space: nowrap }
+ &.unfoldable:hover { cursor: pointer }
+ }
+
+ .line_cell {
+ vertical-align: middle;
+ > .wrapper {
+ display: flex;
+ align-items: center;
+
+ > .content {
+ display: flex;
+ align-items: center;
+ }
+ }
+
+ &.date > .wrapper { justify-content: center }
+ &.numeric > .wrapper { justify-content: flex-end }
+ .name { white-space: nowrap }
+ }
+
+ .editable-cell {
+ input {
+ color: $o-enterprise-action-color;
+ border: none;
+ max-width: 100px;
+ float: right;
+
+ &:hover {
+ cursor: pointer;
+ }
+ }
+
+ &:hover {
+ cursor: pointer;
+ }
+
+ &:focus-within {
+ border-bottom-color: $o-enterprise-action-color !important;
+
+ input {
+ color: $o-black;
+ }
+ }
+ }
+
+ .line_level_0 {
+ color: $o-gray-700;
+ font-weight: bold;
+
+ > td {
+ border-bottom: 0 !important;
+ background-color: $o-gray-300;
+ }
+ .muted { color: $o-gray-400 !important }
+ .btn_debug { color: $o-gray-400 }
+ }
+
+ @for $i from 2 through 16 {
+ .line_level_#{$i} {
+ $indentation: (($i + 1) * 8px) - 20px; // 20px are for the btn_foldable width
+
+ > td {
+ color: $o-gray-700;
+
+ &.line_name.unfoldable .wrapper { column-gap: calc(#{ $indentation }) }
+ &.line_name:not(.unfoldable) .wrapper { padding-left: $indentation }
+ }
+ }
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Link
+ //------------------------------------------------------------------------------------------------------------------
+ .link { color: $o-enterprise-action-color }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // buttons
+ //------------------------------------------------------------------------------------------------------------------
+ .btn_debug, .btn_dropdown, .btn_foldable, .btn_foldable_empty, .btn_sortable, .btn_ellipsis,
+ .btn_more, .btn_annotation, .btn_annotation_go, .btn_annotation_delete, .btn_action, .btn_edit {
+ border: none;
+ color: $o-gray-300;
+ font-size: inherit;
+ font-weight: normal;
+ padding: 0;
+ text-align: center;
+ width: 20px;
+ white-space: nowrap;
+
+ &:hover {
+ color: $o-enterprise-action-color !important;
+ cursor: pointer;
+ }
+ }
+
+ .btn_sortable > .fa-long-arrow-up, .btn_sortable > .fa-long-arrow-down { color: $o-enterprise-action-color }
+ .btn_foldable { color: $o-gray-500 }
+ .btn_foldable_empty:hover { cursor: default }
+ .btn_ellipsis > i { vertical-align: bottom }
+ .btn_more { opacity: 1 }
+ .btn_annotation { margin-left: 6px }
+ .btn_annotation_go { color: $o-gray-600 }
+ .btn_annotation_delete {
+ margin-left: 4px;
+ vertical-align: baseline;
+ }
+ .btn_action {
+ opacity: 0;
+ background-color: $o-view-background-color;
+ color: $o-gray-600;
+ width: auto;
+ padding: 0 0.25rem;
+ margin: 0 0.25rem;
+ border: 1px solid $o-gray-300;
+ border-radius: 0.25rem;
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Dropdown
+ //------------------------------------------------------------------------------------------------------------------
+ .dropdown { display: inline }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Annotation
+ //------------------------------------------------------------------------------------------------------------------
+ .annotations {
+ border-top: 1px solid $o-gray-300;
+ font-size: 0.8rem;
+ padding: 24px 0;
+
+ > li {
+ line-height: 24px;
+ margin-left: 24px;
+ &:hover > button { color: $o-enterprise-action-color }
+ }
+ }
+}
+
+//----------------------------------------------------------------------------------------------------------------------
+// Dialogs
+//----------------------------------------------------------------------------------------------------------------------
+.account_report_annotation_dialog {
+ textarea {
+ border: 1px solid $o-gray-300;
+ border-radius: 0.25rem;
+ height: 120px;
+ padding: .5rem;
+ }
+}
+
+//----------------------------------------------------------------------------------------------------------------------
+// Popovers
+//----------------------------------------------------------------------------------------------------------------------
+.account_report_popover_edit {
+ padding: .5rem 1rem;
+ box-sizing: content-box;
+
+ .edit_popover_boolean label { padding: 0 12px 0 4px }
+
+ .edit_popover_string {
+ width: 260px;
+ padding: 8px;
+ border-color: $o-gray-200;
+ }
+
+ .btn {
+ color: $o-white;
+ background-color: $o-enterprise-action-color;
+ }
+}
+
+.account_report_popover_ellipsis {
+ > p {
+ float: left;
+ margin: 1rem;
+ width: 360px;
+ }
+}
+
+.account_report_btn_clone {
+ margin: 1rem 1rem 0 0;
+ border: none;
+ color: $o-gray-300;
+ font-size: inherit;
+ font-weight: normal;
+ padding: 0;
+ text-align: center;
+ width: 20px;
+
+ &:hover {
+ color: $o-enterprise-action-color !important;
+ cursor: pointer;
+ }
+}
+
+.account_report_popover_debug {
+ width: 350px;
+ overflow-x: auto;
+
+ > .line_debug {
+ display: flex;
+ flex-direction: row;
+ padding: .25rem 1rem;
+
+ &:first-child { padding-top: 1rem }
+ &:last-child { padding-bottom: 1rem }
+
+ > span:first-child {
+ color: $o-gray-600;
+ max-width: 25%; //!\\ Not the same as 'width' because of 'display: flex'
+ min-width: 25%; //!\\ Not the same as 'width' because of 'display: flex'
+ white-space: nowrap;
+ margin-right: 10px;
+ }
+ > span:last-child {
+ color: $o-gray-800;
+ max-width: 75%; //!\\ Not the same as 'width' because of 'display: flex'
+ min-width: 75%; //!\\ Not the same as 'width' because of 'display: flex'
+ }
+ }
+
+ > .totals_separator { margin: .25rem 1rem }
+ > .engine_separator { margin: 1rem }
+}
+
+.carryover_popover {
+ margin: 12px;
+ width: 300px;
+}
+
+.o_web_client:has(.annotation_popover) {
+
+ .popover:has(.annotation_tooltip) { visibility: hidden; }
+
+ .popover:has(.annotation_popover) {
+ max-height: 45%;
+ max-width: 60%;
+ white-space: pre-wrap;
+ overflow-y: auto;
+
+ .annotation_popover {
+ overflow: scroll;
+
+ .annotation_popover_line th{
+ background-color: $o-white;
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ }
+
+ }
+
+ .annotation_popover_line:nth-child(2n+2) { background: $o-gray-200; }
+
+ .annotation_popover_line {
+ .o_datetime_input {
+ border: none;
+ }
+ }
+
+ tr, th, td:not(:has(.btn_annotation_update)):not(:has(.btn_annotation_delete)) {
+ padding: .5rem 1rem .5rem .5rem;
+ vertical-align: top;
+ }
+
+ .annotation_popover_editable_cell {
+ background-color: transparent;
+ border: 0;
+ box-shadow: none;
+ color: $o-gray-700;
+ resize: none;
+ width: 85px;
+ outline: none;
+ }
+ }
+}
+
+label:focus-within input { border: 0; }
+
+.popover:has(.annotation_tooltip) {
+
+ > .tooltip-inner {
+ padding: 0;
+ color: $o-white;
+ background-color: $o-white;
+
+ > .annotation_tooltip {
+ color: $o-gray-700;
+ background-color: $o-white;
+ white-space: pre-wrap;
+
+ > .annotation_tooltip_line:nth-child(2n+2) { background: $o-gray-200; }
+
+ tr, th, td {
+ padding: .25rem .5rem .25rem .25rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ vertical-align: top;
+ }
+ }
+
+ + .popover-arrow {
+ &.top-0::after { border-right-color: $o-white; }
+ &.bottom-0::after { border-left-color: $o-white; }
+ &.start-0::after { border-bottom-color: $o-white; }
+ &.end-0::after { border-top-color: $o-white; }
+ }
+ }
+}
diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/account_report.xml b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/account_report.xml
new file mode 100644
index 0000000..cb3a036
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/account_report.xml
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No data to display !
+
There is no data to display for the given filters.
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/buttons_bar/buttons_bar.js b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/buttons_bar/buttons_bar.js
new file mode 100644
index 0000000..a3c1e7b
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/buttons_bar/buttons_bar.js
@@ -0,0 +1,27 @@
+/** @odoo-module */
+
+import { Component, useState } from "@odoo/owl";
+
+export class AccountReportButtonsBar extends Component {
+ static template = "odex30_account_reports.AccountReportButtonsBar";
+ static props = {};
+
+ setup() {
+ this.controller = useState(this.env.controller);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Buttons
+ //------------------------------------------------------------------------------------------------------------------
+ get barButtons() {
+ const buttons = [];
+
+ for (const button of this.controller.buttons) {
+ if (button.always_show) {
+ buttons.push(button);
+ }
+ }
+
+ return buttons;
+ }
+}
diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/buttons_bar/buttons_bar.xml b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/buttons_bar/buttons_bar.xml
new file mode 100644
index 0000000..47a5501
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/buttons_bar/buttons_bar.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/cog_menu/cog_menu.js b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/cog_menu/cog_menu.js
new file mode 100644
index 0000000..45d262d
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/cog_menu/cog_menu.js
@@ -0,0 +1,31 @@
+/** @odoo-module **/
+
+import {Component, useState} from "@odoo/owl";
+import { Dropdown } from "@web/core/dropdown/dropdown";
+import { DropdownItem } from "@web/core/dropdown/dropdown_item";
+
+
+export class AccountReportCogMenu extends Component {
+ static template = "odex30_account_reports.AccountReportCogMenu";
+ static components = {Dropdown, DropdownItem};
+ static props = {};
+
+ setup() {
+ this.controller = useState(this.env.controller);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Buttons
+ //------------------------------------------------------------------------------------------------------------------
+ get cogButtons() {
+ const buttons = [];
+
+ for (const button of this.controller.buttons) {
+ if (!button.always_show) {
+ buttons.push(button);
+ }
+ }
+
+ return buttons;
+ }
+}
diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/cog_menu/cog_menu.xml b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/cog_menu/cog_menu.xml
new file mode 100644
index 0000000..3efa3b1
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/cog_menu/cog_menu.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/controller.js b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/controller.js
new file mode 100644
index 0000000..1d9623a
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/controller.js
@@ -0,0 +1,820 @@
+/* global owl:readonly */
+
+import { browser } from "@web/core/browser/browser";
+import { session } from "@web/session";
+import { useService } from "@web/core/utils/hooks";
+
+import { removeTaxGroupingFromLineId } from "@odex30_account_reports/js/util";
+
+export class AccountReportController {
+ constructor(action) {
+ this.action = action;
+ this.actionService = useService("action");
+ this.dialog = useService("dialog");
+ this.orm = useService("orm");
+ }
+
+ async load(env) {
+ this.env = env;
+ this.reportOptionsMap = {};
+ this.reportInformationMap = {};
+ this.lastOpenedSectionByReport = {};
+ this.loadingCallNumberByCacheKey = new Proxy(
+ {},
+ {
+ get(target, name) {
+ return name in target ? target[name] : 0;
+ },
+ set(target, name, newValue) {
+ target[name] = newValue;
+ return true;
+ },
+ }
+ );
+ this.actionReportId = this.action.context.report_id;
+ const isOpeningReport = !this.action?.keep_journal_groups_options // true when opening the report, except when coming from the breadcrumb
+ const mainReportOptions = await this.loadReportOptions(this.actionReportId, false, this.action.params?.ignore_session, isOpeningReport);
+ const cacheKey = this.getCacheKey(mainReportOptions['sections_source_id'], mainReportOptions['report_id']);
+
+ // We need the options to be set and saved in order for the loading to work properly
+ this.options = mainReportOptions;
+ this.reportOptionsMap[cacheKey] = mainReportOptions;
+ this.incrementCallNumber(cacheKey);
+ this.options["loading_call_number"] = this.loadingCallNumberByCacheKey[cacheKey];
+ this.saveSessionOptions(mainReportOptions);
+
+ const activeSectionPromise = this.displayReport(mainReportOptions['report_id']);
+ this.preLoadClosedSections();
+ await activeSectionPromise;
+ }
+
+ getCacheKey(sectionsSourceId, reportId) {
+ return `${sectionsSourceId}_${reportId}`
+ }
+
+ incrementCallNumber(cacheKey = null) {
+ if (!cacheKey) {
+ cacheKey = this.getCacheKey(this.options['sections_source_id'], this.options['report_id']);
+ }
+ this.loadingCallNumberByCacheKey[cacheKey] += 1;
+ }
+
+ async displayReport(reportId) {
+ const cacheKey = await this.loadReport(reportId);
+ const options = await this.reportOptionsMap[cacheKey];
+ const informationMap = await this.reportInformationMap[cacheKey];
+ if (
+ options !== undefined
+ && this.loadingCallNumberByCacheKey[cacheKey] === options["loading_call_number"]
+ && (this.lastOpenedSectionByReport === {} || this.lastOpenedSectionByReport[options['selected_variant_id']] === options['selected_section_id'])
+ ) {
+ // the options gotten from the python correspond to the ones that called this displayReport
+ this.options = options;
+
+ // informationMap might be undefined if the promise has been deleted by another call.
+ // Don't need to set data, the call that deleted it is coming to re-put data
+ if (informationMap !== undefined) {
+ this.data = informationMap;
+ // If there is a specific order for lines in the options, we want to use it by default
+ if (this.areLinesOrdered()) {
+ await this.sortLines();
+ }
+ this.setLineVisibility(this.lines);
+ this.refreshVisibleAnnotations();
+ this.saveSessionOptions(this.options);
+ }
+
+ }
+ }
+
+ async reload(optionPath, newOptions) {
+ const rootOptionKey = optionPath ? optionPath.split(".")[0] : "";
+
+ /*
+ When reloading the UI after setting an option filter, invalidate the cached options and data of all sections supporting this filter.
+ This way, those sections will be reloaded (either synchronously when the user tries to access them or asynchronously via the preloading
+ feature), and will then use the new filter value. This ensures the filters are always applied consistently to all sections.
+ */
+ for (const [cacheKey, cachedOptionsPromise] of Object.entries(this.reportOptionsMap)) {
+ let cachedOptions = await cachedOptionsPromise;
+
+ if (rootOptionKey === "" || cachedOptions.hasOwnProperty(rootOptionKey)) {
+ delete this.reportOptionsMap[cacheKey];
+ delete this.reportInformationMap[cacheKey];
+ }
+ }
+
+ this.saveSessionOptions(newOptions); // The new options will be loaded from the session. Saving them now ensures the new filter is taken into account.
+ await this.displayReport(newOptions['report_id']);
+ }
+
+ async preLoadClosedSections() {
+ let sectionLoaded = false;
+ for (const section of this.options['sections']) {
+ // Preload the first non-loaded section we find amongst this report's sections.
+ const cacheKey = this.getCacheKey(this.options['sections_source_id'], section.id);
+ if (section.id != this.options['report_id'] && !this.reportInformationMap[cacheKey]) {
+ await this.loadReport(section.id, true);
+
+ sectionLoaded = true;
+ // Stop iterating and schedule next call. We don't go on in the loop in case the cache is reset and we need to restart preloading.
+ break;
+ }
+ }
+
+ let nextCallDelay = (sectionLoaded) ? 100 : 1000;
+
+ const self = this;
+ setTimeout(() => self.preLoadClosedSections(), nextCallDelay);
+ }
+
+ async loadReport(reportId, preloading=false) {
+ const options = await this.loadReportOptions(reportId, preloading, false); // This also sets the promise in the cache
+ const reportToDisplayId = options['report_id']; // Might be different from reportId, in case the report to open uses sections
+
+ const cacheKey = this.getCacheKey(options['sections_source_id'], reportToDisplayId)
+ if (!this.reportInformationMap[cacheKey]) {
+ this.reportInformationMap[cacheKey] = this.orm.call(
+ "account.report",
+ options.readonly_query ? "get_report_information_readonly" : "get_report_information",
+ [
+ reportToDisplayId,
+ options,
+ ],
+ {
+ context: this.action.context,
+ },
+ );
+ }
+
+ await this.reportInformationMap[cacheKey];
+
+ if (!preloading) {
+ if (options['sections'].length)
+ this.lastOpenedSectionByReport[options['sections_source_id']] = options['selected_section_id'];
+ }
+
+ return cacheKey;
+ }
+
+ async loadReportOptions(reportId, preloading=false, ignore_session=false, isOpeningReport=false) {
+ const loadOptions = (ignore_session || !this.hasSessionOptions()) ? (this.action.params?.options || {}) : this.sessionOptions();
+ const cacheKey = this.getCacheKey(loadOptions['sections_source_id'] || reportId, reportId);
+
+ if (!(cacheKey in this.loadingCallNumberByCacheKey)) {
+ this.incrementCallNumber(cacheKey);
+ }
+ loadOptions["loading_call_number"] = this.loadingCallNumberByCacheKey[cacheKey];
+
+ loadOptions["is_opening_report"] = isOpeningReport;
+
+ if (!this.reportOptionsMap[cacheKey]) {
+ // The options for this section are not loaded nor loading. Let's load them !
+
+ if (preloading)
+ loadOptions['selected_section_id'] = reportId;
+ else {
+ /* Reopen the last opened section by default (cannot be done through regular caching, because composite reports' options are not
+ cached (since they always reroute). */
+ if (this.lastOpenedSectionByReport[reportId])
+ loadOptions['selected_section_id'] = this.lastOpenedSectionByReport[reportId];
+ }
+
+ this.reportOptionsMap[cacheKey] = this.orm.call(
+ "account.report",
+ "get_options",
+ [
+ reportId,
+ loadOptions,
+ ],
+ {
+ context: this.action.context,
+ },
+ );
+
+ // Wait for the result, and check the report hasn't been rerouted to a section or variant; fix the cache if it has
+ let reportOptions = await this.reportOptionsMap[cacheKey];
+
+ // In case of a reroute, also set the cached options into the reroute target's key
+ const loadedOptionsCacheKey = this.getCacheKey(reportOptions['sections_source_id'], reportOptions['report_id']);
+ if (loadedOptionsCacheKey !== cacheKey) {
+ /* We delete the rerouting report from the cache, to avoid redoing this reroute when reloading the cached options, as it would mean
+ route reports can never be opened directly if they open some variant by default.*/
+ delete this.reportOptionsMap[cacheKey];
+ this.reportOptionsMap[loadedOptionsCacheKey] = reportOptions;
+
+ this.loadingCallNumberByCacheKey[loadedOptionsCacheKey] = 1;
+ delete this.loadingCallNumberByCacheKey[cacheKey];
+ return reportOptions;
+ }
+ }
+
+ return this.reportOptionsMap[cacheKey];
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Generic data getters
+ //------------------------------------------------------------------------------------------------------------------
+ get buttons() {
+ return this.options.buttons;
+ }
+
+ get caretOptions() {
+ return this.data.caret_options;
+ }
+
+ get columnHeadersRenderData() {
+ return this.data.column_headers_render_data;
+ }
+
+ get columnGroupsTotals() {
+ return this.data.column_groups_totals;
+ }
+
+ get context() {
+ return this.data.context;
+ }
+
+ get filters() {
+ return this.data.filters;
+ }
+
+ get annotations() {
+ return this.data.annotations;
+ }
+
+ get groups() {
+ return this.data.groups;
+ }
+
+ get lines() {
+ return this.data.lines;
+ }
+
+ get warnings() {
+ return this.data.warnings;
+ }
+
+ get linesOrder() {
+ return this.data.lines_order;
+ }
+
+ get report() {
+ return this.data.report;
+ }
+
+ get visibleAnnotations() {
+ return this.data.visible_annotations;
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Generic data setters
+ //------------------------------------------------------------------------------------------------------------------
+ set annotations(value) {
+ this.data.annotations = value;
+ }
+
+ set columnGroupsTotals(value) {
+ this.data.column_groups_totals = value;
+ }
+
+ set lines(value) {
+ this.data.lines = value;
+ this.setLineVisibility(this.lines);
+ }
+
+ set linesOrder(value) {
+ this.data.lines_order = value;
+ }
+
+ set visibleAnnotations(value) {
+ this.data.visible_annotations = value;
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Helpers
+ //------------------------------------------------------------------------------------------------------------------
+ get needsColumnPercentComparison() {
+ return this.options.column_percent_comparison === "growth";
+ }
+
+ get hasCustomSubheaders() {
+ return this.columnHeadersRenderData.custom_subheaders.length > 0;
+ }
+
+ get hasDebugColumn() {
+ return Boolean(this.options.show_debug_column);
+ }
+
+ get hasStringDate() {
+ return "date" in this.options && "string" in this.options.date;
+ }
+
+ get hasVisibleAnnotations() {
+ return Boolean(this.visibleAnnotations.length);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Options
+ //------------------------------------------------------------------------------------------------------------------
+ async _updateOption(operationType, optionPath, optionValue=null, reloadUI=false) {
+ const optionKeys = optionPath.split(".");
+
+ let currentOptionKey = null;
+ let option = this.options;
+
+ while (optionKeys.length > 1) {
+ currentOptionKey = optionKeys.shift();
+ option = option[currentOptionKey];
+
+ if (option === undefined)
+ throw new Error(`Invalid option key in _updateOption(): ${ currentOptionKey } (${ optionPath })`);
+ }
+
+ switch (operationType) {
+ case "update":
+ option[optionKeys[0]] = optionValue;
+ break;
+ case "delete":
+ delete option[optionKeys[0]];
+ break;
+ case "toggle":
+ option[optionKeys[0]] = !option[optionKeys[0]];
+ break;
+ default:
+ throw new Error(`Invalid operation type in _updateOption(): ${ operationType }`);
+ }
+
+ if (reloadUI) {
+ this.incrementCallNumber();
+ await this.reload(optionPath, this.options);
+ }
+ }
+
+ async updateOption(optionPath, optionValue, reloadUI=false) {
+ await this._updateOption('update', optionPath, optionValue, reloadUI);
+ }
+
+ async deleteOption(optionPath, reloadUI=false) {
+ await this._updateOption('delete', optionPath, null, reloadUI);
+ }
+
+ async toggleOption(optionPath, reloadUI=false) {
+ await this._updateOption('toggle', optionPath, null, reloadUI);
+ }
+
+ async switchToSection(reportId) {
+ this.saveSessionOptions({...this.options, 'selected_section_id': reportId});
+ this.displayReport(reportId);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Session options
+ //------------------------------------------------------------------------------------------------------------------
+ sessionOptionsID() {
+ /* Options are stored by action report (so, the report that was targetted by the original action triggering this flow).
+ This allows a more intelligent reloading of the previous options during user navigation (especially concerning sections and variants;
+ you expect your report to open by default the same section as last time you opened it in this http session).
+ */
+ return `account.report:${ this.actionReportId }:${ session.user_companies.current_company }`;
+ }
+
+ hasSessionOptions() {
+ return Boolean(browser.sessionStorage.getItem(this.sessionOptionsID()))
+ }
+
+ saveSessionOptions(options) {
+ browser.sessionStorage.setItem(this.sessionOptionsID(), JSON.stringify(options));
+ }
+
+ sessionOptions() {
+ return JSON.parse(browser.sessionStorage.getItem(this.sessionOptionsID()));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Lines
+ //------------------------------------------------------------------------------------------------------------------
+ lineHasDebugData(lineIndex) {
+ return 'debug_popup_data' in this.lines[lineIndex];
+ }
+
+ lineHasGrowthComparisonData(lineIndex) {
+ return Boolean(this.lines[lineIndex].column_percent_comparison_data);
+ }
+
+ isLineAncestorOf(ancestorLineId, lineId) {
+ return lineId.startsWith(`${ancestorLineId}|`);
+ }
+
+ isLineChildOf(childLineId, lineId) {
+ return childLineId.startsWith(`${lineId}|`);
+ }
+
+ isLineRelatedTo(relatedLineId, lineId) {
+ return this.isLineAncestorOf(relatedLineId, lineId) || this.isLineChildOf(relatedLineId, lineId);
+ }
+
+ isNextLineChild(index, lineId) {
+ return index < this.lines.length && this.lines[index].id.startsWith(`${lineId}|`);
+ }
+
+ isNextLineDirectChild(index, lineId) {
+ return index < this.lines.length && this.lines[index].parent_id === lineId;
+ }
+
+ isTotalLine(lineIndex) {
+ return this.lines[lineIndex].id.includes("|total~~");
+ }
+
+ isLoadMoreLine(lineIndex) {
+ return this.lines[lineIndex].id.includes("|load_more~~");
+ }
+
+ isLoadedLine(lineIndex) {
+ const lineID = this.lines[lineIndex].id;
+ const nextLineIndex = lineIndex + 1;
+
+ return this.isNextLineChild(nextLineIndex, lineID) && !this.isTotalLine(nextLineIndex) && !this.isLoadMoreLine(nextLineIndex);
+ }
+
+ async replaceLineWith(replaceIndex, newLines) {
+ await this.insertLines(replaceIndex, 1, newLines);
+ }
+
+ async insertLinesAfter(insertIndex, newLines) {
+ await this.insertLines(insertIndex + 1, 0, newLines);
+ }
+
+ async insertLines(lineIndex, deleteCount, newLines) {
+ this.lines.splice(lineIndex, deleteCount, ...newLines);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Unfolded/Folded lines
+ //------------------------------------------------------------------------------------------------------------------
+ async unfoldLoadedLine(lineIndex) {
+ const lineId = this.lines[lineIndex].id;
+ let nextLineIndex = lineIndex + 1;
+
+ while (this.isNextLineChild(nextLineIndex, lineId)) {
+ if (this.isNextLineDirectChild(nextLineIndex, lineId)) {
+ const nextLine = this.lines[nextLineIndex];
+ nextLine.visible = true;
+ if (!nextLine.unfoldable && this.isNextLineChild(nextLineIndex + 1, nextLine.id)) {
+ await this.unfoldLine(nextLineIndex);
+ }
+ }
+ nextLineIndex += 1;
+ }
+ return nextLineIndex;
+ }
+
+ async unfoldNewLine(lineIndex) {
+ const options = await this.options;
+ const newLines = await this.orm.call(
+ "account.report",
+ options.readonly_query ? "get_expanded_lines_readonly" : "get_expanded_lines",
+ [
+ this.options['report_id'],
+ this.options,
+ this.lines[lineIndex].id,
+ this.lines[lineIndex].groupby,
+ this.lines[lineIndex].expand_function,
+ this.lines[lineIndex].progress,
+ 0,
+ this.lines[lineIndex].horizontal_split_side,
+ ],
+ );
+
+ if (this.areLinesOrdered()) {
+ this.updateLinesOrderIndexes(lineIndex, newLines, false)
+ }
+ this.insertLinesAfter(lineIndex, newLines);
+
+ const totalIndex = lineIndex + newLines.length + 1;
+
+ if (this.filters.show_totals && this.lines[totalIndex] && this.isTotalLine(totalIndex))
+ this.lines[totalIndex].visible = true;
+
+ // Update options
+ this.options.unfolded_lines.push(
+ ...newLines.filter(line => line.unfolded).map(({ id }) => id)
+ );
+
+ this.saveSessionOptions(this.options);
+
+ return totalIndex
+ }
+
+ /**
+ * When unfolding a line of a sorted report, we need to update the linesOrder array by adding the new lines,
+ * which will require subsequent updates on the array.
+ *
+ * - lineOrderValue represents the line index before sorting the report.
+ * @param {Integer} lineIndex: Index of the current line
+ * @param {Array} newLines: Array of lines to be added
+ * @param {Boolean} replaceLine: Useful for the splice of the linesOrder array in case we want to replace some line
+ * example: With the load more, we want to replace the line with others
+ **/
+ updateLinesOrderIndexes(lineIndex, newLines, replaceLine) {
+ let unfoldedLineIndex;
+ // The offset is useful because in case we use 'replaceLineWith' we want to replace the line at index
+ // unfoldedLineIndex with the new lines.
+ const offset = replaceLine ? 0 : 1;
+ for (const [lineOrderIndex, lineOrderValue] of Object.entries(this.linesOrder)) {
+ // Since we will have to add new lines into the linesOrder array, we have to update the index of the lines
+ // having a bigger index than the one we will unfold.
+ // deleteCount of 1 means that a line need to be replaced so the index need to be increase by 1 less than usual
+ if (lineOrderValue > lineIndex) {
+ this.linesOrder[lineOrderIndex] += newLines.length - replaceLine;
+ }
+ // The unfolded line is found, providing a reference for adding children in the 'linesOrder' array.
+ if (lineOrderValue === lineIndex) {
+ unfoldedLineIndex = parseInt(lineOrderIndex)
+ }
+ }
+
+ const arrayOfNewIndex = Array.from({ length: newLines.length }, (dummy, index) => this.linesOrder[unfoldedLineIndex] + index + offset);
+ this.linesOrder.splice(unfoldedLineIndex + offset, replaceLine, ...arrayOfNewIndex);
+ }
+
+ async unfoldLine(lineIndex) {
+ const targetLine = this.lines[lineIndex];
+ let lastLineIndex = lineIndex + 1;
+
+ if (this.isLoadedLine(lineIndex))
+ lastLineIndex = await this.unfoldLoadedLine(lineIndex);
+ else if (targetLine.expand_function) {
+ lastLineIndex = await this.unfoldNewLine(lineIndex);
+ }
+
+ this.setLineVisibility(this.lines.slice(lineIndex + 1, lastLineIndex));
+ targetLine.unfolded = true;
+ this.refreshVisibleAnnotations();
+
+ // Update options
+ if (!this.options.unfolded_lines.includes(targetLine.id))
+ this.options.unfolded_lines.push(targetLine.id);
+
+ this.saveSessionOptions(this.options);
+ }
+
+ foldLine(lineIndex) {
+ const targetLine = this.lines[lineIndex];
+
+ let foldedLinesIDs = new Set([targetLine.id]);
+ let nextLineIndex = lineIndex + 1;
+
+ while (this.isNextLineChild(nextLineIndex, targetLine.id)) {
+ this.lines[nextLineIndex].unfolded = false;
+ this.lines[nextLineIndex].visible = false;
+
+ foldedLinesIDs.add(this.lines[nextLineIndex].id);
+
+ nextLineIndex += 1;
+ }
+
+ targetLine.unfolded = false;
+
+ this.refreshVisibleAnnotations();
+
+ // Update options
+ this.options.unfolded_lines = this.options.unfolded_lines.filter(
+ unfoldedLineID => !foldedLinesIDs.has(unfoldedLineID)
+ );
+
+ this.saveSessionOptions(this.options);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Ordered lines
+ //------------------------------------------------------------------------------------------------------------------
+ linesCurrentOrderByColumn(columnIndex) {
+ if (this.areLinesOrderedByColumn(columnIndex))
+ return this.options.order_column.direction;
+
+ return "default";
+ }
+
+ areLinesOrdered() {
+ return this.linesOrder != null && this.options.order_column != null;
+ }
+
+ areLinesOrderedByColumn(columnIndex) {
+ return this.areLinesOrdered() && this.options.order_column.expression_label === this.options.columns[columnIndex].expression_label;
+ }
+
+ async sortLinesByColumnAsc(columnIndex) {
+ this.options.order_column = {
+ expression_label: this.options.columns[columnIndex].expression_label,
+ direction: "ASC",
+ };
+
+ await this.sortLines();
+ this.saveSessionOptions(this.options);
+ }
+
+ async sortLinesByColumnDesc(columnIndex) {
+ this.options.order_column = {
+ expression_label: this.options.columns[columnIndex].expression_label,
+ direction: "DESC",
+ };
+
+ await this.sortLines();
+ this.saveSessionOptions(this.options);
+ }
+
+ sortLinesByDefault() {
+ delete this.options.order_column;
+ delete this.data.lines_order;
+
+ this.saveSessionOptions(this.options);
+ }
+
+ async sortLines() {
+ this.linesOrder = await this.orm.call(
+ "account.report",
+ "sort_lines",
+ [
+ this.lines,
+ this.options,
+ true,
+ ],
+ {
+ context: this.action.context,
+ },
+ );
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Annotations
+ //------------------------------------------------------------------------------------------------------------------
+ async refreshAnnotations() {
+ this.annotations = await this.orm.call("account.report", "get_annotations", [
+ this.action.context.report_id,
+ this.options,
+ ]);
+
+ this.refreshVisibleAnnotations();
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Visibility
+ //------------------------------------------------------------------------------------------------------------------
+
+ refreshVisibleAnnotations() {
+ const visibleAnnotations = new Proxy(
+ {},
+ {
+ get(target, name) {
+ return name in target ? target[name] : [];
+ },
+ set(target, name, newValue) {
+ target[name] = newValue;
+ return true;
+ },
+ }
+ );
+
+ this.lines.forEach((line) => {
+ line["visible_annotations"] = [];
+ const lineWithoutTaxGrouping = removeTaxGroupingFromLineId(line.id);
+ if (line.visible && this.annotations[lineWithoutTaxGrouping]) {
+ for (const index in this.annotations[lineWithoutTaxGrouping]) {
+ const annotation = this.annotations[lineWithoutTaxGrouping][index];
+ visibleAnnotations[lineWithoutTaxGrouping] = [
+ ...visibleAnnotations[lineWithoutTaxGrouping],
+ { ...annotation },
+ ];
+ line["visible_annotations"].push({
+ ...annotation,
+ });
+ }
+ }
+
+ if (
+ line.visible_annotations &&
+ (!this.annotations[lineWithoutTaxGrouping] || !line.visible)
+ ) {
+ delete line.visible_annotations;
+ }
+ });
+
+ this.visibleAnnotations = visibleAnnotations;
+ }
+
+ /**
+ Defines which lines should be visible in the provided list of lines (depending on what is folded).
+ **/
+ setLineVisibility(linesToAssign) {
+ let needHidingChildren = new Set();
+
+ linesToAssign.forEach((line) => {
+ line.visible = !needHidingChildren.has(line.parent_id);
+
+ if (!line.visible || (line.unfoldable &! line.unfolded))
+ needHidingChildren.add(line.id);
+ });
+
+ // If the hide 0 lines is activated we will go through the lines to set the visibility.
+ if (this.options.hide_0_lines) {
+ this.hideZeroLines(linesToAssign);
+ }
+ }
+
+ /**
+ * Defines whether the line should be visible depending on its value and the ones of its children.
+ * For parent lines, it's visible if there is at least one child with a value different from zero
+ * or if a child is visible, indicating it's a parent line.
+ * For leaf nodes, it's visible if the value is different from zero.
+ *
+ * By traversing the 'lines' array in reverse, we can set the visibility of the lines easily by keeping
+ * a dict of visible lines for each parent.
+ *
+ * @param {Object} lines - The lines for which we want to determine visibility.
+ */
+ hideZeroLines(lines) {
+ const hasVisibleChildren = new Set();
+ const reversed_lines = [...lines].reverse()
+
+ const number_figure_types = ['integer', 'float', 'monetary', 'percentage'];
+ reversed_lines.forEach((line) => {
+ const isZero = line.columns.every(column => !number_figure_types.includes(column.figure_type) || column.is_zero);
+
+ // If the line has no visible children and all the columns are equals to zero then the line needs to be hidden
+ if (!hasVisibleChildren.has(line.id) && isZero) {
+ line.visible = false;
+ }
+
+ // If the line has a parent_id and is not hidden then we fill the set 'hasVisibleChildren'. Each parent
+ // will have an array of his visible children
+ if (line.parent_id && line.visible) {
+ // This line allows the initialization of that list.
+ hasVisibleChildren.add(line.parent_id);
+ }
+ })
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Server calls
+ //------------------------------------------------------------------------------------------------------------------
+ buttonAction(ev, button) {
+ // Might be overidden to add specific functionality to button
+ // For instance adding context to a call ...
+ this.reportAction(ev, button.error_action || button.action, button.action_param, true);
+ }
+
+ async reportAction(ev, action, actionParam = null, callOnSectionsSource = false, actionContext=null) {
+ // 'ev' might be 'undefined' if event is not triggered from a button/anchor
+ ev?.preventDefault();
+ ev?.stopPropagation();
+
+ let actionOptions = this.options;
+ if (callOnSectionsSource) {
+ // When calling the sections source, we want to keep track of all unfolded lines of all sections
+ const allUnfoldedLines = this.options.sections.length ? [] : [...this.options['unfolded_lines']]
+
+ for (const sectionData of this.options['sections']) {
+ const cacheKey = this.getCacheKey(this.options['sections_source_id'], sectionData['id']);
+ const sectionOptions = await this.reportOptionsMap[cacheKey];
+ if (sectionOptions)
+ allUnfoldedLines.push(...sectionOptions['unfolded_lines']);
+ }
+
+ actionOptions = {...this.options, unfolded_lines: allUnfoldedLines};
+ }
+
+ const dispatchReportAction = await this.orm.call(
+ "account.report",
+ "dispatch_report_action",
+ [
+ this.options['report_id'],
+ actionOptions,
+ action,
+ actionParam,
+ callOnSectionsSource,
+ ],
+ {
+ context: Object.assign({}, this.context, actionContext)
+ }
+ );
+ if (dispatchReportAction?.help) {
+ dispatchReportAction.help = owl.markup(dispatchReportAction.help)
+ }
+
+ return dispatchReportAction ? this.actionService.doAction(dispatchReportAction) : null;
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // Budget
+ // -----------------------------------------------------------------------------------------------------------------
+
+ async openBudget(budget) {
+ this.actionService.doAction({
+ type: "ir.actions.act_window",
+ res_model: "account.report.budget",
+ res_id: budget.id,
+ views: [[false, "form"]],
+ });
+ }
+}
diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/ellipsis/ellipsis.js b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/ellipsis/ellipsis.js
new file mode 100644
index 0000000..c448c09
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/ellipsis/ellipsis.js
@@ -0,0 +1,64 @@
+/** @odoo-module **/
+
+import { _t } from "@web/core/l10n/translation";
+import { localization } from "@web/core/l10n/localization";
+import { useService } from "@web/core/utils/hooks";
+import { Component, useState } from "@odoo/owl";
+
+import { AccountReportEllipsisPopover } from "@odex30_account_reports/components/account_report/ellipsis/popover/ellipsis_popover";
+
+export class AccountReportEllipsis extends Component {
+ static template = "odex30_account_reports.AccountReportEllipsis";
+ static props = {
+ name: { type: String, optional: true },
+ no_format: { optional: true },
+ type: { type: String, optional: true },
+ maxCharacters: Number,
+ };
+
+ setup() {
+ this.popover = useService("popover");
+ this.notification = useService("notification");
+ this.controller = useState(this.env.controller);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Ellipsis
+ //------------------------------------------------------------------------------------------------------------------
+ get triggersEllipsis() {
+ if (this.props.name)
+ return this.props.name.length > this.props.maxCharacters;
+
+ return false;
+ }
+
+ copyEllipsisText() {
+ navigator.clipboard.writeText(this.props.name);
+ this.notification.add(_t("Text copied"), { type: 'success' });
+ this.popoverCloseFn();
+ this.popoverCloseFn = null;
+ }
+
+ showEllipsisPopover(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ if (this.popoverCloseFn) {
+ this.popoverCloseFn();
+ this.popoverCloseFn = null;
+ }
+
+ this.popoverCloseFn = this.popover.add(
+ ev.currentTarget,
+ AccountReportEllipsisPopover,
+ {
+ name: this.props.name,
+ copyEllipsisText: this.copyEllipsisText.bind(this),
+ },
+ {
+ closeOnClickAway: true,
+ position: localization.direction === "rtl" ? "left" : "right",
+ },
+ );
+ }
+}
diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/ellipsis/ellipsis.xml b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/ellipsis/ellipsis.xml
new file mode 100644
index 0000000..ffc0798
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/ellipsis/ellipsis.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/warnings.xml b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/warnings.xml
new file mode 100644
index 0000000..f887afe
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/warnings.xml
@@ -0,0 +1,17 @@
+
+
+
+ This company is part of a tax unit. You're currently not viewing the whole unit.
+
+
+
+ There are
+ unposted Journal Entries
+ prior or included in this period.
+
+
+
+ This report uses the CTA conversion method to consolidate multiple companies using different currencies,
+ which can lead the report to be unbalanced.
+
+
diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/aged_partner_balance/aged_partner_balance.scss b/dev_odex30_accounting/odex30_account_reports/static/src/components/aged_partner_balance/aged_partner_balance.scss
new file mode 100644
index 0000000..bf01a90
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/aged_partner_balance/aged_partner_balance.scss
@@ -0,0 +1,4 @@
+.account_report.aged_partner_balance {
+ .partner_trust { line-height: 20px }
+ td[data-expression_label='currency'] > .wrapper { justify-content: center }
+}
diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/aged_partner_balance/filter_aging.xml b/dev_odex30_accounting/odex30_account_reports/static/src/components/aged_partner_balance/filter_aging.xml
new file mode 100644
index 0000000..ad23020
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/aged_partner_balance/filter_aging.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+ Based on
+
+ Invoice Date
+
+
+ Due Date
+
+
+
+
+ Due Date
+
+
+
+ Invoice Date
+
+
+
+
+
+
+
+
+
+ Days
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This line is out of sequence.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_reports/static/tests/account_report/account_report.test.js b/dev_odex30_accounting/odex30_account_reports/static/tests/account_report/account_report.test.js
new file mode 100644
index 0000000..1ea9da1
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/tests/account_report/account_report.test.js
@@ -0,0 +1,212 @@
+import { expect, test } from "@odoo/hoot";
+
+import {
+ click,
+ contains,
+ mailModels,
+} from "@mail/../tests/mail_test_helpers";
+
+import {
+ defineModels,
+ getService,
+ mountWithCleanup,
+ onRpc,
+} from "@web/../tests/web_test_helpers";
+import { WebClient } from "@web/webclient/webclient";
+
+// Due to dependency with mail module, we have to define their models for our tests.
+defineModels(mailModels);
+
+const getOptionMockResponse = {
+ "companies": [],
+ "variants_source_id": 14,
+ "has_inactive_variants": false,
+ "available_variants": [],
+ "selected_variant_id": 14,
+ "sections_source_id": 14,
+ "sections": [],
+ "has_inactive_sections": false,
+ "report_id": 14,
+ "allow_domestic": false,
+ "fiscal_position": "all",
+ "available_vat_fiscal_positions": [],
+ "date": {
+ "string": "2025",
+ "period_type": "fiscalyear",
+ "mode": "range",
+ "date_from": "2025-01-01",
+ "date_to": "2025-12-31",
+ "filter": "this_year"
+ },
+ "available_horizontal_groups": [],
+ "selected_horizontal_group_id": null,
+ "account_type": [],
+ "all_entries": false,
+ "aml_ir_filters": [],
+ "buttons": [],
+ "export_mode": null,
+ "hide_0_lines": false,
+ "multi_currency": false,
+ "partner": false,
+ "partner_categories": [],
+ "selected_partner_ids": [],
+ "partner_ids": [],
+ "selected_partner_categories": [],
+ "unreconciled": false,
+ "rounding_unit": "decimals",
+ "rounding_unit_names": {
+ "decimals": [
+ ".$",
+ ""
+ ],
+ },
+ "search_bar": false,
+ "unfold_all": false,
+ "unfolded_lines": [],
+ "column_headers": [],
+ "columns": [
+ {
+ "name": "A column",
+ "column_group_key": "some_key",
+ "expression_label": "a_column",
+ "sortable": false,
+ "figure_type": "string",
+ "blank_if_zero": false,
+ "style": ""
+ },
+ ],
+ "column_groups": {"some_key": {"forced_options": {}, "forced_domain": []}
+ },
+}
+
+const getReportInformationMockResponse = {
+ "caret_options": {},
+ "column_headers_render_data": {"level_colspan": [1], "level_repetitions": [1], "custom_subheaders": []},
+ "column_groups_totals": {"some_key": {}},
+ "context": {},
+ "custom_display": {},
+ "filters": {},
+ "groups": {},
+ "annotations": {},
+ "lines": [
+ {
+ "id": "~account.report~14|~res.partner~1",
+ "name": "A partner",
+ "columns": [
+ {
+ "auditable": false,
+ "blank_if_zero": false,
+ "column_group_key": "some_key",
+ "currency": null,
+ "currency_symbol": "",
+ "digits": 1,
+ "expression_label": "a_column",
+ "figure_type": "string",
+ "green_on_positive": false,
+ "has_sublines": false,
+ "is_zero": false,
+ "name": "",
+ "no_format": null,
+ "report_line_id": null,
+ "sortable": false
+ },
+ ],
+ "level": 1,
+ "trust": "normal",
+ "unfoldable": true,
+ "unfolded": false,
+ "expand_function": "some_expand_function"
+ },
+ ],
+ "warnings": {},
+ "report": { "company_name": "YourCompany", "company_country_code": "US", "company_currency_symbol": "$", "name": "A report", "root_report_id": "account.report()"}
+}
+
+const getExpandedLinesMockResponse = [
+ {
+ "id": "~account.report~14|~res.partner~1|0~account.move.line~1",
+ "parent_id": "~account.report~14|~res.partner~1",
+ "name": "first move line",
+ "columns": [
+ {
+ "auditable": false,
+ "blank_if_zero": false,
+ "column_group_key": "some_key",
+ "currency": null,
+ "currency_symbol": "$",
+ "digits": 1,
+ "expression_label": "a_column",
+ "figure_type": "string",
+ "green_on_positive": false,
+ "has_sublines": false,
+ "is_zero": false,
+ "name": "first value",
+ "no_format": "first value",
+ "report_line_id": null,
+ "sortable": false
+ },
+ ],
+ "level": 3
+ },
+ {
+ "id": "~account.report~14|~res.partner~1|0~account.move.line~11",
+ "parent_id": "~account.report~14|~res.partner~1",
+ "name": "second move line",
+ "columns": [
+ {
+ "auditable": false,
+ "blank_if_zero": false,
+ "column_group_key": "some_key",
+ "currency": null,
+ "currency_symbol": "$",
+ "digits": 1,
+ "expression_label": "a_column",
+ "figure_type": "string",
+ "green_on_positive": false,
+ "has_sublines": false,
+ "is_zero": false,
+ "name": "second value",
+ "no_format": "second value",
+ "report_line_id": null,
+ "sortable": false
+ },
+ ],
+ "level": 3
+ },
+]
+
+
+test("Test unfold loaded line", async() => {
+ async function mockRpcReport({ method, model }) {
+ if (model === 'account.report') {
+ if (method === 'get_options') {
+ return getOptionMockResponse;
+ }
+ if (method === 'get_report_information') {
+ return getReportInformationMockResponse;
+ }
+ if (method === 'get_expanded_lines') {
+ mockRpcReport.getExpandedLineCallCount = (mockRpcReport.getExpandedLineCallCount || 0) + 1;
+ return getExpandedLinesMockResponse;
+ }
+ }
+ };
+ onRpc(mockRpcReport);
+
+ await mountWithCleanup(WebClient);
+ await getService("action").doAction({
+ type: "ir.actions.client",
+ tag: "account_report",
+ params: {},
+ })
+
+ await click(".btn_foldable");
+ await contains(".unfolded", { count: 1 });
+ await click(".btn_foldable");
+ await contains(".unfolded", { count: 0 });
+ await click(".btn_foldable");
+ await contains(".unfolded", { count: 1 });
+
+ // Only one call to get_expanded_lines, as we unfolded/folded/unfolded the same line
+ expect(mockRpcReport.getExpandedLineCallCount).toEqual(1);
+});
diff --git a/dev_odex30_accounting/odex30_account_reports/static/tests/account_report/line_cell_editable/line_cell_editable.test.js b/dev_odex30_accounting/odex30_account_reports/static/tests/account_report/line_cell_editable/line_cell_editable.test.js
new file mode 100644
index 0000000..a0b43bd
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/tests/account_report/line_cell_editable/line_cell_editable.test.js
@@ -0,0 +1,35 @@
+import { expect, test } from "@odoo/hoot";
+import { click, press } from "@odoo/hoot-dom";
+import { animationFrame } from "@odoo/hoot-mock";
+import { mountWithCleanup, makeMockEnv, defineModels } from "@web/../tests/web_test_helpers";
+import { mailModels } from "@mail/../tests/mail_test_helpers";
+
+import { AccountReportLineCellEditable } from "@odex30_account_reports/components/account_report/line_cell_editable/line_cell_editable";
+
+// Due to dependency with mail module, we have to define their models for our tests.
+defineModels(mailModels);
+
+test("can unformat a value when focus and format when blur", async () => {
+ const env = await makeMockEnv({
+ controller: {},
+ });
+ await mountWithCleanup(AccountReportLineCellEditable, {
+ env,
+ props: {
+ cell: {
+ name: "5,702.22",
+ no_format: 5702.22,
+ edit_popup_data: {},
+ },
+ line: {},
+ },
+ });
+
+ expect(".o_input").toHaveValue("5,702.22");
+ await click(".o_input");
+ await animationFrame();
+ expect(".o_input").toHaveValue("5702.22");
+ await press("Enter");
+ await animationFrame();
+ expect(".o_input").toHaveValue("5,702.22");
+});
diff --git a/dev_odex30_accounting/odex30_account_reports/static/tests/legacy/account_report_builder.js b/dev_odex30_accounting/odex30_account_reports/static/tests/legacy/account_report_builder.js
new file mode 100644
index 0000000..f974837
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/tests/legacy/account_report_builder.js
@@ -0,0 +1,684 @@
+/** @odoo-module **/
+
+import { click, drag, editInput, getFixture } from "@web/../tests/helpers/utils";
+import { registerCleanup } from "@web/../tests/helpers/cleanup";
+import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
+import { sortableDrag } from "@web/../tests/core/utils/nested_sortable_tests"
+
+let arch;
+let serverData;
+let target;
+
+QUnit.module("Account Reports Builder", ({ beforeEach }) => {
+ beforeEach(async () => {
+ arch = `
+
+ `;
+
+ serverData = {
+ models: {
+ report: {
+ fields: {
+ id: { string: "ID", type: "integer" },
+ line_ids: {
+ string: "Lines",
+ type: "one2many",
+ relation: "report_lines",
+ relation_field: "report_id",
+ },
+ },
+ records: [
+ {
+ id: 1,
+ line_ids: [1, 2, 3, 4, 5],
+ }
+ ]
+ },
+ report_lines: {
+ fields: {
+ report_id: { string: "Report ID", type: "many2one", relation: "report" },
+ id: { string: "ID", type: "integer" },
+ sequence: { string: "Sequence", type: "integer" },
+ parent_id: {
+ string: "Parent Line",
+ type: "many2one",
+ relation: "report_lines",
+ relation_field: "id",
+ },
+ hierarchy_level: { string: "Level", type: "integer" },
+ name: { string: "Name", type: "char" },
+ code: { string: "Code", type: "char" },
+ },
+ records: [
+ {
+ id: 1,
+ sequence: null,
+ parent_id: false,
+ hierarchy_level: 1,
+ name: "Root without children",
+ code: "RWOC",
+ },
+ {
+ id: 2,
+ sequence: null,
+ parent_id: false,
+ hierarchy_level: 0,
+ name: "Root with children",
+ code: "RC",
+ },
+ {
+ id: 3,
+ sequence: null,
+ parent_id: 2,
+ hierarchy_level: 3,
+ name: "Child #1",
+ code: "C1",
+ },
+ {
+ id: 4,
+ sequence: null,
+ parent_id: 3,
+ hierarchy_level: 5,
+ name: "Grandchild",
+ code: "GC",
+ },
+ {
+ id: 5,
+ sequence: null,
+ parent_id: 2,
+ hierarchy_level: 3,
+ name: "Child #2",
+ code: "C2",
+ },
+ ]
+ },
+ },
+ views: {
+ "report_lines,false,form": `
+
+ `,
+ }
+ };
+
+ target = getFixture();
+
+ // Make fixture in visible range, so that document.elementFromPoint work as expected
+ target.style.position = "absolute";
+ target.style.top = "0";
+ target.style.left = "0";
+ target.style.height = "100%";
+ target.style.opacity = QUnit.config.debug ? "" : "0";
+
+ registerCleanup(async () => {
+ target.style.position = "";
+ target.style.top = "";
+ target.style.left = "";
+ target.style.height = "";
+ target.style.opacity = "";
+ });
+
+ setupViewRegistries();
+ });
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Structure
+ //------------------------------------------------------------------------------------------------------------------
+ QUnit.test("have correct descendants count", async (assert) => {
+ await makeView({
+ type: "form",
+ resId: 1,
+ resModel: "report",
+ serverData,
+ arch,
+ });
+
+ assert.containsOnce(target.querySelector(".account_report_lines_list_x2many"), "li[data-descendants_count='0'] span:contains('Root without children')");
+ assert.containsOnce(target.querySelector(".account_report_lines_list_x2many"), "li[data-descendants_count='3'] span:contains('Root with children')");
+ assert.containsOnce(target.querySelector(".account_report_lines_list_x2many"), "li[data-descendants_count='1'] span:contains('Child #1')");
+ assert.containsOnce(target.querySelector(".account_report_lines_list_x2many"), "li[data-descendants_count='0'] span:contains('Grandchild')");
+ assert.containsOnce(target.querySelector(".account_report_lines_list_x2many"), "li[data-descendants_count='0'] span:contains('Child #2')");
+ });
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Create
+ //------------------------------------------------------------------------------------------------------------------
+ QUnit.test("can create a line", async (assert) => {
+ await makeView({
+ type: "form",
+ resId: 1,
+ resModel: "report",
+ serverData,
+ arch,
+ });
+
+ await click(target.querySelector(".account_report_lines_list_x2many"), "li:last-of-type a");
+
+ assert.containsOnce(target, ".o_dialog");
+
+ await editInput(target.querySelector("div[name='name'] input"), null, "Created line");
+ await click(target.querySelector(".o_dialog"), ".o_form_button_save");
+
+ assert.containsOnce(target.querySelector(".account_report_lines_list_x2many"), "li span:contains('Created line')");
+ });
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Edit
+ //------------------------------------------------------------------------------------------------------------------
+ QUnit.test("can edit a line", async (assert) => {
+ await makeView({
+ type: "form",
+ resId: 1,
+ resModel: "report",
+ serverData,
+ arch,
+ });
+
+ await click(target.querySelector(".account_report_lines_list_x2many"), "li[data-record_id='1'] .column");
+
+ assert.containsOnce(target, ".o_dialog");
+
+ await editInput(target.querySelector("div[name='name'] input"), null, "Line without children (edited)");
+ await click(target.querySelector(".o_dialog"), ".o_form_button_save");
+
+ assert.containsOnce(target.querySelector(".account_report_lines_list_x2many"), "li span:contains('Line without children (edited)')");
+ });
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Delete
+ //------------------------------------------------------------------------------------------------------------------
+ QUnit.test("can delete a root", async (assert) => {
+ await makeView({
+ type: "form",
+ resId: 1,
+ resModel: "report",
+ serverData,
+ arch,
+ });
+
+ await click(target.querySelector(".account_report_lines_list_x2many"), "li[data-record_id='1'] > div > .trash");
+
+ assert.containsNone(target.querySelector(".account_report_lines_list_x2many"), "li span:contains('Root without children')");
+ assert.containsOnce(target.querySelector(".account_report_lines_list_x2many"), "li span:contains('Root with children')");
+ assert.containsOnce(target.querySelector(".account_report_lines_list_x2many"), "li span:contains('Child #1')");
+ assert.containsOnce(target.querySelector(".account_report_lines_list_x2many"), "li span:contains('Grandchild')");
+ assert.containsOnce(target.querySelector(".account_report_lines_list_x2many"), "li span:contains('Child #2')");
+ });
+
+ QUnit.test("can delete a root with children", async (assert) => {
+ await makeView({
+ type: "form",
+ resId: 1,
+ resModel: "report",
+ serverData,
+ arch,
+ });
+
+ await click(target.querySelector(".account_report_lines_list_x2many"), "li[data-record_id='2'] > div > .trash");
+
+ // Confirmation dialog "This line and all its children will be deleted. Are you sure you want to proceed?"
+ assert.containsOnce(target, ".o_dialog");
+
+ await click(target.querySelector(".o_dialog"), ".btn-primary");
+
+ assert.containsOnce(target.querySelector(".account_report_lines_list_x2many"), "li span:contains('Root without children')");
+ assert.containsNone(target.querySelector(".account_report_lines_list_x2many"), "li span:contains('Root with children')");
+ assert.containsNone(target.querySelector(".account_report_lines_list_x2many"), "li span:contains('Child #1')");
+ assert.containsNone(target.querySelector(".account_report_lines_list_x2many"), "li span:contains('Grandchild')");
+ assert.containsNone(target.querySelector(".account_report_lines_list_x2many"), "li span:contains('Child #2')");
+ });
+
+ QUnit.test("can delete a last child", async (assert) => {
+ await makeView({
+ type: "form",
+ resId: 1,
+ resModel: "report",
+ serverData,
+ arch,
+ });
+
+ await click(target.querySelector(".account_report_lines_list_x2many"), "li[data-record_id='4'] > div > .trash");
+
+ assert.containsOnce(target.querySelector(".account_report_lines_list_x2many"), "li span:contains('Root without children')");
+ assert.containsOnce(target.querySelector(".account_report_lines_list_x2many"), "li[data-descendants_count='2'] span:contains('Root with children')");
+ assert.containsOnce(target.querySelector(".account_report_lines_list_x2many"), "li[data-descendants_count='0'] span:contains('Child #1')");
+ assert.containsNone(target.querySelector(".account_report_lines_list_x2many"), "li span:contains('Grandchild')");
+ assert.containsOnce(target.querySelector(".account_report_lines_list_x2many"), "li span:contains('Child #2')");
+ });
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Drag and drop
+ //------------------------------------------------------------------------------------------------------------------
+ QUnit.test("can move a root down", async (assert) => {
+ serverData.models.report.records[0].line_ids = [1, 2, 3, 4];
+ serverData.models.report_lines.records = [
+ {
+ id: 1,
+ sequence: null,
+ parent_id: false,
+ hierarchy_level: 1,
+ name: "dragged",
+ code: "D",
+ },
+ {
+ id: 2,
+ sequence: null,
+ parent_id: false,
+ hierarchy_level: 1,
+ name: "noChild",
+ code: "N",
+ },
+ {
+ id: 3,
+ sequence: null,
+ parent_id: false,
+ hierarchy_level: 0,
+ name: "parent",
+ code: "P",
+ },
+ {
+ id: 4,
+ sequence: null,
+ parent_id: 3,
+ hierarchy_level: 3,
+ name: "child",
+ code: "C",
+ },
+ ];
+
+ await makeView({
+ type: "form",
+ resId: 1,
+ resModel: "report",
+ serverData,
+ arch,
+ mockRPC: (route, args) => {
+ if (args.method === 'web_save') {
+ const lineIds = args.args[1].line_ids;
+
+ // Parents
+ assert.equal(lineIds[0][2].parent_id, 3);
+
+ // Hierarchy levels
+ assert.equal(lineIds[0][2].hierarchy_level, 3);
+
+ // Sequences
+ assert.equal(lineIds[0][2].sequence, 4);
+ assert.equal(lineIds[1][2].sequence, 1);
+ assert.equal(lineIds[2][2].sequence, 2);
+ assert.equal(lineIds[3][2].sequence, 3);
+ }
+ }
+ });
+
+ const { drop, moveUnder } = await sortableDrag("li[data-record_id='1']");
+
+ await moveUnder("li[data-record_id='2']");
+ await moveUnder("li[data-record_id='4']");
+ await drop();
+
+ await click(target.querySelector(".o_form_button_save"));
+ });
+
+ QUnit.test("can move a root up", async (assert) => {
+ serverData.models.report.records[0].line_ids = [1, 2, 3, 4];
+ serverData.models.report_lines.records = [
+ {
+ id: 1,
+ sequence: null,
+ parent_id: false,
+ hierarchy_level: 0,
+ name: "parent",
+ code: "P",
+ },
+ {
+ id: 2,
+ sequence: null,
+ parent_id: 1,
+ hierarchy_level: 3,
+ name: "child",
+ code: "C",
+ },
+ {
+ id: 3,
+ sequence: null,
+ parent_id: false,
+ hierarchy_level: 1,
+ name: "noChild",
+ code: "N",
+ },
+ {
+ id: 4,
+ sequence: null,
+ parent_id: false,
+ hierarchy_level: 1,
+ name: "dragged",
+ code: "D",
+ },
+ ];
+
+ await makeView({
+ type: "form",
+ resId: 1,
+ resModel: "report",
+ serverData,
+ arch,
+ mockRPC: (route, args) => {
+ if (args.method === 'web_save') {
+ const lineIds = args.args[1].line_ids;
+
+ // Parents
+ assert.equal(lineIds[0][2].parent_id, 1);
+
+ // Hierarchy levels
+ assert.equal(lineIds[0][2].hierarchy_level, 3);
+
+ // Sequences
+ assert.equal(lineIds[0][2].sequence, 2);
+ assert.equal(lineIds[1][2].sequence, 1);
+ assert.equal(lineIds[2][2].sequence, 3);
+ assert.equal(lineIds[3][2].sequence, 4);
+ }
+ }
+ });
+
+ const { drop, moveAbove } = await sortableDrag("li[data-record_id='4']");
+
+ await moveAbove("li[data-record_id='3']");
+ await moveAbove("li[data-record_id='2']");
+ await drop();
+
+ await click(target.querySelector(".o_form_button_save"));
+ });
+
+ QUnit.test("can move a child down", async (assert) => {
+ serverData.models.report.records[0].line_ids = [1, 2, 3, 4];
+ serverData.models.report_lines.records = [
+ {
+ id: 1,
+ sequence: null,
+ parent_id: false,
+ hierarchy_level: 0,
+ name: "parent",
+ code: "P",
+ },
+ {
+ id: 2,
+ sequence: null,
+ parent_id: 1,
+ hierarchy_level: 3,
+ name: "dragged",
+ code: "D",
+ },
+ {
+ id: 3,
+ sequence: null,
+ parent_id: 1,
+ hierarchy_level: 3,
+ name: "child",
+ code: "C",
+ },
+ {
+ id: 4,
+ sequence: null,
+ parent_id: false,
+ hierarchy_level: 1,
+ name: "noChild",
+ code: "N",
+ },
+ ];
+
+ await makeView({
+ type: "form",
+ resId: 1,
+ resModel: "report",
+ serverData,
+ arch,
+ mockRPC: (route, args) => {
+ if (args.method === 'web_save') {
+ const lineIds = args.args[1].line_ids;
+
+ // Parents
+ assert.equal(lineIds[0][2].parent_id, false);
+
+ // Hierarchy levels
+ assert.equal(lineIds[0][2].hierarchy_level, 1);
+
+ // Sequences
+ assert.equal(lineIds[0][2].sequence, 4);
+ assert.equal(lineIds[1][2].sequence, 1);
+ assert.equal(lineIds[2][2].sequence, 2);
+ assert.equal(lineIds[3][2].sequence, 3);
+ }
+ }
+ });
+
+ const { drop, moveUnder } = await sortableDrag("li[data-record_id='2']");
+
+ await moveUnder("li[data-record_id='3']");
+ await moveUnder("li[data-record_id='4']");
+ await drop();
+
+ await click(target.querySelector(".o_form_button_save"));
+ });
+
+ QUnit.test("can move a child up", async (assert) => {
+ serverData.models.report.records[0].line_ids = [1, 2, 3];
+ serverData.models.report_lines.records = [
+ {
+ id: 1,
+ sequence: null,
+ parent_id: false,
+ hierarchy_level: 0,
+ name: "parent",
+ code: "P",
+ },
+ {
+ id: 2,
+ sequence: null,
+ parent_id: 1,
+ hierarchy_level: 3,
+ name: "child",
+ code: "C",
+ },
+ {
+ id: 3,
+ sequence: null,
+ parent_id: 1,
+ hierarchy_level: 3,
+ name: "dragged",
+ code: "D",
+ },
+ ];
+
+ await makeView({
+ type: "form",
+ resId: 1,
+ resModel: "report",
+ serverData,
+ arch,
+ mockRPC: (route, args) => {
+ if (args.method === 'web_save') {
+ const lineIds = args.args[1].line_ids;
+
+ // Parents
+ assert.equal(lineIds[0][2].parent_id, false);
+
+ // Hierarchy levels
+ assert.equal(lineIds[0][2].hierarchy_level, 1);
+
+ // Sequences
+ assert.equal(lineIds[0][2].sequence, 1);
+ assert.equal(lineIds[1][2].sequence, 2);
+ assert.equal(lineIds[2][2].sequence, 3);
+ }
+ }
+ });
+
+ const { drop, moveAbove } = await sortableDrag("li[data-record_id='3']");
+
+ await moveAbove("li[data-record_id='2']");
+ await moveAbove("li[data-record_id='1']");
+ await drop();
+
+ await click(target.querySelector(".o_form_button_save"));
+ });
+
+ QUnit.test("can move a new root into a child", async (assert) => {
+ serverData.models.report.records[0].line_ids = [1, 2, 3];
+ serverData.models.report_lines.records = [
+ {
+ id: 1,
+ sequence: null,
+ parent_id: false,
+ hierarchy_level: 0,
+ name: "parent",
+ code: "P",
+ },
+ {
+ id: 2,
+ sequence: null,
+ parent_id: 1,
+ hierarchy_level: 3,
+ name: "child",
+ code: "C",
+ },
+ {
+ id: 3,
+ sequence: null,
+ parent_id: false,
+ hierarchy_level: 1,
+ name: "noChild",
+ code: "N",
+ },
+ ];
+
+ await makeView({
+ type: "form",
+ resId: 1,
+ resModel: "report",
+ serverData,
+ arch,
+ mockRPC: (route, args) => {
+ if (args.method === 'web_save' && !target.querySelector(".o_dialog")) {
+ const lineIds = args.args[1].line_ids;
+
+ // Parents
+ assert.equal(lineIds[0][2].parent_id, 1);
+
+ // Hierarchy levels
+ assert.equal(lineIds[0][2].hierarchy_level, 3);
+
+ // Sequences
+ assert.equal(lineIds[0][2].sequence, 2);
+ assert.equal(lineIds[1][2].sequence, 1);
+ assert.equal(lineIds[2][2].sequence, 3);
+ assert.equal(lineIds[3][2].sequence, 4);
+ }
+ }
+ });
+
+ await click(target.querySelector(".account_report_lines_list_x2many"), "li:last-of-type a");
+ await editInput(target.querySelector("div[name='name'] input"), null, "dragged");
+ await click(target.querySelector(".o_dialog"), ".o_form_button_save");
+
+ const { drop, moveAbove } = await sortableDrag("li[data-record_id='4']");
+
+ await moveAbove("li[data-record_id='3']");
+ await moveAbove("li[data-record_id='2']");
+ await drop();
+
+ await click(target.querySelector(".o_form_button_save"));
+ });
+
+ QUnit.test("can move a child into a new root", async (assert) => {
+ serverData.models.report.records[0].line_ids = [];
+ serverData.models.report_lines.records = [];
+
+ await makeView({
+ type: "form",
+ resId: 1,
+ resModel: "report",
+ serverData,
+ arch,
+ mockRPC: (route, args) => {
+ if (args.method === 'web_save' && !target.querySelector(".o_dialog")) {
+ const lineIds = args.args[1].line_ids;
+
+ // Parents
+ assert.equal(lineIds[0][2].parent_id, 1);
+
+ // Hierarchy levels
+ assert.equal(lineIds[0][2].hierarchy_level, 3);
+
+ // Sequences
+ assert.equal(lineIds[0][2].sequence, 2);
+ assert.equal(lineIds[1][2].sequence, 1);
+ }
+ }
+ });
+
+ await click(target.querySelector(".account_report_lines_list_x2many"), "li:last-of-type a");
+ await editInput(target.querySelector("div[name='name'] input"), null, "parent");
+ await click(target.querySelector(".o_dialog"), ".o_form_button_save");
+
+ await click(target.querySelector(".account_report_lines_list_x2many"), "li:last-of-type a");
+ await editInput(target.querySelector("div[name='name'] input"), null, "dragged");
+ await click(target.querySelector(".o_dialog"), ".o_form_button_save");
+
+ const toSelector = target.querySelector("li[data-record_id='2']");
+ const { drop, moveTo } = await drag(toSelector);
+
+ await moveTo(toSelector, { x: 600 });
+ await drop();
+
+ await click(target.querySelector(".o_form_button_save"));
+ });
+
+ QUnit.test("can display and hide 'Code' column when toggled in optional fields", async (assert) => {
+ await makeView({
+ type: "form",
+ resId: 1,
+ resModel: "report",
+ serverData,
+ arch,
+ });
+ // Ensure `code` column is hidden by default
+ assert.containsNone(
+ target.querySelector(".account_report_lines_list_x2many"),
+ "span.fw-bold.fixed:contains('Code')",
+ "The 'Code' column should be hidden initially"
+ );
+
+ // simulate toggling the `code` field to make it visible
+ await click(target.querySelector(".o-dropdown.dropdown-toggle"));
+ await click(target.querySelector("input[name='code']"));
+
+ // Check that the column is now visible
+ assert.containsOnce(
+ target.querySelector(".account_report_lines_list_x2many"),
+ "span.fw-bold.fixed:contains('Code')",
+ "The 'Code' column should now be visible after toggling"
+ );
+
+ // Toggle it back to hide and verify
+ await click(target.querySelector("input[name='code']"));
+ assert.containsNone(
+ target.querySelector(".account_report_lines_list_x2many"),
+ "span.fw-bold.fixed:contains('Code')",
+ "The 'Code' column should be hidden after toggling back"
+ );
+ });
+});
diff --git a/dev_odex30_accounting/odex30_account_reports/static/tests/legacy/action_manager_account_report_dl_tests.js b/dev_odex30_accounting/odex30_account_reports/static/tests/legacy/action_manager_account_report_dl_tests.js
new file mode 100644
index 0000000..3c9f524
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/tests/legacy/action_manager_account_report_dl_tests.js
@@ -0,0 +1,52 @@
+/** @odoo-module **/
+
+import { mockDownload } from "@web/../tests/helpers/utils";
+
+import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
+
+let serverData;
+QUnit.module('Account Reports', {}, function () {
+ QUnit.test('can execute account report download actions', async function (assert) {
+ assert.expect(5);
+
+ const actions = {
+ 1: {
+ id: 1,
+ data: {
+ model: 'some_model',
+ options: {
+ someOption: true,
+ },
+ output_format: 'pdf',
+ },
+ type: 'ir_actions_account_report_download',
+ },
+ };
+ serverData = {actions};
+ mockDownload((options) => {
+ assert.step(options.url);
+ assert.deepEqual(options.data, {
+ model: 'some_model',
+ options: {
+ someOption: true,
+ },
+ output_format: 'pdf',
+ }, "should give the correct data");
+ return Promise.resolve();
+ });
+ const webClient = await createWebClient({
+ serverData,
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ },
+ });
+ await doAction(webClient, 1);
+
+ assert.verifySteps([
+ '/web/webclient/load_menus',
+ '/web/action/load',
+ '/account_reports',
+ ]);
+
+ });
+});
diff --git a/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account.js b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account.js
new file mode 100644
index 0000000..4bc8cdc
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account.js
@@ -0,0 +1,35 @@
+import { _t } from "@web/core/l10n/translation";
+import { patch } from "@web/core/utils/patch";
+import { accountTourSteps } from "@account/js/tours/account";
+
+patch(accountTourSteps, {
+ onboarding() {
+ return [
+ {
+ trigger: ".o_widget_account_onboarding .fa-circle",
+ },
+ {
+ trigger: "a[data-method=action_open_step_fiscal_year]",
+ content: _t("Set Periods"),
+ run: "click",
+ },
+ {
+ trigger: "button[name=action_save_onboarding_fiscal_year]",
+ content: _t("Save Fiscal Year end"),
+ run: "click",
+ },
+ ];
+ },
+ newInvoice() {
+ return [
+ {
+ trigger: ".o_widget_account_onboarding .fa-check-circle",
+ },
+ {
+ trigger: "button[name=action_create_new]",
+ content: _t("Now, we'll create your first invoice (accountant)"),
+ run: "click",
+ },
+ ];
+ },
+});
diff --git a/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports.js b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports.js
new file mode 100644
index 0000000..3c7d41a
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports.js
@@ -0,0 +1,262 @@
+/** @odoo-module **/
+
+import { Asserts } from "./asserts";
+import { registry } from "@web/core/registry";
+
+registry.category("web_tour.tours").add("account_reports", {
+ url: "/odoo/action-account_reports.action_account_report_bs",
+ steps: () => [
+ //--------------------------------------------------------------------------------------------------------------
+ // Foldable
+ //--------------------------------------------------------------------------------------------------------------
+ {
+ content: "Initial foldable",
+ trigger: ".o_content",
+ run: () => {
+ Asserts.DOMContainsNumber("tbody > tr:not(.d-none):not(.empty)", 28);
+
+ // Since the total line is not displayed (folded), the amount should be on the line
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(4) td:nth-child(2)").textContent,
+ "75.00"
+ );
+ },
+ },
+ {
+ content: "Click to unfold line",
+ trigger: "tr:nth-child(4) td:first()",
+ run: "click",
+ },
+ {
+ content: "Line is unfolded",
+ trigger: "tr:nth-child(5) .name:contains('101401')",
+ run: () => {
+ Asserts.DOMContainsNumber("tbody > tr:not(.d-none):not(.empty)", 30);
+
+ // Since the total line is displayed (unfolded), the amount should not be on the line
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(4) td:nth-child(2)").textContent,
+ ""
+ );
+ },
+ },
+ {
+ content: "Click to fold line",
+ trigger: "tr:nth-child(4) td:first()",
+ run: "click",
+ },
+ {
+ content: "Line is folded",
+ trigger: ".o_content",
+ run: () => {
+ Asserts.DOMContainsNumber("tbody > tr:not(.d-none):not(.empty)", 28);
+ },
+ },
+ //--------------------------------------------------------------------------------------------------------------
+ // Sortable
+ //--------------------------------------------------------------------------------------------------------------
+ {
+ content: "Unfold first line",
+ trigger: "tr:nth-child(4) td:first()",
+ run: "click",
+ },
+ {
+ content: "Unfold second line",
+ trigger: "tr:nth-child(7) td:first()",
+ run: "click",
+ },
+ {
+ content: "Unfold third line",
+ trigger: "tr:nth-child(10) td:first()",
+ run: "click",
+ },
+ {
+ content: "Extra Trigger step",
+ trigger: "tr:nth-child(12):not(.d-none) .name:contains('101404')",
+ },
+ {
+ content: "Initial sortable",
+ trigger: ".o_content",
+ run: () => {
+ // Bank and Cash Accounts
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(5) td:nth-child(2)").textContent,
+ "75.00"
+ );
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(6) td:nth-child(2)").textContent,
+ "75.00"
+ );
+
+ // Receivables
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(8) td:nth-child(2)").textContent,
+ "25.00"
+ );
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(9) td:nth-child(2)").textContent,
+ "25.00"
+ );
+
+ // Current Assets
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(11) td:nth-child(2)").textContent,
+ "100.00"
+ );
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(12) td:nth-child(2)").textContent,
+ "50.00"
+ );
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(13) td:nth-child(2)").textContent,
+ "150.00"
+ );
+ },
+ },
+ {
+ content: "Click sort",
+ trigger: "th .btn_sortable",
+ run: "click",
+ },
+ {
+ trigger: "tr:nth-child(11) td:nth-child(2):contains('50.00')",
+ },
+ {
+ content: "Unfold not previously unfolded line",
+ trigger: "tr:nth-child(22):contains('Current Liabilities') td:first()",
+ run: "click",
+ },
+ {
+ content: "Line is unfolded",
+ trigger: "tr:nth-child(23) .name:contains('251000')",
+ run: "click",
+ },
+ {
+ content: "Sortable (asc)",
+ trigger: "tr:nth-child(11) td:nth-child(2):contains('50.00')",
+ run: () => {
+ // Bank and Cash Accounts
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(5) td:nth-child(2)").textContent,
+ "75.00"
+ );
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(6) td:nth-child(2)").textContent,
+ "75.00"
+ );
+
+ // Receivables
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(8) td:nth-child(2)").textContent,
+ "25.00"
+ );
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(9) td:nth-child(2)").textContent,
+ "25.00"
+ );
+
+ // Current Assets
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(11) td:nth-child(2)").textContent,
+ "50.00"
+ );
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(12) td:nth-child(2)").textContent,
+ "100.00"
+ );
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(13) td:nth-child(2)").textContent,
+ "150.00"
+ );
+ },
+ },
+ {
+ content: "Click sort",
+ trigger: "th .btn_sortable",
+ run: "click",
+ },
+ {
+ content: "Sortable (desc)",
+ trigger: "tr:nth-child(11) td:nth-child(2):contains('100.00')",
+ run: () => {
+ // Bank and Cash Accounts
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(5) td:nth-child(2)").textContent,
+ "75.00"
+ );
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(6) td:nth-child(2)").textContent,
+ "75.00"
+ );
+
+ // Receivables
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(8) td:nth-child(2)").textContent,
+ "25.00"
+ );
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(9) td:nth-child(2)").textContent,
+ "25.00"
+ );
+
+ // Current Assets
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(11) td:nth-child(2)").textContent,
+ "100.00"
+ );
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(12) td:nth-child(2)").textContent,
+ "50.00"
+ );
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(13) td:nth-child(2)").textContent,
+ "150.00"
+ );
+ },
+ },
+ {
+ content: "Click sort",
+ trigger: "th .btn_sortable",
+ run: "click",
+ },
+ {
+ content: "Sortable (reset)",
+ trigger: "tr:nth-child(5) td:nth-child(2):contains('75.00')",
+ run: () => {
+ // Bank and Cash Accounts
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(5) td:nth-child(2)").textContent,
+ "75.00"
+ );
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(6) td:nth-child(2)").textContent,
+ "75.00"
+ );
+
+ // Receivables
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(8) td:nth-child(2)").textContent,
+ "25.00"
+ );
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(9) td:nth-child(2)").textContent,
+ "25.00"
+ );
+
+ // Current Assets
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(11) td:nth-child(2)").textContent,
+ "100.00"
+ );
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(12) td:nth-child(2)").textContent,
+ "50.00"
+ );
+ Asserts.isEqual(
+ document.querySelector("tr:nth-child(13) td:nth-child(2)").textContent,
+ "150.00"
+ );
+ },
+ },
+ ],
+});
diff --git a/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_amount_rounding.js b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_amount_rounding.js
new file mode 100644
index 0000000..bd2e408
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_amount_rounding.js
@@ -0,0 +1,94 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+
+registry.category("web_tour.tours").add('account_reports_rounding_unit', {
+ url: '/odoo/action-account_reports.action_account_report_bs',
+ steps: () => [
+ {
+ content: 'Test the value of `Receivables` line in decimals',
+ trigger: '.line_name:contains("Receivables") + .line_cell:contains("1,150,000.00")',
+ run: "click",
+ },
+ // Units
+ {
+ content: "Open amounts rounding dropdown",
+ trigger: "#filter_rounding_unit button",
+ run: 'click',
+ },
+ {
+ content: "Select the units filter",
+ trigger: ".dropdown-item:contains('In $')",
+ run: 'click',
+ },
+ {
+ trigger:
+ '.line_name:contains("Receivables") + .line_cell:not(:contains("1,150,000.00"))',
+ },
+ {
+ content: 'test the value of `Receivables` line in units',
+ // We wait for the value to change.
+ // We check the new value.
+ trigger: '.line_name:contains("Receivables") + .line_cell:contains("1,150,000")',
+ run: "click",
+ },
+ // Thousands
+ {
+ content: "Open amounts rounding dropdown",
+ trigger: "#filter_rounding_unit button",
+ run: 'click',
+ },
+ {
+ content: "Select the thousands filter",
+ trigger: ".dropdown-item:contains('In K$')",
+ run: 'click',
+ },
+ {
+ trigger: '.line_name:contains("Receivables") + .line_cell:not(:contains("1,150,000"))',
+ },
+ {
+ content: 'test the value of `Receivables` line in thousands',
+ // We wait for the value to change.
+ // We check the new value.
+ trigger: '.line_name:contains("Receivables") + .line_cell:contains("1,150")',
+ run: "click",
+ },
+ // Millions
+ {
+ content: "Open amounts rounding dropdown",
+ trigger: "#filter_rounding_unit button",
+ run: 'click',
+ },
+ {
+ content: "Select the millions filter",
+ trigger: ".dropdown-item:contains('In M$')",
+ run: 'click',
+ },
+ {
+ trigger: '.line_name:contains("Receivables") + .line_cell:not(:contains("1,150"))',
+ },
+ {
+ content: 'test the value of `Receivables` line in millions',
+ // We wait for the value to change.
+ // We check the new value.
+ trigger: '.line_name:contains("Receivables") + .line_cell:contains("1")',
+ run: "click",
+ },
+ // Decimals
+ {
+ content: "Open amounts rounding dropdown",
+ trigger: "#filter_rounding_unit button",
+ run: 'click',
+ },
+ {
+ content: "Select the decimals filter",
+ trigger: ".dropdown-item:contains('In .$')",
+ run: 'click',
+ },
+ {
+ content: 'test the value of `Receivables` line in millions',
+ trigger: '.line_name:contains("Receivables") + .line_cell:contains("1,150,000.00")',
+ run: () => null,
+ },
+ ]
+});
diff --git a/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_analytic_filters.js b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_analytic_filters.js
new file mode 100644
index 0000000..7a060ea
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_analytic_filters.js
@@ -0,0 +1,28 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+
+registry.category("web_tour.tours").add("account_reports_analytic_filters", {
+ url: "/odoo/action-account_reports.action_account_report_general_ledger",
+ steps: () => [
+ {
+ content: "click analytic filters",
+ trigger: ".filter_analytic button",
+ run: "click",
+ },
+ {
+ content: "insert text in the searchbar",
+ trigger: ".o_multi_record_selector input",
+ run: "edit Time",
+ },
+ {
+ content: "click on the item",
+ trigger: '.o-autocomplete--dropdown-item:contains("Time Off")',
+ run: "click",
+ },
+ {
+ content: "Check the label of the badge",
+ trigger: '.dropdown-menu .o_tag_badge_text:contains("Time Off")',
+ },
+ ],
+});
diff --git a/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_annotations.js b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_annotations.js
new file mode 100644
index 0000000..55de235
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_annotations.js
@@ -0,0 +1,298 @@
+/** @odoo-module **/
+
+import { Asserts } from "./asserts";
+import { registry } from "@web/core/registry";
+
+registry.category("web_tour.tours").add("account_reports_annotations", {
+ url: "/odoo/action-account_reports.action_account_report_bs",
+ steps: () => [
+ //--------------------------------------------------------------------------------------------------------------
+ // Annotations
+ //--------------------------------------------------------------------------------------------------------------
+ // Test the initial status of annotations - There are 2 annotations to display
+ {
+ content: "Initial annotations",
+ trigger: ".o_content",
+ run: () => {
+ Asserts.DOMContainsNone(".annotations");
+ },
+ },
+ {
+ content: "Unfold first line",
+ trigger: "tr:nth-child(4) td:first()",
+ run: "click",
+ },
+ {
+ content: "Unfold second line",
+ trigger: "tr:nth-child(7) td:first()",
+ run: "click",
+ },
+ {
+ content: "Unfold third line",
+ trigger: "tr:nth-child(10) td:first()",
+ run: "click",
+ },
+ {
+ content: "Extra Trigger step",
+ trigger: "tr:nth-child(12):not(.d-none) .name:contains('101404')",
+ },
+ {
+ content: "Check there are two lines annotated initially",
+ trigger: ".o_content",
+ run: () => {
+ const annotations = document.querySelectorAll(".btn_annotation .fa-commenting");
+
+ // Check the number of annotated lines
+ Asserts.isEqual(annotations.length, 2);
+
+ // Check the annotations buttons are on the right lines
+ Asserts.isTrue(
+ annotations[0] ===
+ document.querySelector("tr:nth-child(5)").querySelector(".fa-commenting")
+ );
+ Asserts.isTrue(
+ annotations[1] ===
+ document.querySelector("tr:nth-child(12)").querySelector(".fa-commenting")
+ );
+ },
+ },
+ // Test that we can add a new annotation
+ {
+ content: "Click to show caret option",
+ trigger: "tr:nth-child(8) .dropdown-toggle",
+ run: "click",
+ },
+ {
+ content: "Caret option is displayed",
+ trigger: "tr:nth-child(8)",
+ run: () => {
+ Asserts.hasClass("tr:nth-child(8) .o-dropdown", "show");
+ },
+ },
+ {
+ content: "Click on annotate",
+ trigger: ".o-dropdown--menu .dropdown-item:last-of-type:contains('Annotate')",
+ run: "click",
+ },
+ {
+ content: "Add text to annotate",
+ trigger: "textarea",
+ run: "edit Annotation 121000",
+ },
+ {
+ content: "Submit annotation by blurring",
+ trigger: "textarea",
+ run: function () {
+ const annotation = this.anchor;
+ annotation.dispatchEvent(new InputEvent("input"));
+ annotation.dispatchEvent(new Event("change"));
+ },
+ },
+ {
+ content: "Wait for annotation created",
+ trigger: "tr:nth-child(8) .btn_annotation .fa-commenting",
+ },
+ {
+ content: "Close annotation",
+ trigger: ".o_content",
+ run: "click",
+ },
+ {
+ content: "Check there are now three lines annotated",
+ trigger: ".o_content",
+ run: () => {
+ const annotations = document.querySelectorAll(".btn_annotation .fa-commenting");
+
+ // Check the number of annotated lines
+ Asserts.isEqual(annotations.length, 3);
+
+ // Check the annotations buttons are on the right lines
+ Asserts.isTrue(
+ annotations[0] ===
+ document.querySelector("tr:nth-child(5)").querySelector(".fa-commenting")
+ );
+ Asserts.isTrue(
+ annotations[1] ===
+ document.querySelector("tr:nth-child(8)").querySelector(".fa-commenting")
+ );
+ Asserts.isTrue(
+ annotations[2] ===
+ document.querySelector("tr:nth-child(12)").querySelector(".fa-commenting")
+ );
+ },
+ },
+ // Test that we can edit an annotation
+ {
+ content: "Open second annotated line annotation popover",
+ trigger: "tr:nth-child(8) .btn_annotation",
+ run: "click",
+ },
+ {
+ content: "Annotate contains previous text value",
+ trigger: "textarea",
+ run: () => {
+ Asserts.isEqual(document.querySelector("textarea").value, "Annotation 121000");
+ },
+ },
+ {
+ content: "Add text to annotate",
+ trigger: "textarea",
+ run: "edit Annotation 121000 edited",
+ },
+ {
+ content: "Annotation is edited",
+ trigger: "tr:nth-child(8) .btn_annotation",
+ run: () => {
+ Asserts.isEqual(
+ document.querySelector(".annotation_popover_autoresize_textarea").value,
+ "Annotation 121000 edited"
+ );
+ },
+ },
+ // Test that we can delete an annotation by clicking the trash icon
+ {
+ content: "Click on trash can",
+ trigger: ".btn_annotation_delete",
+ run: "click",
+ },
+ {
+ content: "Check there are now only two lines annotated",
+ trigger: "tr:nth-child(8):not(:has(.fa-commenting))",
+ run: () => {
+ const annotations = document.querySelectorAll(".btn_annotation .fa-commenting");
+
+ // Check the number of annotated lines
+ Asserts.isEqual(annotations.length, 2);
+
+ // Check the annotations buttons are on the right lines
+ Asserts.isTrue(
+ annotations[0] ===
+ document.querySelector("tr:nth-child(5)").querySelector(".fa-commenting")
+ );
+ Asserts.isTrue(
+ annotations[1] ===
+ document.querySelector("tr:nth-child(12)").querySelector(".fa-commenting")
+ );
+ },
+ },
+ // Test that we can add an annotation by clicking on the "New" button inside the popover
+ {
+ content: "Open an annotated line annotation popover",
+ trigger: "tr:nth-child(12) .btn_annotation",
+ run: "click",
+ },
+ {
+ content: "Click on the 'New' button",
+ trigger: ".annotation_popover_line .oe_link",
+ run: "click",
+ },
+ {
+ content: "Add text to annotate",
+ trigger: "textarea:last()",
+ run: "edit Annotation 101404 bis",
+ },
+ {
+ content: "Submit annotation by blurring",
+ trigger: "textarea:last()",
+ run: function () {
+ const annotation = this.anchor;
+ annotation.dispatchEvent(new InputEvent("input"));
+ annotation.dispatchEvent(new Event("change"));
+ },
+ },
+ // Check the current state of the annotations
+ {
+ content: "Check there are two annotated lines to end the test",
+ trigger: ".o_content",
+ run: () => {
+ const annotations = document.querySelectorAll(".btn_annotation .fa-commenting");
+
+ // Check there is only one annotated line
+ Asserts.isEqual(annotations.length, 2);
+
+ // Check the annotation buttons are on the right lines
+ Asserts.isTrue(
+ annotations[0] ===
+ document.querySelector("tr:nth-child(5)").querySelector(".fa-commenting")
+ );
+ Asserts.isTrue(
+ annotations[1] ===
+ document.querySelector("tr:nth-child(12)").querySelector(".fa-commenting")
+ );
+ },
+ },
+ //--------------------------------------------------------------------------------------------------------------
+ // Annotations dates
+ //--------------------------------------------------------------------------------------------------------------
+ {
+ content:
+ "Remove first annotation to only have one annotation on line 12 (required setup step)",
+ trigger: ".annotation_popover tr:nth-child(2) .btn_annotation_delete",
+ run: "click",
+ },
+ {
+ content: "Verify that we still have one element",
+ trigger: ".annotation_popover tr:nth-child(3):contains('Add a line')",
+ },
+ {
+ content: "Modify the date of an annotation to a further period",
+ trigger: ".annotation_popover tr:nth-child(2) input",
+ run: "edit 01/01/2100",
+ },
+ {
+ content: "Modify the date of an annotation to a further period",
+ trigger: ".annotation_popover tr:nth-child(2) input",
+ run: function () {
+ // Since the t-on-change of the input is not triggered by the run: "edit" action,
+ // we need to dispatch the event manually requiring a function.
+ const input = this.anchor;
+ input.dispatchEvent(new InputEvent("input"));
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ },
+ },
+ {
+ content: "Check that there is no annotation anymore on line 12",
+ trigger: "tr:nth-child(12):not(:has(.fa-commenting))",
+ },
+ {
+ content: "change date filter",
+ trigger: "#filter_date button",
+ run: "click",
+ },
+ {
+ content: "Open specific date button",
+ trigger: ".dropdown-menu div.dropdown-item",
+ run: "click",
+ },
+ {
+ content: "Go to 15 January 2100",
+ trigger: ".o_datetime_input",
+ run: "edit 01/15/2100",
+ },
+ {
+ content: "Apply filter by closing the dropdown",
+ trigger: "#filter_date .btn:first()",
+ run: "click",
+ },
+ {
+ content: "wait refresh",
+ trigger: `#filter_date button:not(:contains(${new Date().getFullYear()}))`,
+ },
+ {
+ content: "Check there is one annotation on line 12",
+ trigger: "tr:nth-child(12):has(.fa-commenting)",
+ run: () => {
+ const annotations = document.querySelectorAll(".btn_annotation .fa-commenting");
+
+ // Check there is only one annotated line
+ Asserts.isEqual(annotations.length, 1);
+
+ // Check the annotation buttons are on the right lines
+ Asserts.isTrue(
+ annotations[0] ===
+ document.querySelector("tr:nth-child(12)").querySelector(".fa-commenting")
+ );
+ }
+ },
+ ]
+});
diff --git a/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_hide_0_lines.js b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_hide_0_lines.js
new file mode 100644
index 0000000..f6bb6f6
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_hide_0_lines.js
@@ -0,0 +1,107 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+
+registry.category("web_tour.tours").add('account_reports_hide_0_lines', {
+ url: '/odoo/action-account_reports.action_account_report_bs',
+ steps: () => [
+ {
+ content: 'test if the Bank and Cash line is present (but the value is 0)',
+ trigger: '.line_name:contains("Bank and Cash Accounts")',
+ run: "click",
+ },
+ {
+ content: 'test if the Current Year Unallocated Earnings line is present (but the value is 0)',
+ trigger: '.line_name:contains("Current Year Unallocated Earnings")',
+ run: "click",
+ },
+ {
+ content: 'test if the Unallocated Earnings line is present (but value is different from 0 and so should be there after the hide_0_lines',
+ trigger: '.line_name:contains("Unallocated Earnings")',
+ run: "click",
+ },
+ {
+ content: "Open options selector",
+ trigger: "#filter_extra_options button",
+ run: 'click',
+ },
+ {
+ content: "Select the hide line at 0 option",
+ trigger: ".dropdown-item:contains('Hide lines at 0')",
+ run: 'click',
+ },
+ {
+ content: 'test if the Unallocated Earnings line is still present',
+ trigger: '.line_name:contains("Unallocated Earnings")',
+ run: "click",
+ },
+ {
+ content: 'test if the Bank and Cash line is not present',
+ trigger: '.line_name:not(:contains("Bank and Cash Accounts"))',
+ run: "click",
+ },
+ {
+ content: 'test if the Current Year Unallocated Earnings line is not present',
+ trigger: '.line_name:not(:contains("Current Year Unallocated Earnings"))',
+ run: "click",
+ },
+ {
+ content: "Click again to open the options selector",
+ trigger: "#filter_extra_options button",
+ run: 'click',
+ },
+ {
+ content: "Select the hide lines at 0 option again",
+ trigger: ".dropdown-item:contains('Hide lines at 0')",
+ run: 'click',
+ },
+ {
+ content: 'test again if the Bank and Cash line is present (but the value is 0)',
+ trigger: '.line_name:contains("Bank and Cash Accounts")',
+ run: () => null,
+ },
+ ]
+});
+
+registry.category("web_tour.tours").add('account_reports_hide_0_lines_with_string_columns', {
+ url: '/odoo/action-account_reports.action_account_report_general_ledger',
+ steps: () => [
+ {
+ content: "Check if the 211000 Account Payable line is present (but the value is 0)",
+ trigger: ".name:contains('211000 Account Payable')",
+ run: "click",
+ },
+ {
+ content: "Check if the MISC item line is present with string values set up, but all amounts are at 0",
+ trigger: ".name:contains('Coucou les biloutes')",
+ },
+ {
+ content: "Open options selector",
+ trigger: "#filter_extra_options button",
+ run: 'click',
+ },
+ {
+ content: "Select the hide line at 0 option",
+ trigger: ".dropdown-item:contains('Hide lines at 0')",
+ run: 'click',
+ },
+ {
+ content: "Check if the MISC item line is hidden",
+ trigger: ":not(:visible):contains('Coucou les biloutes')",
+ },
+ {
+ content: "Click again to open the options selector",
+ trigger: "#filter_extra_options button",
+ run: 'click',
+ },
+ {
+ content: "Select the hide lines at 0 option again",
+ trigger: ".dropdown-item:contains('Hide lines at 0')",
+ run: 'click',
+ },
+ {
+ content: "Check again if the MISC item line is present (but the value is 0)",
+ trigger: ".name:contains('Coucou les biloutes')",
+ },
+ ]
+});
diff --git a/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_search.js b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_search.js
new file mode 100644
index 0000000..a0df3aa
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_search.js
@@ -0,0 +1,43 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+
+registry.category("web_tour.tours").add('account_reports_search', {
+ url: '/odoo/action-account_reports.action_account_report_general_ledger',
+ steps: () => [
+ {
+ content: "click search",
+ trigger: '.o_searchview_input',
+ run: 'click',
+ },
+ {
+ content: 'insert text in the searchbar',
+ trigger: '.o_searchview_input',
+ run: "edit 40",
+ },
+ {
+ content: 'test if the product sale line is present',
+ trigger: '.line_name:contains("400000 Product Sales")',
+ run: "click",
+ },
+ {
+ content: "click search",
+ trigger: '.o_searchview_input',
+ run: 'click',
+ },
+ {
+ content: 'insert text in the search bar',
+ trigger: '.o_searchview_input',
+ run: "edit Account",
+ },
+ {
+ content: 'test if the receivable line is present',
+ trigger: '.line_name:contains("121000 Account Receivable")',
+ run: 'click',
+ },
+ {
+ content: 'check that the product sale line is not present',
+ trigger: '.line_name:not(:contains("400000 Product Sales"))',
+ },
+ ]
+});
diff --git a/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_sections.js b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_sections.js
new file mode 100644
index 0000000..b12984c
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_sections.js
@@ -0,0 +1,129 @@
+/** @odoo-module **/
+
+const { DateTime } = luxon;
+
+import { Asserts } from "./asserts";
+import { registry } from "@web/core/registry";
+
+registry.category("web_tour.tours").add('account_reports_sections', {
+ url: "/odoo/action-account_reports.action_account_report_gt",
+ steps: () => [
+ {
+ content: "Open variant selector",
+ trigger: "#filter_variant button",
+ run: 'click',
+ },
+ {
+ content: "Select the test variant using sections",
+ trigger: ".dropdown-item:contains('Test Sections')",
+ run: 'click',
+ },
+ {
+ content: "Check the lines of section 1 are displayed",
+ trigger: ".line_name:contains('Section 1 line')",
+ },
+ {
+ content: "Check the columns of section 1 are displayed",
+ trigger: "#table_header th:last():contains('Column 1')",
+ },
+ {
+ content: "Check the export buttons belong to the composite report",
+ trigger: ".btn:contains('composite_report_custom_button')",
+ },
+ {
+ content: "Check the filters displayed belong to section 1 (journals filter is not enabled on section 2, nor the composite report)",
+ trigger: "#filter_journal",
+ },
+ {
+ content: "Check the date chosen by default",
+ trigger: "#filter_date",
+ run: (actionHelper) => {
+ // Generic tax report opens on the previous period and in this case the period is one month.
+ // And since we are using the generic tax report, we need to go back one month.
+ const previousMonth = DateTime.now().minus({months: 1});
+
+ Asserts.isTrue(actionHelper.anchor.getElementsByTagName('button')[0].innerText.includes(previousMonth.year));
+ },
+ },
+ {
+ content: "Switch to section 2",
+ trigger: "#section_selector .btn:contains('Section 2')",
+ run: 'click',
+ },
+ {
+ content: "Check the lines of section 2 are displayed",
+ trigger: ".line_name:contains('Section 2 line')",
+ },
+ {
+ content: "Check the columns of section 2 are displayed",
+ trigger: "#table_header th:last():contains('Column 2')",
+ },
+ {
+ content: "Check the export buttons belong to the composite report",
+ trigger: ".btn:contains('composite_report_custom_button')",
+ },
+ {
+ content: "Check the filters displayed belong to section 2 (comparison filter is not enabled on section 1, nor the composite report)",
+ trigger: "#filter_comparison",
+ },
+ {
+ content: "Open date switcher",
+ trigger: "#filter_date button",
+ run: 'click',
+ },
+ {
+ content: "Select another date in the future",
+ trigger: ".dropdown-menu span.dropdown-item:nth-child(3) .btn_next_date",
+ run: 'click'
+ },
+ {
+ content: "Apply filter by closing the dropdown for the future date",
+ trigger: "#filter_date .btn:first()",
+ run: "click",
+ },
+ {
+ content: "Check that the date has changed",
+ trigger: `#filter_date button:not(:contains(${ DateTime.now().minus({months: 1}).year }))`, // We need to remove one month for the case where we are in january. It will impact the year.
+ run: (actionHelper) => {
+ const nextYear = DateTime.now().plus({years: 1}).year;
+
+ Asserts.isTrue(actionHelper.anchor.innerText.includes(nextYear));
+ },
+ },
+ {
+ content: "Open date switcher",
+ trigger: "#filter_date button",
+ run: 'click',
+ },
+ {
+ content: "Select another date first time",
+ trigger: ".dropdown-menu span.dropdown-item:nth-child(3) .btn_previous_date",
+ run: 'click'
+ },
+ {
+ trigger: `.dropdown-menu span.dropdown-item:nth-child(3) time:contains(${ DateTime.now().year})`,
+ },
+ {
+ content: "Select another date second time",
+ trigger: ".dropdown-menu span.dropdown-item:nth-child(3) .btn_previous_date",
+ run: 'click'
+ },
+ {
+ trigger: `.dropdown-menu span.dropdown-item:nth-child(3) time:contains(${ DateTime.now().minus({years: 1}).year })`,
+ },
+ {
+ content: "Apply filter by closing the dropdown",
+ trigger: "#filter_date .btn:first()",
+ run: "click",
+ },
+ {
+ content: "Check that the date has changed",
+ trigger: `#filter_date button:contains(${ DateTime.now().minus({years: 1}).year })`,
+ },
+ {
+ content: "Switch back to section 1",
+ trigger: "#section_selector .btn:contains('Section 1')",
+ run: 'click',
+ },
+ ]
+});
diff --git a/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_widgets.js b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_widgets.js
new file mode 100644
index 0000000..24d3ffa
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/account_reports_widgets.js
@@ -0,0 +1,82 @@
+import { registry } from "@web/core/registry";
+
+registry.category("web_tour.tours").add("account_reports_widgets", {
+ url: "/odoo/action-account_reports.action_account_report_pl",
+ steps: () => [
+ {
+ content: "change date filter",
+ trigger: "#filter_date button",
+ run: "click",
+ },
+ {
+ content: "Select another date in the future",
+ trigger: ".dropdown-menu span.dropdown-item:nth-child(3) .btn_next_date",
+ run: 'click'
+ },
+ {
+ content: "Apply filter by closing the dropdown",
+ trigger: "#filter_date .btn:first()",
+ run: "click",
+ },
+ {
+ content: "wait refresh",
+ trigger: `#filter_date button:not(:contains(${ new Date().getFullYear() }))`,
+ },
+ {
+ content: "change date filter for the second time",
+ trigger: "#filter_date button",
+ run: "click",
+ },
+ {
+ content: "Select another date in the past first time",
+ trigger: ".dropdown-menu span.dropdown-item:nth-child(3) .btn_previous_date",
+ run: 'click'
+ },
+ {
+ trigger: `.dropdown-menu span.dropdown-item:nth-child(3) time:contains(${new Date().getFullYear()})`,
+ },
+ {
+ content: "Select another date in the past second time",
+ trigger: ".dropdown-menu span.dropdown-item:nth-child(3) .btn_previous_date",
+ run: 'click'
+ },
+ {
+ trigger: `.dropdown-menu span.dropdown-item:nth-child(3) time:contains(${
+ new Date().getFullYear() - 1
+ })`,
+ },
+ {
+ content: "Apply filter by closing the dropdown",
+ trigger: "#filter_date .btn:first()",
+ run: "click",
+ },
+ {
+ content: "wait refresh",
+ trigger: `#filter_date button:contains(${ new Date().getFullYear() - 1 })`,
+ },
+ {
+ content: "change comparison filter",
+ trigger: "#filter_comparison .btn:first()",
+ run: "click",
+ },
+ {
+ content: "change comparison filter",
+ trigger: ".dropdown-item.period:first()",
+ run: "click",
+ },
+ {
+ content: "Apply filter by closing the dropdown",
+ trigger: "#filter_comparison .btn:first()",
+ run: "click",
+ },
+ {
+ content: "wait refresh, report should have 4 columns",
+ trigger: "th + th + th + th",
+ },
+ {
+ content: "export xlsx",
+ trigger: "button:contains('XLSX')",
+ run: "click",
+ },
+ ],
+});
diff --git a/dev_odex30_accounting/odex30_account_reports/static/tests/tours/asserts.js b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/asserts.js
new file mode 100644
index 0000000..a9371bc
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/asserts.js
@@ -0,0 +1,115 @@
+/** @odoo-module **/
+
+//----------------------------------------------------------------------------------------------------------------------
+// This class provides some helpers function to do assertions on tours
+//----------------------------------------------------------------------------------------------------------------------
+export class Asserts {
+ //------------------------------------------------------------------------------------------------------------------
+ // Helpers
+ //------------------------------------------------------------------------------------------------------------------
+ // Gets the number of 'selector' element inside 'target' element
+ static getCount(target, selector) {
+ return document.querySelector(target).querySelectorAll(selector).length;
+ }
+ // Gets the number of 'selector' element inside DOM
+ static getDOMCount(selector) {
+ return document.querySelectorAll(selector).length;
+ }
+ static check(condition, success, error) {
+ condition ? Asserts.success(success) : Asserts.error(error);
+ }
+ static success(message) {
+ return console.info(`SUCCESS: ${message}`);
+ }
+ static error(message) {
+ throw new Error(`FAIL: ${message}`);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Asserts
+ //------------------------------------------------------------------------------------------------------------------
+ static isTrue(actual) {
+ Asserts.check(actual, `${actual} is true`, `${actual} is not true`);
+ }
+ static isFalse(actual) {
+ Asserts.check(!actual, `${actual} is false`, `${actual} is not false`);
+ }
+ // Assert that 'actual' and 'expected' are equal
+ static isEqual(actual, expected) {
+ Asserts.check(
+ (actual == expected),
+ `${actual} is equal to expected ${expected}`,
+ `${actual} is not equal to expected ${expected}`
+ );
+ }
+ // Asserts that 'actual' and 'expected' are strictly equal
+ static isStrictEqual(actual, expected) {
+ Asserts.check(
+ (actual === expected),
+ `${actual} is strictly equal to expected ${expected}`,
+ `${actual} is not strictly equal to expected ${expected}`
+ );
+ }
+ // Assert that 'target' element contains at least one 'selector' element
+ static contains(target, selector) {
+ const count = Asserts.getCount(target, selector);
+ Asserts.check(
+ (count > 0),
+ `There is at least one ${selector} in ${target}`,
+ `There should be at least one ${selector} in ${target} but there is ${count}`
+ );
+ }
+ // Asserts there is no 'selector' element in 'target' element
+ static containsNone(target, selector) {
+ const count = Asserts.getCount(target, selector);
+ Asserts.check(
+ (count === 0),
+ `There is no ${selector} in ${target}`,
+ `There should be no ${selector} in ${target} but there is ${count}`
+ );
+ }
+ // Asserts that 'target' element contains 'number' of 'selector' elements
+ static containsNumber(target, selector, number) {
+ const count = Asserts.getCount(target, selector);
+ Asserts.check(
+ (count === number),
+ `There is the correct number (${number}) of ${selector} in ${target}`,
+ `There should be at ${number} ${selector} in ${target} but there is ${count}`
+ );
+ }
+ // Asserts that DOM contains at least one 'selector' element
+ static DOMContains(selector) {
+ const count = Asserts.getDOMCount(selector);
+ Asserts.check(
+ (count > 0),
+ `There is at least one ${selector} in the DOM`,
+ `There should be at least one ${selector} in the DOM but there is ${count}`
+ );
+ }
+ // Asserts there is no 'selector' element in the DOM
+ static DOMContainsNone(selector) {
+ const count = Asserts.getDOMCount(selector);
+ Asserts.check(
+ (count === 0),
+ `There is no ${selector} in the DOM`,
+ `There should be 0 ${selector} in the DOM but there is ${count}`
+ );
+ }
+ // Asserts that DOM contains 'number' of 'selector' element
+ static DOMContainsNumber(selector, number) {
+ const count = Asserts.getDOMCount(selector);
+ Asserts.check(
+ (Asserts.getDOMCount(selector) === number),
+ `There is the correct number (${number}) of ${selector} in the DOM`,
+ `There should be ${number} ${selector} in the DOM but there is ${count}`
+ );
+ }
+ // Asserts that 'selector' element has class 'classname'
+ static hasClass(selector, classname) {
+ Asserts.check(
+ document.querySelector(selector).classList.contains(classname),
+ `${selector} has class ${classname}`,
+ `${selector} should have class ${classname} but hasn't`
+ );
+ }
+}
diff --git a/dev_odex30_accounting/odex30_account_reports/static/tests/tours/test_tour_bank_rec_ui.js b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/test_tour_bank_rec_ui.js
new file mode 100644
index 0000000..a84d265
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/tests/tours/test_tour_bank_rec_ui.js
@@ -0,0 +1,25 @@
+import { patch } from "@web/core/utils/patch";
+import { accountTourSteps } from "@account/js/tours/account";
+
+patch(accountTourSteps, {
+ bankRecUiReportSteps() {
+ return [
+ {
+ trigger: ".o_bank_rec_selected_st_line:contains('line1')",
+ },
+ {
+ content: "balance is 2100",
+ trigger: ".btn-link:contains('$ 2,100.00')",
+ run: "click",
+ },
+ {
+ trigger: "span:contains('General Ledger')",
+ },
+ {
+ content: "Breadcrumb back to Bank Reconciliation from the report",
+ trigger: ".breadcrumb-item a:contains('Bank Reconciliation')",
+ run: "click",
+ },
+ ];
+ },
+});
diff --git a/dev_odex30_accounting/odex30_account_reports/static/tests/util.test.js b/dev_odex30_accounting/odex30_account_reports/static/tests/util.test.js
new file mode 100644
index 0000000..d98fdf3
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/static/tests/util.test.js
@@ -0,0 +1,56 @@
+import { expect, test } from "@odoo/hoot";
+
+import { buildLineId, parseLineId, removeTaxGroupingFromLineId } from "@odex30_account_reports/js/util";
+
+test("can build a line id from a list of [markup, res_model, res_id]", () => {
+ const values = [
+ [false, "account.account", 72],
+ [null, "account.move", "10"],
+ [undefined, "account.move.line", "22"],
+ ["name", false, null],
+ ['{"groupby": "account"}', false, null],
+ ];
+ expect(buildLineId(values)).toBe(
+ '~account.account~72|~account.move~10|~account.move.line~22|name~~|{"groupby": "account"}~~'
+ );
+});
+
+test("can parse a line id from a generic id with a markup as object", () => {
+ const genericId =
+ '~account.account~72|~account.move~10|~account.move.line~22|name~~|{"groupby": "account"}~~';
+ expect(parseLineId(genericId)).toEqual([
+ [null, "account.account", 72],
+ [null, "account.move", 10],
+ [null, "account.move.line", 22],
+ ["name", null, null],
+ [{ groupby: "account" }, null, null],
+ ]);
+});
+
+test("can parse a line id from a generic id with a markup as string", () => {
+ const genericId =
+ '~account.account~72|~account.move~10|~account.move.line~22|name~~|{"groupby": "account"}~~';
+ expect(parseLineId(genericId, true)).toEqual([
+ [null, "account.account", 72],
+ [null, "account.move", 10],
+ [null, "account.move.line", 22],
+ ["name", null, null],
+ ['{"groupby": "account"}', null, null],
+ ]);
+});
+
+test("can parse and rebuild a line id to have the same one", () => {
+ const genericId =
+ '~account.account~72|~account.move~10|~account.move.line~22|name~~|{"groupby": "account"}~~';
+ const parsedLineId = parseLineId(genericId);
+ const buildedGenericId = buildLineId(parsedLineId);
+ expect(buildedGenericId).toBe(genericId);
+});
+
+test("can remove tax grouping by account group", () => {
+ const genericId =
+ '{"groupby": "account_group_id"}~account.group~22|~account.account~21|~account.move.line~20';
+ expect(removeTaxGroupingFromLineId(genericId)).toBe(
+ "~account.account~21|~account.move.line~20"
+ );
+});
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/__init__.py b/dev_odex30_accounting/odex30_account_reports/tests/__init__.py
new file mode 100644
index 0000000..4d3e550
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/__init__.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+
+from . import common
+from . import account_sales_report_common
+from . import test_account_reports_annotations_export
+from . import test_account_reports_filters
+from . import test_account_reports_journal_filter
+from . import test_account_reports_tax_reminder
+from . import test_account_reports_tours
+from . import test_account_sales_report_generic
+from . import test_general_ledger_report
+from . import test_trial_balance_report
+from . import test_partner_ledger_report
+from . import test_reconciliation_report
+from . import test_aged_receivable_report
+from . import test_aged_payable_report
+from . import test_tax_report
+from . import test_tax_report_default_part
+from . import test_cash_flow_report
+from . import test_financial_report
+from . import test_multicurrencies_revaluation_report
+from . import test_tour_account_reports
+from . import test_tour_analytic_filters
+from . import test_tax_report_carryover
+from . import test_balance_sheet_report
+from . import test_balance_sheet_balanced
+from . import test_journal_report
+from . import test_report_engines
+from . import test_all_reports_generation
+from . import test_analytic_reports
+from . import test_deferred_reports
+from . import test_report_sections
+from . import test_budget
+from . import test_currency_table
+from . import test_followup_report
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/account_sales_report_common.py b/dev_odex30_accounting/odex30_account_reports/tests/account_sales_report_common.py
new file mode 100644
index 0000000..d5ba556
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/account_sales_report_common.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+from odoo import fields
+from odoo.addons.odex30_account_reports.tests.common import TestAccountReportsCommon
+
+
+class AccountSalesReportCommon(TestAccountReportsCommon):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.partner_a.write({
+ 'name': 'Partner A',
+ 'country_id': cls.env.ref('base.fr').id,
+ "vat": "FR23334175221",
+ })
+ cls.partner_b = cls.env['res.partner'].create({
+ 'name': 'Partner B',
+ 'country_id': cls.env.ref('base.be').id,
+ "vat": "BE0477472701",
+ })
+ cls.company.partner_id.update({
+ 'email': 'jsmith@mail.com',
+ 'phone': '+32475123456',
+ })
+
+ def _create_invoices(self, data, is_refund=False):
+ move_vals_list = []
+ for partner, tax, price_unit in data:
+ move_vals_list.append({
+ 'move_type': 'out_refund' if is_refund else 'out_invoice',
+ 'partner_id': partner.id,
+ 'invoice_date': fields.Date.from_string('2019-12-01'),
+ 'invoice_line_ids': [
+ (0, 0, {
+ 'name': 'line_1',
+ 'price_unit': price_unit,
+ 'quantity': 1.0,
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'tax_ids': [(6, 0, tax.ids)],
+ }),
+ ],
+ })
+ moves = self.env['account.move'].create(move_vals_list)
+ moves.action_post()
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/common.py b/dev_odex30_accounting/odex30_account_reports/tests/common.py
new file mode 100644
index 0000000..9477ce4
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/common.py
@@ -0,0 +1,399 @@
+
+import copy
+import io
+import unittest
+from collections import Counter
+from datetime import datetime, date
+
+try:
+ from openpyxl import load_workbook
+except ImportError:
+ load_workbook = None
+
+from odoo.addons.account.tests.common import AccountTestInvoicingCommon
+
+from odoo import Command, fields
+from odoo.exceptions import UserError
+from odoo.tools import DEFAULT_SERVER_DATE_FORMAT
+from odoo.tools.misc import formatLang, file_open
+
+class TestAccountReportsCommon(AccountTestInvoicingCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.other_currency = cls.setup_other_currency('CAD')
+ cls.company_data_2 = cls.setup_other_company()
+ cls.company_data_2['company'].currency_id = cls.other_currency
+ cls.company_data_2['currency'] = cls.other_currency
+
+ @classmethod
+ def _generate_options(cls, report, date_from, date_to, default_options=None):
+ ''' Create new options at a certain date.
+ :param report: The report.
+ :param date_from: A datetime object, str representation of a date or False.
+ :param date_to: A datetime object or str representation of a date.
+ :return: The newly created options.
+ '''
+ if isinstance(date_from, datetime):
+ date_from_str = fields.Date.to_string(date_from)
+ else:
+ date_from_str = date_from
+
+ if isinstance(date_to, datetime):
+ date_to_str = fields.Date.to_string(date_to)
+ else:
+ date_to_str = date_to
+
+ if not default_options:
+ default_options = {}
+
+ return report.get_options({
+ 'selected_variant_id': report.id,
+ 'date': {
+ 'date_from': date_from_str,
+ 'date_to': date_to_str,
+ 'mode': 'range',
+ 'filter': 'custom',
+ },
+ 'show_account': True,
+ 'show_currency': True,
+ **default_options,
+ })
+
+ def _update_comparison_filter(self, options, report, comparison_type, number_period, date_from=None, date_to=None):
+ ''' Modify the existing options to set a new filter_comparison.
+ :param options: The report options.
+ :param report: The report.
+ :param comparison_type: One of the following values: ('no_comparison', 'custom', 'previous_period', 'previous_year').
+ :param number_period: The number of period to compare.
+ :param date_from: A datetime object for the 'custom' comparison_type.
+ :param date_to: A datetime object the 'custom' comparison_type.
+ :return: The newly created options.
+ '''
+ previous_options = {**options, 'comparison': {
+ **options['comparison'],
+ 'date_from': date_from and date_from.strftime(DEFAULT_SERVER_DATE_FORMAT),
+ 'date_to': date_to and date_to.strftime(DEFAULT_SERVER_DATE_FORMAT),
+ 'filter': comparison_type,
+ 'number_period': number_period,
+ }}
+ return report.get_options(previous_options)
+
+ def _update_multi_selector_filter(self, options, option_key, selected_ids):
+ ''' Modify a selector in the options to select .
+ :param options: The report options.
+ :param option_key: The key to the option.
+ :param selected_ids: The ids to be selected.
+ :return: The newly created options.
+ '''
+ new_options = copy.deepcopy(options)
+ for c in new_options[option_key]:
+ c['selected'] = c['id'] in selected_ids
+ return new_options
+
+ def assertColumnPercentComparisonValues(self, lines, expected_values):
+ filtered_lines = self._filter_folded_lines(lines)
+
+ # Check number of lines.
+ self.assertEqual(len(filtered_lines), len(expected_values))
+
+ for value, expected_value in zip(filtered_lines, expected_values):
+ # Check number of columns.
+ key = 'column_percent_comparison_data'
+ self.assertEqual(len(value[key]) + 1, len(expected_value))
+ # Check name, value and class.
+ self.assertEqual((value['name'], value[key]['name'], value[key]['mode']), expected_value)
+
+ def assertHorizontalGroupTotal(self, lines, expected_values):
+ filtered_lines = self._filter_folded_lines(lines)
+
+ # Check number of lines.
+ self.assertEqual(len(filtered_lines), len(expected_values))
+ for line_dict_list, expected_values in zip(filtered_lines, expected_values):
+ column_values = [column['no_format'] for column in line_dict_list['columns']]
+ # Compare the Total column, Total column is there only under certain condition
+ if line_dict_list.get('horizontal_group_total_data'):
+ self.assertEqual(len(line_dict_list['columns']) + 1, len(expected_values[1:]))
+ # Compare the numbers column except the total
+ self.assertEqual(column_values, list(expected_values[1:-1]))
+ # Compare the total column
+ self.assertEqual(line_dict_list['horizontal_group_total_data']['no_format'], expected_values[-1])
+ else:
+ # No total column
+ self.assertEqual(len(line_dict_list['columns']), len(expected_values[1:]))
+ self.assertEqual(column_values, list(expected_values[1:]))
+
+ def assertHeadersValues(self, headers, expected_headers):
+ ''' Helper to compare the headers returned by the _get_table method
+ with some expected results.
+ An header is a row of columns. Then, headers is a list of list of dictionary.
+ :param headers: The headers to compare.
+ :param expected_headers: The expected headers.
+ :return:
+ '''
+ # Check number of header lines.
+ self.assertEqual(len(headers), len(expected_headers))
+
+ for header, expected_header in zip(headers, expected_headers):
+ # Check number of columns.
+ self.assertEqual(len(header), len(expected_header))
+
+ for i, column in enumerate(header):
+ # Check name.
+ self.assertEqual(column['name'], self._convert_str_to_date(column['name'], expected_header[i]))
+
+ def assertIdenticalLines(self, reports):
+ """Helper to compare report lines with the same `code` across multiple reports.
+ The helper checks the lines for similarity on:
+ - number of expressions
+ - expression label
+ - expression engine
+ - expression formula
+ - expression subformula
+ - expression date_scope
+
+ :param reports: (recordset of account.report) The reports to check
+ """
+ def expression_to_comparable_values(expr):
+ return (
+ expr.label,
+ expr.engine,
+ expr.formula,
+ expr.subformula,
+ expr.date_scope
+ )
+
+ if not reports:
+ raise UserError('There are no reports to compare.')
+ visited_line_codes = set()
+ for line in reports.line_ids:
+ if not line.code or line.code in visited_line_codes:
+ continue
+ identical_lines = reports.line_ids.filtered(lambda l: l != line and l.code == line.code)
+ if not identical_lines:
+ continue
+ with self.subTest(line_code=line.code):
+ for tested_line in identical_lines:
+ self.assertCountEqual(
+ line.expression_ids.mapped(expression_to_comparable_values),
+ tested_line.expression_ids.mapped(expression_to_comparable_values),
+ (
+ f'The line with code {line.code} from reports "{line.report_id.name}" and '
+ f'"{tested_line.report_id.name}" has different expression values in both reports.'
+ )
+ )
+ visited_line_codes.add(line.code)
+
+ def assertLinesValues(self, lines, columns, expected_values, options, currency_map=None, ignore_folded=True):
+ ''' Helper to compare the lines returned by the _get_lines method
+ with some expected results and ensuring the 'id' key of each line holds a unique value.
+ :param lines: See _get_lines.
+ :param columns: The columns index.
+ :param expected_values: A list of iterables.
+ :param options: The options from the current report.
+ :param currency_map: A map mapping each column_index to some extra options to test the lines:
+ - currency: The currency to be applied on the column.
+ - currency_code_index: The index of the column containing the currency code.
+ :param ignore_folded: Will not filter folded lines when True.
+ '''
+ if currency_map is None:
+ currency_map = {}
+
+ filtered_lines = self._filter_folded_lines(lines) if ignore_folded else lines
+
+ # Compare the table length to see if any line is missing
+ self.assertEqual(len(filtered_lines), len(expected_values))
+
+ # Compare cell by cell the current value with the expected one.
+ to_compare_list = []
+ for i, line in enumerate(filtered_lines):
+ compared_values = [[], []]
+ for j, index in enumerate(columns):
+ if index == 0:
+ current_value = line['name']
+ else:
+ # Some lines may not have columns, like title lines. In such case, no values should be provided for these.
+ # Note that the function expect a tuple, so the line still need a comma after the name value.
+ if j > len(expected_values[i]) - 1:
+ break
+ current_value = line['columns'][index-1].get('name', '')
+ current_figure_type = line['columns'][index - 1].get('figure_type', '')
+
+ expected_value = expected_values[i][j]
+ currency_data = currency_map.get(index, {})
+ used_currency = None
+ if 'currency' in currency_data:
+ used_currency = currency_data['currency']
+ elif 'currency_code_index' in currency_data:
+ currency_code = line['columns'][currency_data['currency_code_index'] - 1].get('name', '')
+ if currency_code:
+ used_currency = self.env['res.currency'].search([('name', '=', currency_code)], limit=1)
+ assert used_currency, "Currency having name=%s not found." % currency_code
+ if not used_currency:
+ used_currency = self.env.company.currency_id
+
+ if type(expected_value) in (int, float) and type(current_value) == str:
+ if current_figure_type and current_figure_type != 'monetary':
+ expected_value = str(expected_value)
+ elif options.get('multi_currency'):
+ expected_value = formatLang(self.env, expected_value, currency_obj=used_currency)
+ else:
+ expected_value = formatLang(self.env, expected_value, digits=used_currency.decimal_places)
+
+ compared_values[0].append(current_value)
+ compared_values[1].append(expected_value)
+
+ to_compare_list.append(compared_values)
+
+ errors = []
+ for i, to_compare in enumerate(to_compare_list):
+ if to_compare[0] != to_compare[1]:
+ errors += [
+ "\n==== Differences at index %s ====" % str(i),
+ "Current Values: %s" % str(to_compare[0]),
+ "Expected Values: %s" % str(to_compare[1]),
+ ]
+
+ id_counts = Counter(line['id'] for line in lines)
+ duplicate_ids = {k: v for k, v in id_counts.items() if v > 1}
+ if duplicate_ids:
+ index_to_id = [
+ f"index={index:<6} name={line.get('name', 'no line name?!')} \tline_id={line.get('id', 'no line id?!')}"
+ for index, line in enumerate(lines)
+ if line.get('id', 'no line id?!') in duplicate_ids
+ ]
+ errors += [
+ "\n==== There are lines sharing the same id ====",
+ "\n".join(index_to_id)
+ ]
+
+ if errors:
+ self.fail('\n'.join(errors))
+
+ def _filter_folded_lines(self, lines):
+ """ Children lines returned for folded lines (for example, totals below sections) should be ignored when comparing the results
+ in assertLinesValues (their parents are folded, so they are not shown anyway). This function returns a filtered version of lines
+ list, without the chilren of folded lines.
+ """
+ filtered_lines = []
+ folded_lines = set()
+ for line in lines:
+ if line.get('parent_id') in folded_lines:
+ folded_lines.add(line['id'])
+ else:
+ if line.get('unfoldable') and not line.get('unfolded'):
+ folded_lines.add(line['id'])
+ filtered_lines.append(line)
+ return filtered_lines
+
+ def _convert_str_to_date(self, ref, val):
+ if isinstance(ref, date) and isinstance(val, str):
+ return datetime.strptime(val, '%Y-%m-%d').date()
+ return val
+
+ @classmethod
+ def _create_tax_report_line(cls, name, report, tag_name=None, parent_line=None, sequence=None, code=None, formula=None):
+ """ Creates a tax report line
+ """
+ create_vals = {
+ 'name': name,
+ 'code': code,
+ 'report_id': report.id,
+ 'sequence': sequence,
+ 'expression_ids': [],
+ }
+ if tag_name and formula:
+ raise UserError("Can't use this helper to create a line with both tags and formula")
+ if tag_name:
+ create_vals['expression_ids'].append(Command.create({
+ "label": "balance",
+ "engine": "tax_tags",
+ "formula": tag_name,
+ }))
+ if parent_line:
+ create_vals['parent_id'] = parent_line.id
+ if formula:
+ create_vals['expression_ids'].append(Command.create({
+ "label": "balance",
+ "engine": "aggregation",
+ "formula": formula,
+ }))
+
+ return cls.env['account.report.line'].create(create_vals)
+
+ @classmethod
+ def _get_tag_ids(cls, sign, expressions, company=False):
+ """ Helper function to define tag ids for taxes """
+ return [(6, 0, cls.env['account.account.tag'].search([
+ ('applicability', '=', 'taxes'),
+ ('country_id.code', '=', (company or cls.env.company).account_fiscal_country_id.code),
+ ('name', 'in', [f"{sign}{f}" for f in expressions.mapped('formula')]),
+ ]).ids)]
+
+ @classmethod
+ def _get_basic_line_dict_id_from_report_line(cls, report_line):
+ """ Computes a full generic id for the provided report line (hence including the one of its parent as prefix), using no markup.
+ """
+ report = report_line.report_id
+ if report_line.parent_id:
+ parent_line_id = cls._get_basic_line_dict_id_from_report_line(report_line.parent_id)
+ return report._get_generic_line_id(report_line._name, report_line.id, parent_line_id=parent_line_id)
+
+ return report._get_generic_line_id(report_line._name, report_line.id)
+
+ @classmethod
+ def _get_basic_line_dict_id_from_report_line_ref(cls, report_line_xmlid):
+ """ Same as _get_basic_line_dict_id_from_report_line, but from the line's xmlid, for convenience in the tests.
+ """
+ return cls._get_basic_line_dict_id_from_report_line(cls.env.ref(report_line_xmlid))
+
+ @classmethod
+ def _get_audit_params_from_report_line(cls, options, report_line, report_line_dict, **kwargs):
+ return {
+ 'report_line_id': report_line.id,
+ 'calling_line_dict_id': report_line_dict['id'],
+ 'expression_label': 'balance',
+ 'column_group_key': next(iter(options['column_groups'])),
+ **kwargs,
+ }
+
+ def _report_compare_with_test_file(self, report, xml_file=None, test_xml=None):
+ report_xml = self.get_xml_tree_from_string(report['file_content'])
+ if xml_file and not test_xml:
+ with file_open(f"{self.test_module}/tests/expected_xmls/{xml_file}", 'rb') as fp:
+ test_xml = fp.read()
+ test_xml_tree = self.get_xml_tree_from_string(test_xml)
+ self.assertXmlTreeEqual(report_xml, test_xml_tree)
+
+ @classmethod
+ def _fill_tax_report_line_external_value(cls, target, amount, date):
+ cls.env['account.report.external.value'].create({
+ 'company_id': cls.company_data['company'].id,
+ 'target_report_expression_id': cls.env.ref(target).id,
+ 'name': 'Manual value',
+ 'date': fields.Date.from_string(date),
+ 'value': amount,
+ })
+
+ def _test_xlsx_file(self, file_content, expected_values):
+ """ Takes in the binary content of a xlsx file and a dict of expected values.
+ It will then parse the file in order to compare the values with the expected ones.
+ The expected values dict format is:
+ 'row_number': ['cell_1_val', 'cell_2_val', ...]
+
+ :param file_content: The binary content of the xlsx file
+ :param expected_values: The dict of expected values
+ """
+ if load_workbook is None:
+ raise unittest.SkipTest("openpyxl not available")
+
+ report_file = io.BytesIO(file_content)
+ xlsx = load_workbook(filename=report_file, data_only=True)
+ sheet = xlsx.worksheets[0]
+ sheet_values = list(sheet.values)
+
+ for row, values in expected_values.items():
+ row_values = [v if v is not None else '' for v in sheet_values[row]]
+ for row_value, expected_value in zip(row_values, values):
+ self.assertEqual(row_value, expected_value)
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_account_reports_annotations_export.py b/dev_odex30_accounting/odex30_account_reports/tests/test_account_reports_annotations_export.py
new file mode 100644
index 0000000..80f6106
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_account_reports_annotations_export.py
@@ -0,0 +1,136 @@
+import datetime
+import io
+import unittest
+
+from odoo import Command
+from odoo.tests import tagged
+from odoo.addons.odex30_account_reports.tests.common import TestAccountReportsCommon
+
+try:
+ from openpyxl import load_workbook
+except ImportError:
+ load_workbook = None
+
+
+@tagged('post_install', '-at_install')
+class TestAccountReportAnnotationsExport(TestAccountReportsCommon):
+ @classmethod
+ def setUpClass(cls):
+ if load_workbook is None:
+ raise unittest.SkipTest("openpyxl not available")
+
+ super().setUpClass()
+
+ cls.report = cls.env.ref('odex30_account_reports.balance_sheet')
+ cls.report.column_ids.sortable = True
+
+ # Get accounts
+ bank_default_account = cls.company_data["default_journal_bank"].default_account_id
+ tax_sale_default_account = cls.company_data["default_account_tax_sale"]
+ # Create move
+ move = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2024-06-10',
+ 'journal_id': cls.company_data['default_journal_cash'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 250.0, 'credit': 0.0, 'account_id': bank_default_account.id}),
+ (0, 0, {'debit': 0.0, 'credit': 250.0, 'account_id': tax_sale_default_account.id}),
+ ],
+ })
+ move.action_post()
+ # Get line_ids
+ line_id_ta = cls.report._get_generic_line_id('account.report.line', cls.env.ref('odex30_account_reports.account_financial_report_total_assets0').id)
+ line_id_ca = cls.report._get_generic_line_id('account.report.line', cls.env.ref('odex30_account_reports.account_financial_report_current_assets_view0').id, parent_line_id=line_id_ta)
+ line_id_ba = cls.report._get_generic_line_id('account.report.line', cls.env.ref('odex30_account_reports.account_financial_report_bank_view0').id, parent_line_id=line_id_ca)
+ line_id_bank = cls.report._get_generic_line_id('account.account', bank_default_account.id, markup={'groupby': 'account_id'}, parent_line_id=line_id_ba)
+ # Create annotation
+ date = datetime.datetime.strptime('2024-06-20', '%Y-%m-%d').date()
+ cls.report.write({
+ 'annotations_ids': [
+ Command.create({
+ 'line_id': cls.env['account.report.annotation']._remove_tax_grouping_from_line_id(line_id_bank),
+ 'text': 'Papa a vu le fifi de lolo',
+ 'date': date,
+ }),
+ ]
+ })
+
+ def read_xlsx_data(self, report_data):
+ report_file = io.BytesIO(report_data)
+ xlsx = load_workbook(filename=report_file, data_only=True)
+ sheet = xlsx.worksheets[0]
+ return list(sheet.values)
+
+ def test_annotations_export_no_comparison(self):
+ options = self._generate_options(self.report, '2024-06-01', '2024-06-30', default_options={'unfold_all': True})
+ report_data = self.report.export_to_xlsx(options)
+ export_content = self.read_xlsx_data(report_data['file_content'])
+
+ # When there is no comparison, there are two columns for the accounts (number, name), one column for the data and
+ # finally one column for the annotations. Hence the index 3 for the annotation column.
+ self.assertEqual(export_content[0][3], "Annotations")
+ self.assertEqual(export_content[7][3], "1 - Papa a vu le fifi de lolo")
+
+ def test_annotations_export_same_period_last_year_comparison(self):
+ default_comparison = {
+ 'filter': 'same_last_year',
+ 'number_period': 1,
+ 'date_from': '2023-06-01',
+ 'date_to': '2023-06-30',
+ 'periods': [{
+ 'string': 'As of 06/30/2023',
+ 'period_type': 'month', 'mode':
+ 'single', 'date_from': '2023-06-01',
+ 'date_to': '2023-06-30'
+ }],
+ 'period_order':
+ 'descending',
+ 'string': 'As of 06/30/2023',
+ 'period_type': 'month',
+ 'mode': 'single'
+ }
+ options = self._generate_options(self.report, '2024-06-01', '2024-06-30', default_options={'unfold_all': True, 'comparison': default_comparison})
+ report_data = self.report.export_to_xlsx(options)
+ export_content = self.read_xlsx_data(report_data['file_content'])
+
+ # When comparing with the same period last year, there are two columns for the accounts (number, name), two columns for the
+ # date (one per period), one column for the growth comparison percentage and finally one column for the annotations.
+ # Hence the index 5 for the annotation column.
+ self.assertEqual(export_content[0][5], "Annotations")
+ self.assertEqual(export_content[7][5], "1 - Papa a vu le fifi de lolo")
+
+ def test_annotations_export_two_last_periods_comparison(self):
+ default_comparison = {
+ 'filter': 'previous_period',
+ 'number_period': 2,
+ 'date_from': '2024-05-01',
+ 'date_to': '2024-05-31',
+ 'periods': [
+ {
+ 'string': 'As of 05/31/2024',
+ 'period_type': 'month',
+ 'mode': 'single',
+ 'date_from': '2024-05-01',
+ 'date_to': '2024-05-31'
+ },
+ {
+ 'string': 'As of 04/30/2024',
+ 'period_type': 'month',
+ 'mode': 'single',
+ 'date_from': '2024-04-01',
+ 'date_to': '2024-04-30'
+ }
+ ],
+ 'period_order': 'descending',
+ 'string': 'As of 05/31/2024',
+ 'period_type': 'month',
+ 'mode': 'single'
+ }
+ options = self._generate_options(self.report, '2024-06-01', '2024-06-30', default_options={'unfold_all': True, 'comparison': default_comparison})
+ report_data = self.report.export_to_xlsx(options)
+ export_content = self.read_xlsx_data(report_data['file_content'])
+
+ # When comparing with the two last periods, there are two columns for the accounts (number, name), three columns for the
+ # data (one per period) and finally one column for the annotations. Hence the index 5 for the annotation column.
+ self.assertEqual(export_content[0][5], "Annotations")
+ self.assertEqual(export_content[7][5], "1 - Papa a vu le fifi de lolo")
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_account_reports_filters.py b/dev_odex30_accounting/odex30_account_reports/tests/test_account_reports_filters.py
new file mode 100644
index 0000000..65f1196
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_account_reports_filters.py
@@ -0,0 +1,1617 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=C0326
+import odoo.tests
+
+from odoo.tests import tagged
+from odoo import Command, fields
+from .common import TestAccountReportsCommon
+from odoo.tools import date_utils
+from odoo.tools.misc import formatLang, format_date
+
+from dateutil.relativedelta import relativedelta
+from unittest.mock import patch
+from freezegun import freeze_time
+
+
+@tagged('post_install', '-at_install')
+class TestAccountReportsFilters(TestAccountReportsCommon, odoo.tests.HttpCase):
+
+ def _assert_filter_date(self, report, previous_options, expected_date_values):
+ """ Initializes and checks the 'date' option computed for the provided report and previous_options
+ """
+ options = report.get_options(previous_options)
+ self.assertDictEqual(options['date'], expected_date_values)
+
+ def _assert_filter_comparison(self, report, previous_options, expected_period_values):
+ """ Initializes and checks the 'comparison' option computed for the provided report and previous_options
+ """
+ options = report.get_options(previous_options)
+
+ self.assertEqual(len(options['comparison']['periods']), len(expected_period_values))
+
+ for i, expected_values in enumerate(expected_period_values):
+ self.assertDictEqual(options['comparison']['periods'][i], expected_values)
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.single_date_report = cls.env['account.report'].create({
+ 'name': "Single Date Report",
+ 'filter_period_comparison': True,
+ 'filter_date_range': False,
+ })
+
+ cls.date_range_report = cls.env['account.report'].create({
+ 'name': "Date Range Report",
+ 'filter_period_comparison': True,
+ })
+
+ ####################################################
+ # DATES RANGE
+ ####################################################
+
+ @freeze_time('2017-12-31')
+ def test_filter_date_month_range(self):
+ ''' Test the filter_date with 'this_month'/'last_month' in 'range' mode.'''
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'this_month', 'mode': 'range'}},
+ {
+ 'string': 'Dec 2017',
+ 'period_type': 'month',
+ 'mode': 'range',
+ 'filter': 'this_month',
+ 'date_from': '2017-12-01',
+ 'date_to': '2017-12-31',
+ 'currency_table_period_key': '2017-12-01_2017-12-31',
+ },
+ )
+
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'previous_month', 'mode': 'range'}},
+ {
+ 'string': 'Nov 2017',
+ 'period_type': 'month',
+ 'mode': 'range',
+ 'filter': 'previous_month',
+ 'period': -1,
+ 'date_from': '2017-11-01',
+ 'date_to': '2017-11-30',
+ 'currency_table_period_key': '2017-11-01_2017-11-30',
+ },
+ )
+
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'next_month', 'mode': 'range'}},
+ {
+ 'string': 'Jan 2018',
+ 'period_type': 'month',
+ 'mode': 'range',
+ 'filter': 'next_month',
+ 'period': 1,
+ 'date_from': '2018-01-01',
+ 'date_to': '2018-01-31',
+ 'currency_table_period_key': '2018-01-01_2018-01-31',
+ },
+ )
+
+ self._assert_filter_comparison(
+ self.date_range_report,
+ {'date': {'filter': 'this_month', 'mode': 'range'}, 'comparison': {'filter': 'previous_period', 'number_period': 2}},
+ [
+ {
+ 'string': 'Nov 2017',
+ 'period_type': 'month',
+ 'mode': 'range',
+ 'date_from': '2017-11-01',
+ 'date_to': '2017-11-30',
+ 'currency_table_period_key': '2017-11-01_2017-11-30',
+ },
+ {
+ 'string': 'Oct 2017',
+ 'period_type': 'month',
+ 'mode': 'range',
+ 'date_from': '2017-10-01',
+ 'date_to': '2017-10-31',
+ 'currency_table_period_key': '2017-10-01_2017-10-31',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.date_range_report,
+ {'date': {'filter': 'this_month', 'mode': 'range'}, 'comparison': {'filter': 'same_last_year', 'number_period': 2}},
+ [
+ {
+ 'string': 'Dec 2016',
+ 'period_type': 'month',
+ 'mode': 'range',
+ 'date_from': '2016-12-01',
+ 'date_to': '2016-12-31',
+ 'currency_table_period_key': '2016-12-01_2016-12-31',
+ },
+ {
+ 'string': 'Dec 2015',
+ 'period_type': 'month',
+ 'mode': 'range',
+ 'date_from': '2015-12-01',
+ 'date_to': '2015-12-31',
+ 'currency_table_period_key': '2015-12-01_2015-12-31',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.date_range_report,
+ {'date': {'filter': 'this_month', 'mode': 'range'}, 'comparison':{'filter': 'custom', 'date_from': '2016-12-01', 'date_to': '2016-12-31'}},
+ [
+ {
+ 'string': 'Dec 2016',
+ 'period_type': 'month',
+ 'mode': 'range',
+ 'date_from': '2016-12-01',
+ 'date_to': '2016-12-31',
+ 'currency_table_period_key': '2016-12-01_2016-12-31',
+ },
+ ],
+ )
+
+ @freeze_time('2017-12-31')
+ def test_filter_date_quarter_range(self):
+ ''' Test the filter_date with 'this_quarter'/'last_quarter' in 'range' mode.'''
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'this_quarter', 'mode': 'range'}},
+ {
+ 'string': 'Oct - Dec 2017',
+ 'period_type': 'quarter',
+ 'mode': 'range',
+ 'filter': 'this_quarter',
+ 'date_from': '2017-10-01',
+ 'date_to': '2017-12-31',
+ 'currency_table_period_key': '2017-10-01_2017-12-31',
+ },
+ )
+
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'previous_quarter', 'mode': 'range'}},
+ {
+ 'string': 'Jul - Sep 2017',
+ 'period_type': 'quarter',
+ 'mode': 'range',
+ 'filter': 'previous_quarter',
+ 'period': -1,
+ 'date_from': '2017-07-01',
+ 'date_to': '2017-09-30',
+ 'currency_table_period_key': '2017-07-01_2017-09-30',
+ },
+ )
+
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'next_quarter', 'mode': 'range'}},
+ {
+ 'string': 'Jan - Mar 2018',
+ 'period_type': 'quarter',
+ 'mode': 'range',
+ 'filter': 'next_quarter',
+ 'period': 1,
+ 'date_from': '2018-01-01',
+ 'date_to': '2018-03-31',
+ 'currency_table_period_key': '2018-01-01_2018-03-31',
+ },
+ )
+
+ self._assert_filter_comparison(
+ self.date_range_report,
+ {'date': {'filter': 'this_quarter', 'mode': 'range'}, 'comparison': {'filter': 'previous_period', 'number_period': 2}},
+ [
+ {
+ 'string': 'Jul - Sep 2017',
+ 'period_type': 'quarter',
+ 'mode': 'range',
+ 'date_from': '2017-07-01',
+ 'date_to': '2017-09-30',
+ 'currency_table_period_key': '2017-07-01_2017-09-30',
+ },
+ {
+ 'string': 'Apr - Jun 2017',
+ 'period_type': 'quarter',
+ 'mode': 'range',
+ 'date_from': '2017-04-01',
+ 'date_to': '2017-06-30',
+ 'currency_table_period_key': '2017-04-01_2017-06-30',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.date_range_report,
+ {'date': {'filter': 'this_quarter', 'mode': 'range'}, 'comparison': {'filter': 'same_last_year', 'number_period': 2}},
+ [
+ {
+ 'string': 'Oct - Dec 2016',
+ 'period_type': 'quarter',
+ 'mode': 'range',
+ 'date_from': '2016-10-01',
+ 'date_to': '2016-12-31',
+ 'currency_table_period_key': '2016-10-01_2016-12-31',
+ },
+ {
+ 'string': 'Oct - Dec 2015',
+ 'period_type': 'quarter',
+ 'mode': 'range',
+ 'date_from': '2015-10-01',
+ 'date_to': '2015-12-31',
+ 'currency_table_period_key': '2015-10-01_2015-12-31',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.date_range_report,
+ {'date': {'filter': 'this_quarter', 'mode': 'range'}, 'comparison': {'filter': 'custom', 'date_from': '2016-10-01', 'date_to': '2016-12-31'}},
+ [
+ {
+ 'string': 'Oct - Dec 2016',
+ 'period_type': 'quarter',
+ 'mode': 'range',
+ 'date_from': '2016-10-01',
+ 'date_to': '2016-12-31',
+ 'currency_table_period_key': '2016-10-01_2016-12-31',
+ },
+ ],
+ )
+
+ @freeze_time('2017-12-31')
+ def test_filter_date_fiscalyear_range_full_year(self):
+ ''' Test the filter_date with 'this_year'/'last_year' in 'range' mode when the fiscal year ends the 12-31.'''
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'this_year', 'mode': 'range'}},
+ {
+ 'string': '2017',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'filter': 'this_year',
+ 'date_from': '2017-01-01',
+ 'date_to': '2017-12-31',
+ 'currency_table_period_key': '2017-01-01_2017-12-31',
+ },
+ )
+
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'previous_year', 'mode': 'range'}},
+ {
+ 'string': '2016',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'filter': 'previous_year',
+ 'period': -1,
+ 'date_from': '2016-01-01',
+ 'date_to': '2016-12-31',
+ 'currency_table_period_key': '2016-01-01_2016-12-31',
+ },
+ )
+
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'next_year', 'mode': 'range'}},
+ {
+ 'string': '2018',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'filter': 'next_year',
+ 'period': 1,
+ 'date_from': '2018-01-01',
+ 'date_to': '2018-12-31',
+ 'currency_table_period_key': '2018-01-01_2018-12-31',
+ },
+ )
+
+ self._assert_filter_comparison(
+ self.date_range_report,
+ {'date': {'filter': 'this_year', 'mode': 'range'}, 'comparison': {'filter': 'previous_period', 'number_period': 2}},
+ [
+ {
+ 'string': '2016',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'date_from': '2016-01-01',
+ 'date_to': '2016-12-31',
+ 'currency_table_period_key': '2016-01-01_2016-12-31',
+ },
+ {
+ 'string': '2015',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'date_from': '2015-01-01',
+ 'date_to': '2015-12-31',
+ 'currency_table_period_key': '2015-01-01_2015-12-31',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.date_range_report,
+ {'date': {'filter': 'this_year', 'mode': 'range'}, 'comparison': {'filter': 'same_last_year', 'number_period': 2}},
+ [
+ {
+ 'string': '2016',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'date_from': '2016-01-01',
+ 'date_to': '2016-12-31',
+ 'currency_table_period_key': '2016-01-01_2016-12-31',
+ },
+ {
+ 'string': '2015',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'date_from': '2015-01-01',
+ 'date_to': '2015-12-31',
+ 'currency_table_period_key': '2015-01-01_2015-12-31',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.date_range_report,
+ {'date': {'filter': 'this_year', 'mode': 'range'}, 'comparison': {'filter': 'custom', 'date_from': '2016-01-01', 'date_to': '2016-12-31'}},
+ [
+ {
+ 'string': '2016',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'date_from': '2016-01-01',
+ 'date_to': '2016-12-31',
+ 'currency_table_period_key': '2016-01-01_2016-12-31',
+ },
+ ],
+ )
+
+ @freeze_time('2017-12-31')
+ def test_filter_date_fiscalyear_range_overlap_years(self):
+ ''' Test the filter_date with 'this_year'/'last_year' in 'range' mode when the fiscal year overlaps 2 years.'''
+ self.env.company.fiscalyear_last_day = 30
+ self.env.company.fiscalyear_last_month = '6'
+
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'this_year', 'mode': 'range'}},
+ {
+ 'string': '2017 - 2018',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'filter': 'this_year',
+ 'date_from': '2017-07-01',
+ 'date_to': '2018-06-30',
+ 'currency_table_period_key': '2017-07-01_2018-06-30',
+ },
+ )
+
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'previous_year', 'mode': 'range'}},
+ {
+ 'string': '2016 - 2017',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'filter': 'previous_year',
+ 'period': -1,
+ 'date_from': '2016-07-01',
+ 'date_to': '2017-06-30',
+ 'currency_table_period_key': '2016-07-01_2017-06-30',
+ },
+ )
+
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'next_year', 'mode': 'range'}},
+ {
+ 'string': '2018 - 2019',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'filter': 'next_year',
+ 'period': 1,
+ 'date_from': '2018-07-01',
+ 'date_to': '2019-06-30',
+ 'currency_table_period_key': '2018-07-01_2019-06-30',
+ },
+ )
+
+ self._assert_filter_comparison(
+ self.date_range_report,
+ {'date': {'filter': 'this_year', 'mode': 'range'}, 'comparison': {'filter': 'previous_period', 'number_period': 2}},
+ [
+ {
+ 'string': '2016 - 2017',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'date_from': '2016-07-01',
+ 'date_to': '2017-06-30',
+ 'currency_table_period_key': '2016-07-01_2017-06-30',
+ },
+ {
+ 'string': '2015 - 2016',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'date_from': '2015-07-01',
+ 'date_to': '2016-06-30',
+ 'currency_table_period_key': '2015-07-01_2016-06-30',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.date_range_report,
+ {'date': {'filter': 'this_year', 'mode': 'range'}, 'comparison': {'filter': 'same_last_year', 'number_period': 2}},
+ [
+ {
+ 'string': '2016 - 2017',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'date_from': '2016-07-01',
+ 'date_to': '2017-06-30',
+ 'currency_table_period_key': '2016-07-01_2017-06-30',
+ },
+ {
+ 'string': '2015 - 2016',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'date_from': '2015-07-01',
+ 'date_to': '2016-06-30',
+ 'currency_table_period_key': '2015-07-01_2016-06-30',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.date_range_report,
+ {'date': {'filter': 'this_year', 'mode': 'range'}, 'comparison': {'filter': 'custom', 'date_from': '2016-07-01', 'date_to': '2017-06-30'}},
+ [
+ {
+ 'string': '2016 - 2017',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'date_from': '2016-07-01',
+ 'date_to': '2017-06-30',
+ 'currency_table_period_key': '2016-07-01_2017-06-30',
+ },
+ ],
+ )
+
+ @freeze_time('2017-12-31')
+ def test_filter_date_fiscalyear_range_custom_years(self):
+ ''' Test the filter_date with 'this_year'/'last_year' in 'range' mode with custom account.fiscal.year records.'''
+ # Create a custom fiscal year for the nine previous quarters.
+ today = fields.Date.from_string('2017-12-31')
+ for i in range(9):
+ quarter_df, quarter_dt = date_utils.get_quarter(today - relativedelta(months=i * 3))
+ self.env['account.fiscal.year'].create({
+ 'name': 'custom %s' % i,
+ 'date_from': fields.Date.to_string(quarter_df),
+ 'date_to': fields.Date.to_string(quarter_dt),
+ 'company_id': self.env.company.id,
+ })
+ quarter_df, quarter_dt = date_utils.get_quarter(today + relativedelta(months=3))
+ # Adding a next quarter
+ self.env['account.fiscal.year'].create({
+ 'name': 'custom next quarter',
+ 'date_from': fields.Date.to_string(quarter_df),
+ 'date_to': fields.Date.to_string(quarter_dt),
+ 'company_id': self.env.company.id,
+ })
+
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'next_year', 'mode': 'range'}},
+ {
+ 'string': 'custom next quarter',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'filter': 'next_year',
+ 'period': 1,
+ 'date_from': '2018-01-01',
+ 'date_to': '2018-03-31',
+ 'currency_table_period_key': '2018-01-01_2018-03-31',
+ },
+ )
+
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'this_year', 'mode': 'range'}},
+ {
+ 'string': 'custom 0',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'filter': 'this_year',
+ 'date_from': '2017-10-01',
+ 'date_to': '2017-12-31',
+ 'currency_table_period_key': '2017-10-01_2017-12-31',
+ },
+ )
+
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'previous_year', 'mode': 'range'}},
+ {
+ 'string': 'custom 1',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'filter': 'previous_year',
+ 'period': -1,
+ 'date_from': '2017-07-01',
+ 'date_to': '2017-09-30',
+ 'currency_table_period_key': '2017-07-01_2017-09-30',
+ },
+ )
+
+ self._assert_filter_comparison(
+ self.date_range_report,
+ {'date': {'filter': 'this_year', 'mode': 'range'}, 'comparison': {'filter': 'previous_period', 'number_period': 2}},
+ [
+ {
+ 'string': 'custom 1',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'date_from': '2017-07-01',
+ 'date_to': '2017-09-30',
+ 'currency_table_period_key': '2017-07-01_2017-09-30',
+ },
+ {
+ 'string': 'custom 2',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'date_from': '2017-04-01',
+ 'date_to': '2017-06-30',
+ 'currency_table_period_key': '2017-04-01_2017-06-30',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.date_range_report,
+ {'date': {'filter': 'this_year', 'mode': 'range'}, 'comparison': {'filter': 'same_last_year', 'number_period': 2}},
+ [
+ {
+ 'string': 'custom 4',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'date_from': '2016-10-01',
+ 'date_to': '2016-12-31',
+ 'currency_table_period_key': '2016-10-01_2016-12-31',
+ },
+ {
+ 'string': 'custom 8',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'date_from': '2015-10-01',
+ 'date_to': '2015-12-31',
+ 'currency_table_period_key': '2015-10-01_2015-12-31',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.date_range_report,
+ {'date': {'filter': 'this_year', 'mode': 'range'}, 'comparison': {'filter': 'custom', 'date_from': '2017-07-01', 'date_to': '2017-09-30'}},
+ [
+ {
+ 'string': 'custom 1',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'date_from': '2017-07-01',
+ 'date_to': '2017-09-30',
+ 'currency_table_period_key': '2017-07-01_2017-09-30',
+ },
+ ],
+ )
+
+ @freeze_time('2017-12-31')
+ def test_filter_date_custom_range(self):
+ ''' Test the filter_date with a custom dates range.'''
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'custom', 'mode': 'range', 'date_from': '2017-01-01', 'date_to': '2017-01-15'}},
+ {
+ 'string': 'From %s\nto %s' % (format_date(self.env, '2017-01-01'), format_date(self.env, '2017-01-15')),
+ 'period_type': 'custom',
+ 'mode': 'range',
+ 'filter': 'custom',
+ 'date_from': '2017-01-01',
+ 'date_to': '2017-01-15',
+ 'currency_table_period_key': '2017-01-01_2017-01-15',
+ },
+ )
+
+ self._assert_filter_comparison(
+ self.date_range_report,
+ {
+ 'date': {'filter': 'custom', 'mode': 'range', 'date_from': '2017-01-01', 'date_to': '2017-01-15'},
+ 'comparison': {'filter': 'previous_period', 'number_period': 2},
+ },
+ [
+ {
+ 'string': 'Dec 2016',
+ 'period_type': 'month',
+ 'mode': 'range',
+ 'date_from': '2016-12-01',
+ 'date_to': '2016-12-31',
+ 'currency_table_period_key': '2016-12-01_2016-12-31',
+ },
+ {
+ 'string': 'Nov 2016',
+ 'period_type': 'month',
+ 'mode': 'range',
+ 'date_from': '2016-11-01',
+ 'date_to': '2016-11-30',
+ 'currency_table_period_key': '2016-11-01_2016-11-30',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.date_range_report,
+ {
+ 'date': {'filter': 'custom', 'mode': 'range', 'date_from': '2017-01-01', 'date_to': '2017-01-15'},
+ 'comparison': {'filter': 'same_last_year', 'number_period': 2},
+ },
+ [
+ {
+ 'string': 'From %s\nto %s' % (format_date(self.env, '2016-01-01'), format_date(self.env, '2016-01-15')),
+ 'period_type': 'custom',
+ 'mode': 'range',
+ 'date_from': '2016-01-01',
+ 'date_to': '2016-01-15',
+ 'currency_table_period_key': '2016-01-01_2016-01-15',
+ },
+ {
+ 'string': 'From %s\nto %s' % (format_date(self.env, '2015-01-01'), format_date(self.env, '2015-01-15')),
+ 'period_type': 'custom',
+ 'mode': 'range',
+ 'date_from': '2015-01-01',
+ 'date_to': '2015-01-15',
+ 'currency_table_period_key': '2015-01-01_2015-01-15',
+ },
+ ],
+ )
+
+ @freeze_time('2017-12-31')
+ def test_filter_date_custom_range_recognition(self):
+ ''' Test the period is well recognized when dealing with custom dates range.
+ It means date_from = '2018-01-01', date_to = '2018-12-31' must be considered as a full year.
+ '''
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'custom', 'mode': 'range', 'date_from': '2017-12-01', 'date_to': '2017-12-31'}},
+ {
+ 'string': 'Dec 2017',
+ 'period_type': 'month',
+ 'mode': 'range',
+ 'filter': 'custom',
+ 'date_from': '2017-12-01',
+ 'date_to': '2017-12-31',
+ 'currency_table_period_key': '2017-12-01_2017-12-31',
+ },
+ )
+
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'custom', 'mode': 'range', 'date_from': '2017-10-01', 'date_to': '2017-12-31'}},
+ {
+ 'string': 'Oct - Dec 2017',
+ 'period_type': 'quarter',
+ 'mode': 'range',
+ 'filter': 'custom',
+ 'date_from': '2017-10-01',
+ 'date_to': '2017-12-31',
+ 'currency_table_period_key': '2017-10-01_2017-12-31',
+ },
+ )
+
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'custom', 'mode': 'range', 'date_from': '2017-01-01', 'date_to': '2017-12-31'}},
+ {
+ 'string': '2017',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'filter': 'custom',
+ 'date_from': '2017-01-01',
+ 'date_to': '2017-12-31',
+ 'currency_table_period_key': '2017-01-01_2017-12-31',
+ },
+ )
+
+ self.env.company.fiscalyear_last_day = 30
+ self.env.company.fiscalyear_last_month = '6'
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'custom', 'mode': 'range', 'date_from': '2016-07-01', 'date_to': '2017-06-30'}},
+ {
+ 'string': '2016 - 2017',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'filter': 'custom',
+ 'date_from': '2016-07-01',
+ 'date_to': '2017-06-30',
+ 'currency_table_period_key': '2016-07-01_2017-06-30',
+ },
+ )
+
+ self.env['account.fiscal.year'].create({
+ 'name': 'custom 0',
+ 'date_from': '2017-10-01',
+ 'date_to': '2017-12-31',
+ 'company_id': self.env.company.id,
+ })
+ self._assert_filter_date(
+ self.date_range_report,
+ {'date': {'filter': 'custom', 'mode': 'range', 'date_from': '2017-10-01', 'date_to': '2017-12-31'}},
+ {
+ 'string': 'custom 0',
+ 'period_type': 'fiscalyear',
+ 'mode': 'range',
+ 'filter': 'custom',
+ 'date_from': '2017-10-01',
+ 'date_to': '2017-12-31',
+ 'currency_table_period_key': '2017-10-01_2017-12-31',
+ },
+ )
+
+ ####################################################
+ # SINGLE DATE
+ ####################################################
+
+ @freeze_time('2017-12-30')
+ def test_filter_date_today_single(self):
+ ''' Test the filter_date with 'today' in 'single' mode.'''
+ self._assert_filter_date(
+ self.single_date_report,
+ {'date': {'filter': 'today', 'mode': 'single'}},
+ {
+ 'string': 'As of %s' % format_date(self.env, '2017-12-30'),
+ 'period_type': 'today',
+ 'mode': 'single',
+ 'filter': 'today',
+ 'date_from': '2017-01-01',
+ 'date_to': '2017-12-30',
+ 'currency_table_period_key': 'None_2017-12-30',
+ },
+ )
+
+ self._assert_filter_comparison(
+ self.single_date_report,
+ {'date': {'filter': 'today', 'mode': 'single'}, 'comparison': {'filter': 'previous_period', 'number_period': 2}},
+ [
+ {
+ 'string': 'As of %s' % format_date(self.env, '2016-12-31'),
+ 'period_type': 'fiscalyear',
+ 'mode': 'single',
+ 'date_from': '2016-01-01',
+ 'date_to': '2016-12-31',
+ 'currency_table_period_key': 'None_2016-12-31',
+ },
+ {
+ 'string': 'As of %s' % format_date(self.env, '2015-12-31'),
+ 'period_type': 'fiscalyear',
+ 'mode': 'single',
+ 'date_from': '2015-01-01',
+ 'date_to': '2015-12-31',
+ 'currency_table_period_key': 'None_2015-12-31',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.single_date_report,
+ {'date': {'filter': 'today', 'mode': 'single'}, 'comparison': {'filter': 'same_last_year', 'number_period': 2}},
+ [
+ {
+ 'string': 'As of %s' % format_date(self.env, '2016-12-30'),
+ 'period_type': 'today',
+ 'mode': 'single',
+ 'date_from': '2016-01-01',
+ 'date_to': '2016-12-30',
+ 'currency_table_period_key': 'None_2016-12-30',
+ },
+ {
+ 'string': 'As of %s' % format_date(self.env, '2015-12-30'),
+ 'period_type': 'today',
+ 'mode': 'single',
+ 'date_from': '2015-01-01',
+ 'date_to': '2015-12-30',
+ 'currency_table_period_key': 'None_2015-12-30',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.single_date_report,
+ {'date': {'filter': 'today', 'mode': 'single'}, 'comparison': {'filter': 'custom', 'date_to': '2016-12-31'}},
+ [
+ {
+ 'string': 'As of %s' % format_date(self.env, '2016-12-31'),
+ 'period_type': 'custom',
+ 'mode': 'single',
+ 'date_from': False,
+ 'date_to': '2016-12-31',
+ 'currency_table_period_key': 'None_2016-12-31',
+ },
+ ],
+ )
+
+ @freeze_time('2017-12-31')
+ def test_filter_date_month_single(self):
+ ''' Test the filter_date with 'this_month'/'last_month' in 'single' mode.'''
+ self._assert_filter_date(
+ self.single_date_report,
+ {'date': {'filter': 'this_month', 'mode': 'single'}},
+ {
+ 'string': 'As of %s' % format_date(self.env, '2017-12-31'),
+ 'period_type': 'month',
+ 'mode': 'single',
+ 'filter': 'this_month',
+ 'date_from': '2017-12-01',
+ 'date_to': '2017-12-31',
+ 'currency_table_period_key': 'None_2017-12-31',
+ },
+ )
+
+ self._assert_filter_comparison(
+ self.single_date_report,
+ {'date': {'filter': 'this_month', 'mode': 'single'}, 'comparison': {'filter': 'previous_period', 'number_period': 2}},
+ [
+ {
+ 'string': 'As of %s' % format_date(self.env, '2017-11-30'),
+ 'period_type': 'month',
+ 'mode': 'single',
+ 'date_from': '2017-11-01',
+ 'date_to': '2017-11-30',
+ 'currency_table_period_key': 'None_2017-11-30',
+ },
+ {
+ 'string': 'As of %s' % format_date(self.env, '2017-10-31'),
+ 'period_type': 'month',
+ 'mode': 'single',
+ 'date_from': '2017-10-01',
+ 'date_to': '2017-10-31',
+ 'currency_table_period_key': 'None_2017-10-31',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.single_date_report,
+ {'date': {'filter': 'this_month', 'mode': 'single'}, 'comparison': {'filter': 'same_last_year', 'number_period': 2}},
+ [
+ {
+ 'string': 'As of %s' % format_date(self.env, '2016-12-31'),
+ 'period_type': 'month',
+ 'mode': 'single',
+ 'date_from': '2016-12-01',
+ 'date_to': '2016-12-31',
+ 'currency_table_period_key': 'None_2016-12-31',
+ },
+ {
+ 'string': 'As of %s' % format_date(self.env, '2015-12-31'),
+ 'period_type': 'month',
+ 'mode': 'single',
+ 'date_from': '2015-12-01',
+ 'date_to': '2015-12-31',
+ 'currency_table_period_key': 'None_2015-12-31',
+ },
+ ],
+ )
+
+ @freeze_time('2017-12-31')
+ def test_filter_date_quarter_single(self):
+ ''' Test the filter_date with 'this_quarter'/'last_quarter' in 'single' mode.'''
+ self._assert_filter_date(
+ self.single_date_report,
+ {'date': {'filter': 'this_quarter', 'mode': 'single'}},
+ {
+ 'string': 'As of %s' % format_date(self.env, '2017-12-31'),
+ 'period_type': 'quarter',
+ 'mode': 'single',
+ 'filter': 'this_quarter',
+ 'date_from': '2017-10-01',
+ 'date_to': '2017-12-31',
+ 'currency_table_period_key': 'None_2017-12-31',
+ },
+ )
+
+ self._assert_filter_comparison(
+ self.single_date_report,
+ {'date': {'filter': 'this_quarter', 'mode': 'single'}, 'comparison': {'filter': 'previous_period', 'number_period': 2}},
+ [
+ {
+ 'string': 'As of %s' % format_date(self.env, '2017-09-30'),
+ 'period_type': 'quarter',
+ 'mode': 'single',
+ 'date_from': '2017-07-01',
+ 'date_to': '2017-09-30',
+ 'currency_table_period_key': 'None_2017-09-30',
+ },
+ {
+ 'string': 'As of %s' % format_date(self.env, '2017-06-30'),
+ 'period_type': 'quarter',
+ 'mode': 'single',
+ 'date_from': '2017-04-01',
+ 'date_to': '2017-06-30',
+ 'currency_table_period_key': 'None_2017-06-30',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.single_date_report,
+ {'date': {'filter': 'this_quarter', 'mode': 'single'}, 'comparison': {'filter': 'same_last_year', 'number_period': 2}},
+ [
+ {
+ 'string': 'As of %s' % format_date(self.env, '2016-12-31'),
+ 'period_type': 'quarter',
+ 'mode': 'single',
+ 'date_from': '2016-10-01',
+ 'date_to': '2016-12-31',
+ 'currency_table_period_key': 'None_2016-12-31',
+ },
+ {
+ 'string': 'As of %s' % format_date(self.env, '2015-12-31'),
+ 'period_type': 'quarter',
+ 'mode': 'single',
+ 'date_from': '2015-10-01',
+ 'date_to': '2015-12-31',
+ 'currency_table_period_key': 'None_2015-12-31',
+ },
+ ],
+ )
+
+ @freeze_time('2017-12-31')
+ def test_filter_date_fiscalyear_single_full_year(self):
+ ''' Test the filter_date with 'this_year'/'last_year' in 'single' mode when the fiscal year ends the 12-31.'''
+ self._assert_filter_date(
+ self.single_date_report,
+ {'date': {'filter': 'this_year', 'mode': 'single'}},
+ {
+ 'string': 'As of %s' % format_date(self.env, '2017-12-31'),
+ 'period_type': 'fiscalyear',
+ 'mode': 'single',
+ 'filter': 'this_year',
+ 'date_from': '2017-01-01',
+ 'date_to': '2017-12-31',
+ 'currency_table_period_key': 'None_2017-12-31',
+ },
+ )
+
+ self._assert_filter_comparison(
+ self.single_date_report,
+ {'date': {'filter': 'this_year', 'mode': 'single'}, 'comparison': {'filter': 'previous_period', 'number_period': 2}},
+ [
+ {
+ 'string': 'As of %s' % format_date(self.env, '2016-12-31'),
+ 'period_type': 'fiscalyear',
+ 'mode': 'single',
+ 'date_from': '2016-01-01',
+ 'date_to': '2016-12-31',
+ 'currency_table_period_key': 'None_2016-12-31',
+ },
+ {
+ 'string': 'As of %s' % format_date(self.env, '2015-12-31'),
+ 'period_type': 'fiscalyear',
+ 'mode': 'single',
+ 'date_from': '2015-01-01',
+ 'date_to': '2015-12-31',
+ 'currency_table_period_key': 'None_2015-12-31',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.single_date_report,
+ {'date': {'filter': 'this_year', 'mode': 'single'}, 'comparison': {'filter': 'same_last_year', 'number_period': 2}},
+ [
+ {
+ 'string': 'As of %s' % format_date(self.env, '2016-12-31'),
+ 'period_type': 'fiscalyear',
+ 'mode': 'single',
+ 'date_from': '2016-01-01',
+ 'date_to': '2016-12-31',
+ 'currency_table_period_key': 'None_2016-12-31',
+ },
+ {
+ 'string': 'As of %s' % format_date(self.env, '2015-12-31'),
+ 'period_type': 'fiscalyear',
+ 'mode': 'single',
+ 'date_from': '2015-01-01',
+ 'date_to': '2015-12-31',
+ 'currency_table_period_key': 'None_2015-12-31',
+ },
+ ],
+ )
+
+ @freeze_time('2017-12-31')
+ def test_filter_date_fiscalyear_single_overlap_years(self):
+ ''' Test the filter_date with 'this_year'/'last_year' in 'single' mode when the fiscal year overlaps 2 years.'''
+ self.env.company.fiscalyear_last_day = 30
+ self.env.company.fiscalyear_last_month = '6'
+
+ self._assert_filter_date(
+ self.single_date_report,
+ {'date': {'filter': 'this_year', 'mode': 'single'}},
+ {
+ 'string': 'As of %s' % format_date(self.env, '2018-06-30'),
+ 'period_type': 'fiscalyear',
+ 'mode': 'single',
+ 'filter': 'this_year',
+ 'date_from': '2017-07-01',
+ 'date_to': '2018-06-30',
+ 'currency_table_period_key': 'None_2018-06-30',
+ },
+ )
+
+ self._assert_filter_comparison(
+ self.single_date_report,
+ {'date': {'filter': 'this_year', 'mode': 'single'}, 'comparison': {'filter': 'previous_period', 'number_period': 2}},
+ [
+ {
+ 'string': 'As of %s' % format_date(self.env, '2017-06-30'),
+ 'period_type': 'fiscalyear',
+ 'mode': 'single',
+ 'date_from': '2016-07-01',
+ 'date_to': '2017-06-30',
+ 'currency_table_period_key': 'None_2017-06-30',
+ },
+ {
+ 'string': 'As of %s' % format_date(self.env, '2016-06-30'),
+ 'period_type': 'fiscalyear',
+ 'mode': 'single',
+ 'date_from': '2015-07-01',
+ 'date_to': '2016-06-30',
+ 'currency_table_period_key': 'None_2016-06-30',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.single_date_report,
+ {'date': {'filter': 'this_year', 'mode': 'single'}, 'comparison': {'filter': 'same_last_year', 'number_period': 2}},
+ [
+ {
+ 'string': 'As of %s' % format_date(self.env, '2017-06-30'),
+ 'period_type': 'fiscalyear',
+ 'mode': 'single',
+ 'date_from': '2016-07-01',
+ 'date_to': '2017-06-30',
+ 'currency_table_period_key': 'None_2017-06-30',
+ },
+ {
+ 'string': 'As of %s' % format_date(self.env, '2016-06-30'),
+ 'period_type': 'fiscalyear',
+ 'mode': 'single',
+ 'date_from': '2015-07-01',
+ 'date_to': '2016-06-30',
+ 'currency_table_period_key': 'None_2016-06-30',
+ },
+ ],
+ )
+
+ @freeze_time('2017-12-31')
+ def test_filter_date_fiscalyear_single_custom_years(self):
+ ''' Test the filter_date with 'this_year'/'last_year' in 'single' mode with custom account.fiscal.year records.'''
+ # Create a custom fiscal year for the nine previous quarters.
+ today = fields.Date.from_string('2017-12-31')
+ for i in range(9):
+ quarter_df, quarter_dt = date_utils.get_quarter(today - relativedelta(months=i * 3))
+ self.env['account.fiscal.year'].create({
+ 'name': 'custom %s' % i,
+ 'date_from': fields.Date.to_string(quarter_df),
+ 'date_to': fields.Date.to_string(quarter_dt),
+ 'company_id': self.env.company.id,
+ })
+
+ self._assert_filter_date(
+ self.single_date_report,
+ {'date': {'filter': 'this_year', 'mode': 'single'}},
+ {
+ 'string': 'custom 0',
+ 'period_type': 'fiscalyear',
+ 'mode': 'single',
+ 'filter': 'this_year',
+ 'date_from': '2017-10-01',
+ 'date_to': '2017-12-31',
+ 'currency_table_period_key': 'None_2017-12-31',
+ },
+ )
+
+ self._assert_filter_comparison(
+ self.single_date_report,
+ {'date': {'filter': 'this_year', 'mode': 'single'}, 'comparison': {'filter': 'previous_period', 'number_period': 2}},
+ [
+ {
+ 'string': 'custom 1',
+ 'period_type': 'fiscalyear',
+ 'mode': 'single',
+ 'date_from': '2017-07-01',
+ 'date_to': '2017-09-30',
+ 'currency_table_period_key': 'None_2017-09-30',
+ },
+ {
+ 'string': 'custom 2',
+ 'period_type': 'fiscalyear',
+ 'mode': 'single',
+ 'date_from': '2017-04-01',
+ 'date_to': '2017-06-30',
+ 'currency_table_period_key': 'None_2017-06-30',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.single_date_report,
+ {'date': {'filter': 'this_year', 'mode': 'single'}, 'comparison': {'filter': 'same_last_year', 'number_period': 2}},
+ [
+ {
+ 'string': 'custom 4',
+ 'period_type': 'fiscalyear',
+ 'mode': 'single',
+ 'date_from': '2016-10-01',
+ 'date_to': '2016-12-31',
+ 'currency_table_period_key': 'None_2016-12-31',
+ },
+ {
+ 'string': 'custom 8',
+ 'period_type': 'fiscalyear',
+ 'mode': 'single',
+ 'date_from': '2015-10-01',
+ 'date_to': '2015-12-31',
+ 'currency_table_period_key': 'None_2015-12-31',
+ },
+ ],
+ )
+
+ @freeze_time('2017-12-31')
+ def test_filter_date_custom_single(self):
+ ''' Test the filter_date with a custom date in 'single' mode.'''
+ self._assert_filter_date(
+ self.single_date_report,
+ {'date': {'filter': 'custom', 'mode': 'single', 'date_to': '2018-01-15'}},
+ {
+ 'string': 'As of %s' % format_date(self.env, '2018-01-15'),
+ 'period_type': 'custom',
+ 'mode': 'single',
+ 'filter': 'custom',
+ 'date_from': '2018-01-01',
+ 'date_to': '2018-01-15',
+ 'currency_table_period_key': 'None_2018-01-15',
+ },
+ )
+
+ self._assert_filter_comparison(
+ self.single_date_report,
+ {'date': {'filter': 'custom', 'mode': 'single', 'date_to': '2018-01-15'}, 'comparison': {'filter': 'previous_period', 'number_period': 2}},
+ [
+ {
+ 'string': 'As of %s' % format_date(self.env, '2017-12-31'),
+ 'period_type': 'month',
+ 'mode': 'single',
+ 'date_from': '2017-12-01',
+ 'date_to': '2017-12-31',
+ 'currency_table_period_key': 'None_2017-12-31',
+ },
+ {
+ 'string': 'As of %s' % format_date(self.env, '2017-11-30'),
+ 'period_type': 'month',
+ 'mode': 'single',
+ 'date_from': '2017-11-01',
+ 'date_to': '2017-11-30',
+ 'currency_table_period_key': 'None_2017-11-30',
+ },
+ ],
+ )
+
+ self._assert_filter_comparison(
+ self.single_date_report,
+ {'date': {'filter': 'custom', 'mode': 'single', 'date_to': '2018-01-15'}, 'comparison': {'filter': 'same_last_year', 'number_period': 2}},
+ [
+ {
+ 'string': 'As of %s' % format_date(self.env, '2017-01-15'),
+ 'period_type': 'custom',
+ 'mode': 'single',
+ 'date_from': '2017-01-01',
+ 'date_to': '2017-01-15',
+ 'currency_table_period_key': 'None_2017-01-15',
+ },
+ {
+ 'string': 'As of %s' % format_date(self.env, '2016-01-15'),
+ 'period_type': 'custom',
+ 'mode': 'single',
+ 'date_from': '2016-01-01',
+ 'date_to': '2016-01-15',
+ 'currency_table_period_key': 'None_2016-01-15',
+ },
+ ],
+ )
+
+ @freeze_time('2021-09-01')
+ def test_filter_date_custom_single_period_type_month(self):
+ ''' Test the filter_date with a custom date in 'single' mode.'''
+ self._assert_filter_date(
+ self.single_date_report,
+ {
+ 'date': {
+ 'period_type': 'today',
+ 'mode': 'single',
+ 'date_from': '2021-09-01',
+ 'date_to': '2019-07-18',
+ 'filter': 'custom',
+ 'currency_table_period_key': 'None_2019-07-18',
+ }
+ },
+ {
+ 'string': 'As of %s' % format_date(self.env, '2019-07-18'),
+ 'period_type': 'custom',
+ 'mode': 'single',
+ 'filter': 'custom',
+ 'date_from': '2019-07-01',
+ 'date_to': '2019-07-18',
+ 'currency_table_period_key': 'None_2019-07-18',
+ },
+ )
+
+ self._assert_filter_comparison(
+ self.single_date_report,
+ {'date': {'filter': 'custom', 'mode': 'single', 'date_to': '2019-07-18'}, 'comparison': {'filter': 'previous_period', 'number_period': 2}},
+ [
+ {
+ 'string': 'As of %s' % format_date(self.env, '2019-06-30'),
+ 'period_type': 'month',
+ 'mode': 'single',
+ 'date_from': '2019-06-01',
+ 'date_to': '2019-06-30',
+ 'currency_table_period_key': 'None_2019-06-30',
+ },
+ {
+ 'string': 'As of %s' % format_date(self.env, '2019-05-31'),
+ 'period_type': 'month',
+ 'mode': 'single',
+ 'date_from': '2019-05-01',
+ 'date_to': '2019-05-31',
+ 'currency_table_period_key': 'None_2019-05-31',
+ },
+ ],
+ )
+
+ ####################################################
+ # User Defined Filters on Journal Items
+ ####################################################
+
+ @freeze_time('2023-09-01')
+ def test_filter_aml_ir_filters(self):
+ # Test user-defined filter set on journal items used as report options
+
+ filter_record = self.env['ir.filters'].create({
+ 'model_id': 'account.move.line',
+ 'user_id': self.uid,
+ 'name': 'To Check',
+ 'domain': '[("move_id.checked", "=", False)]',
+ })
+
+ report = self.env['account.report'].create({
+ 'name': 'Test ir filters',
+ 'filter_aml_ir_filters': True,
+ 'root_report_id': self.env.ref("odex30_account_reports.profit_and_loss").id,
+ 'column_ids': [
+ Command.create({
+ 'name': 'Balance',
+ 'expression_label': 'balance',
+ }),
+ ],
+ 'line_ids': [
+ Command.create({
+ 'name': 'Line 1',
+ 'expression_ids': [
+ Command.create({
+ 'label': 'balance',
+ 'engine': 'domain',
+ 'formula': '[("account_id.account_type", "=", "income")]',
+ 'subformula': '-sum',
+ }),
+ ],
+ }),
+ ],
+ })
+
+ moves = (
+ self.init_invoice("out_invoice", self.partner_a, "2023-09-01", amounts=[1000])
+ + self.init_invoice("out_invoice", self.partner_a, "2023-09-01", amounts=[1000])
+ )
+ moves.action_post()
+ moves[0].checked = False
+
+ options = self._generate_options(report, '2023-01-01', '2023-12-31')
+
+ for opt in options['aml_ir_filters']:
+ if opt['id'] == filter_record.id:
+ opt['selected'] = True
+ break
+
+ # Ensure that only the move with the 'checked' at false attribute is included in the report
+ self.assertLinesValues(
+ report._get_lines(options),
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Line 1', 1000)
+ ],
+ options
+ )
+
+ def test_hide_line_at_0_tour(self):
+ report = self.env.ref('odex30_account_reports.balance_sheet')
+ report.filter_hide_0_lines = 'optional'
+ self.env['account.move'].create([{
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'date': '2020-0%s-15' % i,
+ 'invoice_date': '2020-0%s-15' % i,
+ 'invoice_line_ids': [(0, 0, {
+ 'product_id': self.product_a.id,
+ 'price_unit': 1000.0,
+ 'tax_ids': [(6, 0, self.tax_sale_a.ids)],
+ })],
+ } for i in range(1, 4)]).action_post()
+
+ self.start_tour("/odoo", 'account_reports_hide_0_lines', login=self.env.user.login)
+
+ @freeze_time('2020-01-16')
+ def test_hide_line_at_0_tour_with_string_columns(self):
+ report = self.env.ref('odex30_account_reports.general_ledger_report')
+ report.filter_hide_0_lines = 'optional'
+ self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2020-01-15',
+ 'line_ids': [Command.create({
+ 'partner_id': self.partner_a.id,
+ 'debit': 0.0,
+ 'credit': 0.0,
+ 'name': "Coucou les biloutes",
+ 'account_id': self.company_data['default_account_payable'].id,
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ })],
+ }).action_post()
+
+ self.start_tour("/odoo", 'account_reports_hide_0_lines_with_string_columns', login=self.env.user.login)
+
+ def test_rounding_unit_tour(self):
+ self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'date': '2023-01-01',
+ 'invoice_date': '2023-01-01',
+ 'invoice_line_ids': [Command.create({
+ 'product_id': self.product_a.id,
+ 'price_unit': 1000000.0,
+ 'tax_ids': [Command.set(self.tax_sale_a.ids)],
+ })],
+ }).action_post()
+
+ self.start_tour("/odoo", 'account_reports_rounding_unit', login=self.env.user.login)
+
+ def test_filter_multi_company(self):
+ def _check_company_filter(allowed_companies, expected_companies, message=None, match_active=True):
+ options = self.single_date_report.with_context(allowed_company_ids=allowed_companies.ids).get_options({})
+ computed_company_ids = self.env['account.report'].get_report_company_ids(options)
+ if match_active:
+ # Active company should match
+ self.assertEqual(computed_company_ids[0], expected_companies[0].id, message)
+ # Selected companies should match, whatever their order
+ self.assertEqual(set(computed_company_ids), set(expected_companies.ids), message)
+
+ main_company = self.company_data['company']
+ main_company.vat = '123'
+ branch_1 = self.env['res.company'].create({'name': "Branch 1", 'parent_id': main_company.id, 'vat': '123'})
+ branch_1_1 = self.env['res.company'].create({'name': "Branch 1 sub-branch 1", 'parent_id': branch_1.id})
+ branch_1_2 = self.env['res.company'].create({'name': "Branch 1 sub-branch 2", 'parent_id': branch_1.id, 'vat': '123'})
+ branch_2 = self.env['res.company'].create({'name': "Branch 2", 'parent_id': main_company.id})
+ branch_2_1 = self.env['res.company'].create({'name': "Branch 2 sub-branch 1", 'parent_id': branch_2.id})
+ other_company = self.env['res.company'].create({'name': "Other company"})
+
+ # Test 'disabled' filter, as well as 'tax_units' when no tax unit is defined and VAT is shared (they should behave in the same way)
+ for company_filter in ('disabled', 'tax_units'):
+ self.single_date_report.filter_multi_company = company_filter
+
+ _check_company_filter(
+ main_company + branch_1 + branch_1_1 + branch_1_2 + branch_2 + branch_2_1 + other_company,
+ main_company + branch_1 + branch_1_1 + branch_1_2 + branch_2 + branch_2_1,
+ "The main company and all of its sub-branches should be selected",
+ )
+ _check_company_filter(
+ branch_1 + main_company + branch_1_1 + branch_1_2 + branch_2 + branch_2_1 + other_company,
+ branch_1 + branch_1_1 + branch_1_2,
+ "When the active company is a branch of another active company, it should only be selected with its sub-branches.",
+ )
+ _check_company_filter(
+ main_company + branch_1 + branch_1_2 + branch_2_1 + other_company,
+ main_company + branch_1 + branch_1_2 + branch_2_1,
+ "Choosing a subset of branches in the company selector should keep that selection in the report.",
+ )
+
+ # Test 'selector' filter
+ self.single_date_report.filter_multi_company = 'selector'
+
+ _check_company_filter(
+ branch_1,
+ branch_1,
+ )
+ _check_company_filter(
+ main_company + branch_1 + branch_1_1 + branch_1_2 + branch_2 + branch_2_1 + other_company,
+ main_company + branch_1 + branch_1_1 + branch_1_2 + branch_2 + branch_2_1 + other_company,
+ )
+ _check_company_filter(
+ branch_1 + main_company + branch_1_1 + branch_1_2 + branch_2 + branch_2_1 + other_company,
+ branch_1 + main_company + branch_1_1 + branch_1_2 + branch_2 + branch_2_1 + other_company,
+ )
+ _check_company_filter(
+ main_company + branch_1_1 + branch_1_2 + branch_2 + other_company,
+ main_company + branch_1_1 + branch_1_2 + branch_2 + other_company,
+ )
+
+ # Test 'tax_units' filter, with no tax unit, and non-shared VAT numbers
+ self.single_date_report.filter_multi_company = 'tax_units'
+ branch_1_1.vat = '456'
+ branch_2.vat = '789'
+
+ _check_company_filter(
+ main_company + branch_1_1 + branch_1_2 + branch_2 + branch_2_1 + other_company,
+ main_company + branch_1_2,
+ "Only the current company and its sub-branches sharing its vat number should be selected.",
+ )
+
+ _check_company_filter(
+ branch_2 + main_company + branch_1_1 + branch_1_2 + branch_2_1 + other_company,
+ branch_2 + branch_2_1,
+ "Only the current company and its sub-branches sharing its vat number should be selected.",
+ )
+
+ # Test 'tax_units' filter, with an existing tax unit object
+ self.single_date_report.country_id = self.env.ref('base.be')
+ self.single_date_report.availability_condition = 'country'
+
+ tax_unit = self.env['account.tax.unit'].create({
+ 'name': "Test Tax Unit",
+ 'country_id': self.single_date_report.country_id.id,
+ 'vat': 'BE0477472701',
+ 'company_ids': (main_company + branch_1_1 + branch_1_2 + branch_2 + other_company).ids,
+ 'main_company_id': main_company.id,
+ })
+
+ _check_company_filter(
+ other_company + main_company + branch_1_1 + branch_1_2 + branch_2,
+ other_company + main_company + branch_1_1 + branch_1_2 + branch_2,
+ "Opening the report with a company selector matching the content of the tax unit should select this tax unit, keeping the companies.",
+ match_active=False,
+ )
+
+ _check_company_filter(
+ tax_unit.company_ids + branch_2_1,
+ main_company + branch_1_2,
+ "Opening the report with a company selector matching more than the content of the tax unit should not select the tax unit, "
+ "but take the accessible branches with the same VAT number as the active company.",
+ )
+
+ _check_company_filter(
+ main_company + branch_1_1 + branch_1_2 + branch_2,
+ main_company + branch_1_2,
+ "Opening the report with a company selector matching less than the content of the tax unit should select the active sub-branches "
+ "with the same VAT as the active company.",
+ )
+
+ # Test 'tax_units' filter, with no tax unit, and no VAT number on branches (only one on main company)
+ branch_1.vat = None
+ branch_1_1.vat = None
+ branch_1_2.vat = None
+ branch_2.vat = None
+
+ _check_company_filter(
+ branch_2 + branch_2_1,
+ branch_2 + branch_2_1,
+ "When no VAT exists in the hierarchy; all companies should be considered as sharing the same VAT, and active companies should be kept.",
+ )
+
+ _check_company_filter(
+ branch_2_1 + branch_2,
+ branch_2_1 + branch_2,
+ "When no VAT exists in the hierarchy; all companies should be considered as sharing the same VAT, and active companies should be kept.",
+ )
+
+ @freeze_time('2017-12-31')
+ def test_period_order(self):
+ report = self.date_range_report
+ previous_options = {'date': {'filter': 'this_year', 'mode': 'range'}, 'comparison': {'filter': 'same_last_year', 'number_period': 1, 'period_order': 'descending'}}
+ options = report.get_options(previous_options)
+
+ expected_values = [
+ {
+ 'name': '2017',
+ 'forced_options': {
+ 'date': {'string': '2017', 'period_type': 'fiscalyear', 'mode': 'range', 'date_from': '2017-01-01', 'date_to': '2017-12-31', 'filter': 'this_year', 'currency_table_period_key': '2017-01-01_2017-12-31'}
+ }
+ },
+ {
+ 'name': '2016',
+ 'forced_options': {
+ 'date': {'string': '2016', 'period_type': 'fiscalyear', 'mode': 'range', 'date_from': '2016-01-01', 'date_to': '2016-12-31', 'currency_table_period_key': '2016-01-01_2016-12-31'}
+ }
+ },
+ ]
+
+ for i, val in enumerate(expected_values):
+ self.assertDictEqual(options['column_headers'][0][i], val)
+
+ previous_options['comparison']['period_order'] = 'ascending'
+ new_options = report.get_options(previous_options)
+ new_expected_values = expected_values[::-1]
+
+ for i, val in enumerate(new_expected_values):
+ self.assertDictEqual(new_options['column_headers'][0][i], val)
+
+ ####################################################
+ # DATES RANGE
+ ####################################################
+
+ @freeze_time('2024-09-01')
+ def test_tax_period_filter(self):
+ generic_tax_report = self.env.ref('account.generic_tax_report')
+ self._assert_filter_date(
+ generic_tax_report,
+ {},
+ {
+ 'string': 'Aug 2024',
+ 'period_type': 'month',
+ 'mode': 'range',
+ 'filter': 'previous_month',
+ 'period': -1,
+ 'date_from': '2024-08-01',
+ 'date_to': '2024-08-31',
+ 'currency_table_period_key': '2024-08-01_2024-08-31',
+ },
+ )
+
+ self._assert_filter_date(
+ generic_tax_report,
+ {'date': {'period': -8, 'filter': 'previous_tax_period'}},
+ {
+ 'string': 'Jan 2024',
+ 'period_type': 'month',
+ 'mode': 'range',
+ 'filter': 'previous_month',
+ 'period': -8,
+ 'date_from': '2024-01-01',
+ 'date_to': '2024-01-31',
+ 'currency_table_period_key': '2024-01-01_2024-01-31',
+ },
+ )
+
+ self.env.company.account_tax_periodicity = 'year'
+
+ self._assert_filter_date(
+ generic_tax_report,
+ {'date': {'period': -1, 'filter': 'previous_tax_period'}},
+ {
+ 'string': '2023',
+ 'period_type': 'year',
+ 'mode': 'range',
+ 'filter': 'previous_year',
+ 'period': -1,
+ 'date_from': '2023-01-01',
+ 'date_to': '2023-12-31',
+ 'currency_table_period_key': '2023-01-01_2023-12-31',
+ },
+ )
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_account_reports_journal_filter.py b/dev_odex30_accounting/odex30_account_reports/tests/test_account_reports_journal_filter.py
new file mode 100644
index 0000000..bb297a1
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_account_reports_journal_filter.py
@@ -0,0 +1,731 @@
+# -*- coding: utf-8 -*-
+from odoo import Command
+from odoo.tests import tagged
+from odoo.addons.account.tests.common import AccountTestInvoicingCommon
+
+
+@tagged('post_install', '-at_install')
+class TestAccountReportsJournalFilter(AccountTestInvoicingCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.vanilla_company1 = cls.env['res.company'].create({'name': "Vanilla1"})
+ cls.vanilla_company2 = cls.env['res.company'].create({'name': "Vanilla2"})
+
+ # Force the test user to only access the vanilla companies
+ cls.env.user.write({
+ 'company_ids': [Command.set((cls.vanilla_company1 + cls.vanilla_company2).ids)],
+ 'company_id': cls.vanilla_company1.id,
+ })
+
+ cls.report = cls.env.ref('odex30_account_reports.balance_sheet')
+
+ def _assert_filter_journal(self, options, display_name, expected_values_list):
+ journal_options = options['journals']
+ self.assertEqual(options['name_journal_group'], display_name)
+ self.assertEqual(len(journal_options), len(expected_values_list))
+ for journal_option, expected_values in zip(journal_options, expected_values_list):
+ if isinstance(expected_values, dict):
+ self.assertDictEqual(expected_values, {k: journal_option.get(k) for k in expected_values})
+ elif len(expected_values) == 2:
+ record, selected = expected_values
+ self.assertDictEqual(
+ {
+ 'id': journal_option.get('id'),
+ 'model': journal_option.get('model'),
+ 'selected': journal_option.get('selected'),
+ },
+ {
+ 'id': record.id,
+ 'model': record._name,
+ 'selected': selected,
+ },
+ )
+
+ def _assert_filter_journal_visible_unfolded(self, options, expected_values):
+ self.assertEqual(len(options['journals']), len(expected_values))
+ for journal, results in zip(options['journals'], expected_values):
+ self.assertTupleEqual((journal.get('visible', None), journal.get('unfolded', None)), results)
+
+ def _quick_create_journal(self, name, company, journal_type='sale'):
+ return self.env['account.journal'].create([{
+ 'name': name,
+ 'code': name,
+ 'type': journal_type,
+ 'company_id': company.id,
+ }])
+
+ def _quick_create_journal_group(self, name, company, excluded_journals):
+ return self.env['account.journal.group'].create([{
+ 'name': name,
+ 'excluded_journal_ids': [Command.set(excluded_journals.ids)],
+ 'company_id': company.id if company else False,
+ }])
+
+ def _press_journal_filter(self, options, journals):
+ for option_journal in options['journals']:
+ if option_journal.get('model') == 'account.journal' and option_journal.get('id') in journals.ids:
+ option_journal['selected'] = not option_journal['selected']
+
+ def test_journal_filter_single_company(self):
+ j1 = self._quick_create_journal("j1", self.vanilla_company1)
+ j2 = self._quick_create_journal("j2", self.vanilla_company1)
+ j3 = self._quick_create_journal("j3", self.vanilla_company1)
+ j4 = self._quick_create_journal("j4", self.vanilla_company1)
+ j5 = self._quick_create_journal("j5", self.vanilla_company1)
+ j6 = self._quick_create_journal("j6", self.vanilla_company1)
+ j7 = self._quick_create_journal("j7", self.vanilla_company1)
+ j8 = self._quick_create_journal("j8", self.vanilla_company1)
+
+ options = self.report.get_options({})
+ self._assert_filter_journal(options, "All Journals", [
+ (j1, False),
+ (j2, False),
+ (j3, False),
+ (j4, False),
+ (j5, False),
+ (j6, False),
+ (j7, False),
+ (j8, False),
+ ])
+
+ self._press_journal_filter(options, (j1 + j2 + j3))
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "j1, j2, j3", [
+ (j1, True),
+ (j2, True),
+ (j3, True),
+ (j4, False),
+ (j5, False),
+ (j6, False),
+ (j7, False),
+ (j8, False),
+ ])
+
+ self._press_journal_filter(options, (j4 + j5 + j6))
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "j1, j2, j3, j4, j5 and one other", [
+ (j1, True),
+ (j2, True),
+ (j3, True),
+ (j4, True),
+ (j5, True),
+ (j6, True),
+ (j7, False),
+ (j8, False),
+ ])
+
+ self._press_journal_filter(options, j7)
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "j1, j2, j3, j4, j5 and 2 others", [
+ (j1, True),
+ (j2, True),
+ (j3, True),
+ (j4, True),
+ (j5, True),
+ (j6, True),
+ (j7, True),
+ (j8, False),
+ ])
+
+ self._press_journal_filter(options, j8)
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "All Journals", [
+ (j1, False),
+ (j2, False),
+ (j3, False),
+ (j4, False),
+ (j5, False),
+ (j6, False),
+ (j7, False),
+ (j8, False),
+ ])
+
+ def test_journal_filter_multi_company(self):
+ j1 = self._quick_create_journal("j1", self.vanilla_company1)
+ j2 = self._quick_create_journal("j2", self.vanilla_company1)
+ j3 = self._quick_create_journal("j3", self.vanilla_company2)
+ j4 = self._quick_create_journal("j4", self.vanilla_company2)
+ j5 = self._quick_create_journal("j5", self.vanilla_company1)
+ j6 = self._quick_create_journal("j6", self.vanilla_company1)
+ j7 = self._quick_create_journal("j7", self.vanilla_company2)
+ j8 = self._quick_create_journal("j8", self.vanilla_company2)
+
+ options = self.report.get_options({'is_opening_report': True})
+ self._assert_filter_journal(options, "All Journals", [
+ {'id': 'divider'},
+ (j1, False),
+ (j2, False),
+ (j5, False),
+ (j6, False),
+ {'id': 'divider'},
+ (j3, False),
+ (j4, False),
+ (j7, False),
+ (j8, False),
+ ])
+
+ self._press_journal_filter(options, (j1 + j3 + j5 + j7))
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "j1, j5, j3, j7", [
+ {'id': 'divider'},
+ (j1, True),
+ (j2, False),
+ (j5, True),
+ (j6, False),
+ {'id': 'divider'},
+ (j3, True),
+ (j4, False),
+ (j7, True),
+ (j8, False),
+ ])
+
+ def test_journal_filter_with_groups_single_company(self):
+ j1 = self._quick_create_journal("j1", self.vanilla_company1)
+ j2 = self._quick_create_journal("j2", self.vanilla_company1)
+ j3 = self._quick_create_journal("j3", self.vanilla_company1)
+ j4 = self._quick_create_journal("j4", self.vanilla_company1)
+ j5 = self._quick_create_journal("j5", self.vanilla_company1)
+ j6 = self._quick_create_journal("j6", self.vanilla_company1)
+
+ g1 = self._quick_create_journal_group("g1", self.vanilla_company1, j2 + j4)
+ g2 = self._quick_create_journal_group("g2", self.vanilla_company1, j2 + j5)
+
+ options = self.report.get_options({'is_opening_report': True})
+ self._assert_filter_journal(options, "g1", [
+ {'id': 'divider'},
+ (g1, True),
+ (g2, False),
+ {'id': 'divider'},
+ (j1, True),
+ (j2, False),
+ (j3, True),
+ (j4, False),
+ (j5, True),
+ (j6, True),
+ ])
+
+ # Check g2.
+ options['__journal_group_action'] = {'action': 'add', 'id': g2.id}
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "g2", [
+ {'id': 'divider'},
+ (g1, False),
+ (g2, True),
+ {'id': 'divider'},
+ (j1, True),
+ (j2, False),
+ (j3, True),
+ (j4, True),
+ (j5, False),
+ (j6, True),
+ ])
+
+ # Uncheck g2.
+ options['__journal_group_action'] = {'action': 'remove', 'id': g2.id}
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "All Journals", [
+ {'id': 'divider'},
+ (g1, False),
+ (g2, False),
+ {'id': 'divider'},
+ (j1, False),
+ (j2, False),
+ (j3, False),
+ (j4, False),
+ (j5, False),
+ (j6, False),
+ ])
+
+ def test_journal_filter_with_groups_multi_company(self):
+ j1 = self._quick_create_journal("j1", self.vanilla_company1)
+ j2 = self._quick_create_journal("j2", self.vanilla_company1)
+ j3 = self._quick_create_journal("j3", self.vanilla_company1)
+ j4 = self._quick_create_journal("j4", self.vanilla_company1)
+ j5 = self._quick_create_journal("j5", self.vanilla_company2)
+ j6 = self._quick_create_journal("j6", self.vanilla_company2)
+
+ g1 = self._quick_create_journal_group("g1", self.vanilla_company1, j2 + j3)
+ g2 = self._quick_create_journal_group("g2", self.vanilla_company2, j6)
+
+ options = self.report.get_options({'is_opening_report': True})
+ self._assert_filter_journal(options, "g1", [
+ {'id': 'divider'},
+ (g1, True),
+ (g2, False),
+ {'id': 'divider'},
+ (j1, True),
+ (j2, False),
+ (j3, False),
+ (j4, True),
+ {'id': 'divider'},
+ (j5, True),
+ (j6, True),
+ ])
+
+ # Check g2.
+ options['__journal_group_action'] = {'action': 'add', 'id': g2.id}
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "g2", [
+ {'id': 'divider'},
+ (g1, False),
+ (g2, True),
+ {'id': 'divider'},
+ (j1, True),
+ (j2, True),
+ (j3, True),
+ (j4, True),
+ {'id': 'divider'},
+ (j5, True),
+ (j6, False),
+ ])
+
+ # Uncheck g2.
+ options['__journal_group_action'] = {'action': 'remove', 'id': g2.id}
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "All Journals", [
+ {'id': 'divider'},
+ (g1, False),
+ (g2, False),
+ {'id': 'divider'},
+ (j1, False),
+ (j2, False),
+ (j3, False),
+ (j4, False),
+ {'id': 'divider'},
+ (j5, False),
+ (j6, False),
+ ])
+
+ def test_journal_filter_with_single_group_multi_company(self):
+ j1 = self._quick_create_journal("j1", self.vanilla_company1)
+ j2 = self._quick_create_journal("j2", self.vanilla_company1)
+ j3 = self._quick_create_journal("j3", self.vanilla_company2)
+ j4 = self._quick_create_journal("j4", self.vanilla_company2)
+
+ g1 = self._quick_create_journal_group("g1", self.vanilla_company1, j2)
+
+ options = self.report.get_options({'is_opening_report': True})
+ self._assert_filter_journal(options, "g1", [
+ {'id': 'divider'},
+ (g1, True),
+ {'id': 'divider'},
+ (j1, True),
+ (j2, False),
+ {'id': 'divider'},
+ (j3, True),
+ (j4, True),
+ ])
+
+ # Remove g1.
+ options['__journal_group_action'] = {'action': 'remove', 'id': g1.id}
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "All Journals", [
+ {'id': 'divider'},
+ (g1, False),
+ {'id': 'divider'},
+ (j1, False),
+ (j2, False),
+ {'id': 'divider'},
+ (j3, False),
+ (j4, False),
+ ])
+
+ self._press_journal_filter(options, j3) # check j3
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "j3", [
+ {'id': 'divider'},
+ (g1, False),
+ {'id': 'divider'},
+ (j1, False),
+ (j2, False),
+ {'id': 'divider'},
+ (j3, True),
+ (j4, False),
+ ])
+
+ # Check g1.
+ options['__journal_group_action'] = {'action': 'add', 'id': g1.id}
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "g1", [
+ {'id': 'divider'},
+ (g1, True),
+ {'id': 'divider'},
+ (j1, True),
+ (j2, False),
+ {'id': 'divider'},
+ (j3, True),
+ (j4, True),
+ ])
+
+ self._press_journal_filter(options, j3) # Uncheck j3
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "j1, j4", [
+ {'id': 'divider'},
+ (g1, False),
+ {'id': 'divider'},
+ (j1, True),
+ (j2, False),
+ {'id': 'divider'},
+ (j3, False),
+ (j4, True),
+ ])
+
+ self._press_journal_filter(options, j3) # Check j3
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "g1", [
+ {'id': 'divider'},
+ (g1, True),
+ {'id': 'divider'},
+ (j1, True),
+ (j2, False),
+ {'id': 'divider'},
+ (j3, True),
+ (j4, True),
+ ])
+
+ def test_journal_filter_with_groups_cash_flow_statement(self):
+ """
+ Test the behaviour of the journal filter with groups in a report
+ that does not allow all journals, cash flow statement is a perfect
+ fit for this use case
+ """
+ bnk = self._quick_create_journal("BNK", self.vanilla_company1, 'bank')
+ csh = self._quick_create_journal("CSH", self.vanilla_company1, 'cash')
+ misc = self._quick_create_journal("MISC", self.vanilla_company1, 'general')
+ exch = self._quick_create_journal("EXCH", self.vanilla_company1, 'general')
+ ifrs = self._quick_create_journal("IFRS", self.vanilla_company1, 'general')
+ caba = self._quick_create_journal("CABA", self.vanilla_company1, 'general')
+ inv = self._quick_create_journal("INV", self.vanilla_company1, 'sale') # Not accepted by the report
+ bill = self._quick_create_journal("BILL", self.vanilla_company1, 'purchase') # Not accepted by the report
+
+ g1 = self._quick_create_journal_group("g1", self.vanilla_company1, inv + bill)
+ g2 = self._quick_create_journal_group("g2", self.vanilla_company1, misc + bill)
+
+ report = self.env.ref('odex30_account_reports.cash_flow_report')
+ options = report.get_options({'is_opening_report': True})
+ self._assert_filter_journal(options, "g1", [
+ {'id': 'divider'},
+ (g1, True),
+ (g2, False), # g2 should be displayed because it has journals that are allowed in the report
+ {'id': 'divider'},
+ (bnk, True),
+ (caba, True),
+ (csh, True),
+ (exch, True),
+ (ifrs, True),
+ (misc, True),
+ ])
+
+ # Check g2, all journals from g2 that are allowed in report should be selected
+ options['__journal_group_action'] = {'action': 'add', 'id': g2.id}
+ options = report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "g2", [
+ {'id': 'divider'},
+ (g1, False),
+ (g2, True),
+ {'id': 'divider'},
+ (bnk, True),
+ (caba, True),
+ (csh, True),
+ (exch, True),
+ (ifrs, True),
+ (misc, False),
+ ])
+
+ # Uncheck g2.
+ options['__journal_group_action'] = {'action': 'remove', 'id': g2.id}
+ options = report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "All Journals", [
+ {'id': 'divider'},
+ (g1, False),
+ (g2, False),
+ {'id': 'divider'},
+ (bnk, False),
+ (caba, False),
+ (csh, False),
+ (exch, False),
+ (ifrs, False),
+ (misc, False),
+ ])
+
+ def test_journal_filter_branch_company(self):
+ """
+ The purpose of this test is to ensure that the journal filter is
+ well managed with sub companies.
+ Each journal should appear once, even when it is shared within
+ a company and its children.
+ Also, journal from parent company should be display in the filter
+ if only the child company is selected
+ """
+ self.vanilla_company1.write({'child_ids': [Command.create({'name': 'Vanilla3'})]})
+ vanilla_company3 = self.vanilla_company1.child_ids[0]
+ self.env.user.write({
+ 'company_ids': [Command.set((self.vanilla_company1 + self.vanilla_company2 + vanilla_company3).ids)],
+ 'company_id': self.vanilla_company1.id,
+ })
+
+ j1 = self._quick_create_journal("j1", self.vanilla_company1)
+ j2 = self._quick_create_journal("j2", self.vanilla_company1)
+ j3 = self._quick_create_journal("j3", self.vanilla_company2)
+ j4 = self._quick_create_journal("j4", self.vanilla_company2)
+ j5 = self._quick_create_journal("j5", self.vanilla_company1)
+ j6 = self._quick_create_journal("j6", self.vanilla_company1)
+ j7 = self._quick_create_journal("j7", self.vanilla_company2)
+ j8 = self._quick_create_journal("j8", self.vanilla_company2)
+ j9 = self._quick_create_journal("j9", vanilla_company3)
+ j10 = self._quick_create_journal("j10", vanilla_company3)
+
+ # With all companies selected, all journals should be displayed
+ options = self.report.get_options({'is_opening_report': True})
+ self._assert_filter_journal(options, "All Journals", [
+ {'id': 'divider'},
+ (j1, False),
+ (j2, False),
+ (j5, False),
+ (j6, False),
+ {'id': 'divider'},
+ (j3, False),
+ (j4, False),
+ (j7, False),
+ (j8, False),
+ {'id': 'divider'},
+ (j10, False),
+ (j9, False),
+ ])
+
+ self._press_journal_filter(options, (j1 + j3 + j5 + j7 + j9))
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "j1, j5, j3, j7, j9", [
+ {'id': 'divider'},
+ (j1, True),
+ (j2, False),
+ (j5, True),
+ (j6, False),
+ {'id': 'divider'},
+ (j3, True),
+ (j4, False),
+ (j7, True),
+ (j8, False),
+ {'id': 'divider'},
+ (j10, False),
+ (j9, True),
+ ])
+
+ # Select only the child company
+ self.env.user.write({
+ 'company_ids': [Command.set((vanilla_company3).ids)],
+ 'company_id': vanilla_company3.id,
+ })
+
+ # Parent company journals should be displayed too
+ options = self.report.get_options({'is_opening_report': True})
+ self._assert_filter_journal(options, "All Journals", [
+ {'id': 'divider'},
+ (j1, False),
+ (j2, False),
+ (j5, False),
+ (j6, False),
+ {'id': 'divider'},
+ (j10, False),
+ (j9, False),
+ ])
+
+ self._press_journal_filter(options, (j1 + j5 + j10))
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "j1, j5, j10", [
+ {'id': 'divider'},
+ (j1, True),
+ (j2, False),
+ (j5, True),
+ (j6, False),
+ {'id': 'divider'},
+ (j10, True),
+ (j9, False),
+ ])
+
+ # Select only the parent company
+ self.env.user.write({
+ 'company_ids': [Command.set((self.vanilla_company1).ids)],
+ 'company_id': self.vanilla_company1.id,
+ })
+
+ # Only parent company journals should be displayed
+ options = self.report.get_options({'is_opening_report': True})
+ self._assert_filter_journal(options, "All Journals", [
+ (j1, False),
+ (j2, False),
+ (j5, False),
+ (j6, False),
+ ])
+
+ self._press_journal_filter(options, (j1 + j5))
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "j1, j5", [
+ (j1, True),
+ (j2, False),
+ (j5, True),
+ (j6, False),
+ ])
+
+ def test_journal_filter_with_groups_no_company_multi_company(self):
+ """Test the journals filter with a group where no company is set, therefore implying that all companies can
+ visualize the filter in their accounting reports"""
+ j1 = self._quick_create_journal("j1", self.vanilla_company1)
+ j2 = self._quick_create_journal("j2", self.vanilla_company1)
+ j3 = self._quick_create_journal("j3", self.vanilla_company1)
+ j4 = self._quick_create_journal("j4", self.vanilla_company2)
+ j5 = self._quick_create_journal("j5", self.vanilla_company2)
+
+ # g1 has journals of company 1 and 2, even though visible only for company 1
+ g1 = self._quick_create_journal_group("g1", self.vanilla_company1, j1 + j4)
+
+ # g2 has no company set, therefore implying that this multi-ledger is visible for all companies
+ g2 = self._quick_create_journal_group("g2", False, j2 + j5)
+
+ # only select company 2
+ self.env.user.write({
+ 'company_ids': [Command.set(self.vanilla_company2.ids)],
+ 'company_id': self.vanilla_company2.id,
+ })
+
+ options = self.report.get_options({'is_opening_report': True})
+ self._assert_filter_journal(options, "g2", [
+ {'id': 'divider'},
+ (g2, True),
+ {'id': 'divider'},
+ (j4, True),
+ (j5, False),
+ ])
+
+ self.env.user.write({
+ 'company_ids': [Command.set((self.vanilla_company1 + self.vanilla_company2).ids)],
+ 'company_id': self.vanilla_company2.id,
+ })
+
+ # Check g1
+ options['__journal_group_action'] = {'action': 'add', 'id': g1.id}
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "g1", [
+ {'id': 'divider'},
+ (g1, True),
+ (g2, False),
+ {'id': 'divider'},
+ (j1, False),
+ (j2, True),
+ (j3, True),
+ {'id': 'divider'},
+ (j4, False),
+ (j5, True),
+ ])
+
+ # Check g2
+ options['__journal_group_action'] = {'action': 'add', 'id': g2.id}
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "g2", [
+ {'id': 'divider'},
+ (g1, False),
+ (g2, True),
+ {'id': 'divider'},
+ (j1, True),
+ (j2, False),
+ (j3, True),
+ {'id': 'divider'},
+ (j4, True),
+ (j5, False),
+ ])
+
+ # only select company 1
+ self.env.user.write({
+ 'company_ids': [Command.set(self.vanilla_company1.ids)],
+ 'company_id': self.vanilla_company1.id,
+ })
+
+ # The company is changed -> reset the journals filter and select the first available group
+ options = self.report.get_options({'is_opening_report': True})
+ self._assert_filter_journal(options, "g1", [
+ {'id': 'divider'},
+ (g1, True),
+ (g2, False),
+ {'id': 'divider'},
+ (j1, False),
+ (j2, True),
+ (j3, True),
+ ])
+
+ # Check g2
+ options['__journal_group_action'] = {'action': 'add', 'id': g2.id}
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal(options, "g2", [
+ {'id': 'divider'},
+ (g1, False),
+ (g2, True),
+ {'id': 'divider'},
+ (j1, True),
+ (j2, False),
+ (j3, True),
+ ])
+
+ # Check that changing the sequence of journal groups changes the order in the filter, and therefore the default selected group
+ g2.sequence = g1.sequence - 1
+ options = self.report.get_options({'is_opening_report': True})
+ self._assert_filter_journal(options, "g2", [
+ {'id': 'divider'},
+ (g2, True),
+ (g1, False),
+ {'id': 'divider'},
+ (j1, True),
+ (j2, False),
+ (j3, True),
+ ])
+
+ def test_filter_journal_visible_items(self):
+ """ Test the unfolded and visible attributes of the dropdown items of the journals filter """
+ j1 = self._quick_create_journal("j1", self.vanilla_company1)
+ j2 = self._quick_create_journal("j5", self.vanilla_company2)
+ g1 = self._quick_create_journal_group("g1", False, j1)
+ options = self.report.get_options({'is_opening_report': True})
+
+ self._assert_filter_journal(options, "g1", [
+ {'id': 'divider'},
+ (g1, True),
+ {'id': 'divider'},
+ (j1, False),
+ {'id': 'divider'},
+ (j2, True),
+ ])
+
+ # visible, unfolded (None means there is no value because of irrelevance)
+ expected_values_at_opening = [
+ (None, None),
+ (None, None),
+ (None, False),
+ (False, None),
+ (None, False),
+ (False, None)
+ ]
+ # Ensure that when opening the report for the first time, all companies are folded
+ self._assert_filter_journal_visible_unfolded(options, expected_values_at_opening)
+
+ # simulate unfold of 1st company
+ options['journals'][2]['unfolded'] = True
+ options['journals'][3]['visible'] = True
+
+ options = self.report.get_options(previous_options=options)
+
+ expected_values_after_unfolding = [
+ (None, None),
+ (None, None),
+ (None, True),
+ (True, None),
+ (None, False),
+ (False, None)
+ ]
+ self._assert_filter_journal_visible_unfolded(options, expected_values_after_unfolding)
+
+ # simulate the refresh of the page, should fold back the 1st company
+ options['is_opening_report'] = True
+ options = self.report.get_options(previous_options=options)
+ self._assert_filter_journal_visible_unfolded(options, expected_values_at_opening)
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_account_reports_tax_reminder.py b/dev_odex30_accounting/odex30_account_reports/tests/test_account_reports_tax_reminder.py
new file mode 100644
index 0000000..89ffcbc
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_account_reports_tax_reminder.py
@@ -0,0 +1,135 @@
+# -*- coding: utf-8 -*-
+from dateutil.relativedelta import relativedelta
+from unittest.mock import patch
+
+from odoo.addons.odex30_account_reports.tests.common import TestAccountReportsCommon
+from odoo.tests import tagged
+from odoo import fields
+
+
+@tagged('post_install', '-at_install')
+class TestAccountReportsTaxReminder(TestAccountReportsCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.report = cls.env.ref('account.generic_tax_report')
+ cls.pay_activity_id = cls.env.ref('odex30_account_reports.mail_activity_type_tax_report_to_pay').id
+ cls.options = cls._generate_options(cls.report, '2024-08-01', '2024-08-31')
+ action = cls.env['account.tax.report.handler'].with_context({'override_tax_closing_warning': True}).action_periodic_vat_entries(cls.options)
+ cls.tax_return_move = cls.env['account.move'].browse(action['res_id'])
+
+ def test_posting_adds_an_activity(self):
+ """Posting the tax report move should be adding the proper tax to be sent activity"""
+ act_type_tax_to_pay = self.env.ref('odex30_account_reports.mail_activity_type_tax_report_to_pay')
+ act_type_report_to_send = self.env.ref('odex30_account_reports.mail_activity_type_tax_report_to_be_sent')
+ all_report_activity_type = act_type_report_to_send + act_type_tax_to_pay
+
+ self.tax_return_move.refresh_tax_entry()
+ self.assertEqual(self.tax_return_move.state, 'draft')
+ self.assertFalse(all_report_activity_type & self.tax_return_move.activity_ids.activity_type_id,
+ "There shouldn't be any of the closing activity on the closing move yet")
+
+ self.init_invoice(
+ 'out_invoice',
+ partner=self.partner_a,
+ invoice_date=self.tax_return_move.date + relativedelta(days=-1),
+ post=True,
+ amounts=[200],
+ taxes=self.tax_sale_a
+ )
+ self.tax_return_move.refresh_tax_entry()
+ # Posting the tax entry should post a mail activity of this type
+ with patch.object(self.env.registry[self.report._name], 'export_to_pdf', autospec=True, side_effect=lambda *args, **kwargs: {'file_name': 'dummy', 'file_content': b'', 'file_type': 'pdf'}):
+ self.tax_return_move.action_post()
+
+ self.assertEqual(self.tax_return_move.state, 'posted')
+ self.assertEqual(self.tax_return_move._get_tax_to_pay_on_closing(), 30.0)
+ self.assertRecordValues(self.tax_return_move.activity_ids, [{
+ 'activity_type_id': act_type_report_to_send.id,
+ 'summary': f'Send tax report: {self.tax_return_move.date.strftime("%B %Y")}',
+ 'date_deadline': fields.Date.context_today(self.env.user),
+ }, {
+ 'activity_type_id': act_type_tax_to_pay.id,
+ 'summary': f'Pay tax: {self.tax_return_move.date.strftime("%B %Y")}',
+ 'date_deadline': fields.Date.context_today(self.env.user),
+ }])
+
+ # Posting tax return again should not create another activity
+ before = len(self.tax_return_move.activity_ids)
+ self.tax_return_move.button_draft()
+ self.tax_return_move.refresh_tax_entry()
+ with patch.object(self.env.registry[self.report._name], 'export_to_pdf', autospec=True, side_effect=lambda *args, **kwargs: {'file_name': 'dummy', 'file_content': b'', 'file_type': 'pdf'}):
+ self.tax_return_move.action_post()
+ after = len(self.tax_return_move.activity_ids)
+ self.assertEqual(before, after, "resetting to draft and posting again shouldn't create a new activity")
+
+ # 0.0 tax returns create a send tax report activity but shouldn't trigger the payment activity
+ options = self._generate_options(self.report, '2024-09-01', '2024-09-30')
+ action = self.env['account.tax.report.handler'].with_context({'override_tax_closing_warning': True}).action_periodic_vat_entries(options)
+ next_tax_return_move = self.env['account.move'].browse(action['res_id'])
+ next_tax_return_move.refresh_tax_entry()
+ with patch.object(self.env.registry[self.report._name], 'export_to_pdf', autospec=True, side_effect=lambda *args, **kwargs: {'file_name': 'dummy', 'file_content': b'', 'file_type': 'pdf'}):
+ next_tax_return_move.action_post()
+ self.assertEqual(next_tax_return_move._get_tax_to_pay_on_closing(), 0.0)
+
+ self.assertRecordValues(next_tax_return_move.activity_ids, [{
+ 'activity_type_id': act_type_report_to_send.id,
+ 'summary': f'Send tax report: {next_tax_return_move.date.strftime("%B %Y")}',
+ 'date_deadline': fields.Date.context_today(self.env.user),
+ }])
+
+ next_tax_return_move.activity_ids.action_done()
+ self.assertFalse(all_report_activity_type & next_tax_return_move.activity_ids.activity_type_id,
+ "marking the sending as done shouldn't trigger any other similar activity")
+
+ def test_posting_without_amount_and_no_pay_activity(self):
+ """
+ 0.0 closing does not create a pay activity
+ """
+ with patch.object(self.env.registry[self.report._name], 'export_to_pdf', autospec=True, side_effect=lambda *args, **kwargs: {'file_name': 'dummy', 'file_content': b'', 'file_type': 'pdf'}):
+ self.tax_return_move.action_post()
+ self.assertEqual(self.tax_return_move._get_tax_to_pay_on_closing(), 0.0)
+ self.assertFalse(self.env['mail.activity'].search([
+ ('res_id', '=', self.tax_return_move.id),
+ ('activity_type_id', '=', self.pay_activity_id),
+ ]))
+
+ def test_tax_closing_activity_reminder_duplication(self):
+ """
+ Test triggering multiple times the closing action don't recreate an activity for the closing move even if the moves are cancelled
+ """
+ # Cancel the main one to be able to create new ones for this closing
+ self.tax_return_move.button_cancel()
+ for i in range(0, 2):
+ action = self.env['account.tax.report.handler'].with_context({'override_tax_closing_warning': True}).action_periodic_vat_entries(self.options)
+ move = self.env['account.move'].browse(action['res_id'])
+ move.button_cancel()
+ activity = self.env.company._get_tax_closing_reminder_activity(self.report.id, fields.Date.from_string(self.options['date']['date_to']))
+ self.assertEqual(len(activity), 1, "You cannot have duplicate tax closing reminder for the same report on the same period")
+
+ def test_tax_closing_activity_reminder_post(self):
+ """
+ Test that when posting a closing move, the next one is created
+ """
+ activity = self.env.company._get_tax_closing_reminder_activity(self.report.id, fields.Date.from_string(self.options['date']['date_to']))
+ self.assertTrue(activity, "There has been no activity created for the current period closing")
+ with patch.object(self.env.registry[self.report._name], 'export_to_pdf', autospec=True, side_effect=lambda *args, **kwargs: {'file_name': 'dummy', 'file_content': b'', 'file_type': 'pdf'}):
+ self.tax_return_move.action_post()
+
+ _dummy, period_end = self.env.company._get_tax_closing_period_boundaries(fields.Date.from_string(self.options['date']['date_to']) + relativedelta(days=1), self.report)
+ activity = self.env.company._get_tax_closing_reminder_activity(self.report.id, period_end)
+ self.assertTrue(activity, "There has been no activity created for the next period closing")
+
+ def test_tax_closing_activity_reminder_reset_on_periodicity_change(self):
+ """
+ Test that when changing the periodicity, the old activities got replaced by new ones
+ """
+ old_activity = self.env.company._get_tax_closing_reminder_activity(self.report.id, fields.Date.from_string(self.options['date']['date_to']))
+
+ self.env.company.account_tax_periodicity = 'year'
+ _dummy, period_end = self.env.company._get_tax_closing_period_boundaries(fields.Date.today(), self.report)
+ new_activity = self.env.company._get_tax_closing_reminder_activity(self.report.id, period_end)
+
+ self.assertNotEqual(old_activity.id, new_activity.id)
+ self.assertEqual(fields.Date.from_string(new_activity.account_tax_closing_params['tax_closing_end_date']), period_end)
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_account_reports_tours.py b/dev_odex30_accounting/odex30_account_reports/tests/test_account_reports_tours.py
new file mode 100644
index 0000000..28c9dcf
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_account_reports_tours.py
@@ -0,0 +1,87 @@
+# -*- coding: utf-8 -*-
+
+
+from odoo import Command, fields
+
+from odoo.tests import tagged
+from odoo.addons.account.tests.common import AccountTestInvoicingHttpCommon
+
+@tagged('post_install', '-at_install')
+class TestAccountReportsTours(AccountTestInvoicingHttpCommon):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.report = cls.env.ref('odex30_account_reports.balance_sheet')
+ cls.report.column_ids.sortable = True
+
+ # Create moves
+ cls.account_101401 = cls.env['account.account'].search([
+ ('company_ids', '=', cls.company_data['company'].id),
+ ('code', '=', 101401)
+ ])
+
+ cls.account_101402 = cls.env['account.account'].search([
+ ('company_ids', '=', cls.company_data['company'].id),
+ ('code', '=', 101402)
+ ])
+
+ cls.account_101404 = cls.env['account.account'].search([
+ ('company_ids', '=', cls.company_data['company'].id),
+ ('code', '=', '101404')
+ ])
+
+ cls.account_121000 = cls.env['account.account'].search([
+ ('company_ids', '=', cls.company_data['company'].id),
+ ('code', '=', 121000)
+ ])
+
+ cls.account_251000 = cls.env['account.account'].search([
+ ('company_ids', '=', cls.company_data['company'].id),
+ ('code', '=', 251000)
+ ])
+
+ move = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2022-06-01',
+ 'journal_id': cls.company_data['default_journal_cash'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 75.0, 'credit': 0.0, 'account_id': cls.account_101401.id}),
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'account_id': cls.account_101402.id}),
+ (0, 0, {'debit': 50.0, 'credit': 0.0, 'account_id': cls.account_101404.id}),
+ (0, 0, {'debit': 25.0, 'credit': 0.0, 'account_id': cls.account_121000.id}),
+ (0, 0, {'debit': 0.0, 'credit': 250.0, 'account_id': cls.account_251000.id}),
+ ],
+ })
+
+ move.action_post()
+
+ def test_account_reports_tours(self):
+ self.start_tour("/odoo", 'odex30_account_reports', login=self.env.user.login)
+
+ def test_account_reports_annotations_tours(self):
+ # Line ids
+ line_id_ta = self.report._get_generic_line_id('account.report.line', self.env.ref('odex30_account_reports.account_financial_report_total_assets0').id)
+ line_id_ca = self.report._get_generic_line_id('account.report.line', self.env.ref('odex30_account_reports.account_financial_report_current_assets_view0').id, parent_line_id=line_id_ta)
+ line_id_ba = self.report._get_generic_line_id('account.report.line', self.env.ref('odex30_account_reports.account_financial_report_bank_view0').id, parent_line_id=line_id_ca)
+ line_id_101401 = self.report._get_generic_line_id('account.account', self.account_101401.id, markup={'groupby': 'account_id'}, parent_line_id=line_id_ba)
+ line_id_cas = self.report._get_generic_line_id('account.report.line', self.env.ref('odex30_account_reports.account_financial_report_current_assets0').id, parent_line_id=line_id_ca)
+ line_id_101404 = self.report._get_generic_line_id('account.account', self.account_101404.id, markup={'groupby': 'account_id'}, parent_line_id=line_id_cas)
+ # Create annotations
+ date = fields.Date.today().strftime('%Y-%m-%d')
+ self.report.write({
+ 'annotations_ids': [
+ Command.create({
+ 'line_id': line_id_101401,
+ 'text': 'Annotation 101401',
+ 'date': date,
+ }),
+ Command.create({
+ 'line_id': line_id_101404,
+ 'text': 'Annotation 101404',
+ 'date': date,
+ }),
+ ]
+ })
+
+ self.start_tour("/odoo", 'account_reports_annotations', login=self.env.user.login)
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_account_sales_report_generic.py b/dev_odex30_accounting/odex30_account_reports/tests/test_account_sales_report_generic.py
new file mode 100644
index 0000000..95bf8bf
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_account_sales_report_generic.py
@@ -0,0 +1,177 @@
+# -*- coding: utf-8 -*-
+
+from odoo.addons.odex30_account_reports.tests.account_sales_report_common import AccountSalesReportCommon
+from odoo.tests import tagged
+from odoo.tools.misc import NON_BREAKING_SPACE
+from freezegun import freeze_time
+
+from odoo import Command
+
+
+@tagged('post_install', '-at_install')
+class AccountSalesReportTest(AccountSalesReportCommon):
+
+ @classmethod
+ def collect_company_accounting_data(cls, company):
+ res = super().collect_company_accounting_data(company)
+ res['company'].update({
+ 'country_id': cls.env.ref('base.us').id,
+ 'vat': 'US123456789047',
+ # Country outside of EU to avoid local reports being chosen over this one (wanted behaviour)
+ })
+ return res
+
+ @freeze_time('2019-12-31')
+ def test_ec_sales_report(self):
+ l_tax = self.env['account.tax'].create({
+ 'name': 'goods',
+ 'amount_type': 'percent',
+ 'amount': 0,
+ 'type_tax_use': 'sale',
+ 'price_include_override': 'tax_excluded',
+ 'include_base_amount': False,
+ })
+ t_tax = self.env['account.tax'].create({
+ 'name': 'triangular',
+ 'amount_type': 'percent',
+ 'amount': 0,
+ 'type_tax_use': 'purchase',
+ 'price_include_override': 'tax_excluded',
+ 'include_base_amount': False,
+ })
+ s_tax = self.env['account.tax'].create({
+ 'name': 'services',
+ 'amount_type': 'percent',
+ 'amount': 0,
+ 'type_tax_use': 'sale',
+ 'price_include_override': 'tax_excluded',
+ 'include_base_amount': False,
+ })
+ bad_tax_1 = self.env['account.tax'].create({
+ 'name': 'bad_1',
+ 'amount_type': 'fixed',
+ 'amount': 0,
+ 'type_tax_use': 'sale',
+ 'price_include_override': 'tax_excluded',
+ 'include_base_amount': False,
+ })
+ bad_tax_2 = self.env['account.tax'].create({
+ 'name': 'bad_2',
+ 'amount_type': 'percent',
+ 'amount': 10,
+ 'type_tax_use': 'sale',
+ 'price_include_override': 'tax_excluded',
+ 'include_base_amount': False,
+ })
+ self._create_invoices([
+ (self.partner_a, l_tax[:1], 100),
+ (self.partner_a, l_tax[:1], 200),
+ (self.partner_a, t_tax[:1], 300), # Should be ignored due to purchase tax
+ (self.partner_b, t_tax[:1], 100), # Should be ignored due to purchase tax
+ (self.partner_a, s_tax[:1], 400),
+ (self.partner_b, s_tax[:1], 500),
+ (self.partner_b, bad_tax_1[:1], 700), # Should be ignored due to fixed amount
+ (self.partner_b, bad_tax_2[:1], 700), # Should be ignored due to non-null amount
+ ])
+ report = self.env.ref('odex30_account_reports.generic_ec_sales_report')
+ options = self._generate_options(report, '2019-12-01', '2019-12-31')
+
+ self.assertLinesValues(
+ report._get_lines(options),
+ # pylint: disable=C0326
+ # Partner, country code, VAT Number, Amount
+ [ 0, 1, 2, 3],
+ [
+ (self.partner_a.name, self.partner_a.vat[:2], self.partner_a.vat[2:], f'${NON_BREAKING_SPACE}700.00'),
+ (self.partner_b.name, self.partner_b.vat[:2], self.partner_b.vat[2:], f'${NON_BREAKING_SPACE}500.00'),
+ ('Total', '', '', f'${NON_BREAKING_SPACE}1,200.00'),
+ ],
+ options,
+ )
+
+ @freeze_time('2019-12-31')
+ def test_ec_sales_report_with_northern_irish_customer(self):
+ """
+ Ensure that Northern Irish companies are included in the EC sales report.
+ """
+ northern_ireland = self.env.ref('account_intrastat.xi', raise_if_not_found=False)
+
+ if not northern_ireland:
+ self.skipTest("`account_intrastat` module not installed")
+
+ self.partner_a.write({
+ 'country_id': northern_ireland.id,
+ 'vat': 'IE1234567FA',
+ })
+ self.tax_sale_a.amount = 0
+
+ self._create_invoices([
+ (self.partner_a, self.tax_sale_a, 100),
+ ])
+ report = self.env.ref('odex30_account_reports.generic_ec_sales_report')
+ options = self._generate_options(report, '2019-12-01', '2019-12-31')
+
+ self.assertLinesValues(
+ report._get_lines(options),
+ # Partner, country code, VAT Number, Amount
+ [ 0, 1, 2, 3],
+ [
+ (self.partner_a.name, self.partner_a.vat[:2], self.partner_a.vat[2:], f'${NON_BREAKING_SPACE}100.00'),
+ ('Total', '', '', f'${NON_BREAKING_SPACE}100.00'),
+ ],
+ options,
+ )
+
+ def test_ec_sales_set_as_main(self):
+ """Test setting a partner as the main in EC Sales Report when two partners share the same VAT.
+
+ Scenario:
+ - Two partners have the same VAT.
+ - Set one partner as the main partner.
+ - Validate that the second partner is linked correctly as a child and that moves are updated.
+ """
+ # Prepare partners
+ partner_main = self.partner_a
+ partner_duplicate = self.partner_b
+ partner_duplicate.vat = partner_main.vat
+
+ move_vals = {
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2025-04-29',
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 500.0,
+ 'tax_ids': [],
+ })],
+ }
+ # Create and post invoice for the main partner
+ move_main = self.env['account.move'].create({**move_vals, 'partner_id': partner_main.id})
+ move_main.action_post()
+ # Create and post invoice for the duplicate partner
+ move_duplicate = self.env['account.move'].create({**move_vals, 'partner_id': partner_duplicate.id})
+ move_duplicate.action_post()
+
+ # Call with context
+ partner_main.with_context(duplicated_partners_vat=[partner_main.vat, partner_duplicate.vat]).set_commercial_partner_main()
+
+ # Assertions: partner relationships
+ self.assertEqual(
+ partner_duplicate.commercial_partner_id, partner_main,
+ "Duplicate partner's commercial partner should be the main partner."
+ )
+ self.assertEqual(
+ partner_duplicate.parent_id, partner_main,
+ "Duplicate partner's parent should be set to the main partner."
+ )
+
+ # Assertions: accounting move reassignment
+ self.assertEqual(
+ move_duplicate.commercial_partner_id, partner_main,
+ "The move's commercial partner should also be reassigned."
+ )
+
+ # Assertions: journal items (move lines)
+ self.assertEqual(
+ move_duplicate.line_ids.partner_id, partner_main,
+ "Each move line should now be assigned to the main partner."
+ )
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_aged_payable_report.py b/dev_odex30_accounting/odex30_account_reports/tests/test_aged_payable_report.py
new file mode 100644
index 0000000..a2ac028
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_aged_payable_report.py
@@ -0,0 +1,798 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=C0326
+from .common import TestAccountReportsCommon
+
+from odoo import fields, Command
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestAgedPayableReport(TestAccountReportsCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.partner_category_a = cls.env['res.partner.category'].create({'name': 'partner_categ_a'})
+ cls.partner_category_b = cls.env['res.partner.category'].create({'name': 'partner_categ_b'})
+
+ cls.partner_a.write({'category_id': [Command.set([cls.partner_category_a.id, cls.partner_category_b.id])]})
+ cls.partner_b.write({'category_id': [Command.set([cls.partner_category_a.id])]})
+
+ payable_1 = cls.company_data['default_account_payable']
+ payable_2 = cls.company_data['default_account_payable'].copy()
+ payable_3 = cls.company_data['default_account_payable'].copy()
+ payable_4 = cls.company_data_2['default_account_payable']
+ payable_5 = cls.company_data_2['default_account_payable'].copy()
+ payable_6 = cls.company_data_2['default_account_payable'].copy()
+ misc_1 = cls.company_data['default_account_expense']
+ misc_2 = cls.company_data_2['default_account_expense']
+
+ # Test will use the following dates:
+ # As of 2017-02-01
+ # 1 - 30: 2017-01-31 - 2017-01-02
+ # 31 - 60: 2017-01-01 - 2016-12-03
+ # 61 - 90: 2016-12-02 - 2016-11-03
+ # 91 - 120: 2016-11-02 - 2016-10-04
+ # Older: 2016-10-03
+
+ # ==== Journal entries in company_1 for partner_a ====
+
+ move_1 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2016-11-03'),
+ 'journal_id': cls.company_data['default_journal_purchase'].id,
+ 'line_ids': [
+ # 1000.0 in 61 - 90.
+ Command.create({'debit': 0.0, 'credit': 1000.0, 'date_maturity': False, 'account_id': payable_1.id, 'partner_id': cls.partner_a.id}),
+ # -800.0 in 31 - 60
+ Command.create({'debit': 800.0, 'credit': 0.0, 'date_maturity': '2017-01-01', 'account_id': payable_2.id, 'partner_id': cls.partner_a.id}),
+ # Ignored line.
+ Command.create({'debit': 200.0, 'credit': 0.0, 'date_maturity': False, 'account_id': misc_1.id, 'partner_id': cls.partner_a.id}),
+ ],
+ })
+
+ move_2 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2016-10-05'),
+ 'journal_id': cls.company_data['default_journal_purchase'].id,
+ 'line_ids': [
+ # -200.0 in 61 - 90
+ Command.create({'debit': 200.0, 'credit': 0.0, 'date_maturity': '2016-12-02', 'account_id': payable_1.id, 'partner_id': cls.partner_a.id}),
+ # -300.0 in 31 - 60
+ Command.create({'debit': 300.0, 'credit': 0.0, 'date_maturity': '2016-12-03', 'account_id': payable_1.id, 'partner_id': cls.partner_a.id}),
+ # 1000.0 in 91 - 120
+ Command.create({'debit': 0.0, 'credit': 1000.0, 'date_maturity': False, 'account_id': payable_2.id, 'partner_id': cls.partner_a.id}),
+ # 100.0 in all dates
+ Command.create({'debit': 0.0, 'credit': 100.0, 'date_maturity': '2017-02-01', 'account_id': payable_3.id, 'partner_id': cls.partner_a.id}),
+ Command.create({'debit': 0.0, 'credit': 100.0, 'date_maturity': '2017-01-02', 'account_id': payable_3.id, 'partner_id': cls.partner_a.id}),
+ Command.create({'debit': 0.0, 'credit': 100.0, 'date_maturity': '2016-12-03', 'account_id': payable_3.id, 'partner_id': cls.partner_a.id}),
+ Command.create({'debit': 0.0, 'credit': 100.0, 'date_maturity': '2016-11-03', 'account_id': payable_3.id, 'partner_id': cls.partner_a.id}),
+ Command.create({'debit': 0.0, 'credit': 100.0, 'date_maturity': '2016-10-04', 'account_id': payable_3.id, 'partner_id': cls.partner_a.id}),
+ Command.create({'debit': 0.0, 'credit': 100.0, 'date_maturity': '2016-01-01', 'account_id': payable_3.id, 'partner_id': cls.partner_a.id}),
+ # Ignored line.
+ Command.create({'debit': 1100.0, 'credit': 0.0, 'date_maturity': '2016-10-05', 'account_id': misc_1.id, 'partner_id': cls.partner_a.id}),
+ ],
+ })
+ (move_1 + move_2).action_post()
+ (move_1 + move_2).line_ids.filtered(lambda line: line.account_id == payable_1).reconcile()
+ (move_1 + move_2).line_ids.filtered(lambda line: line.account_id == payable_2).reconcile()
+
+ # ==== Journal entries in company_2 for partner_b ====
+
+ move_3 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2016-11-03'),
+ 'journal_id': cls.company_data_2['default_journal_purchase'].id,
+ 'line_ids': [
+ # 1000.0 in 61 - 90.
+ Command.create({'debit': 0.0, 'credit': 1000.0, 'date_maturity': False, 'account_id': payable_4.id, 'partner_id': cls.partner_b.id}),
+ # -800.0 in 31 - 60
+ Command.create({'debit': 800.0, 'credit': 0.0, 'date_maturity': '2017-01-01', 'account_id': payable_5.id, 'partner_id': cls.partner_b.id}),
+ # Ignored line.
+ Command.create({'debit': 200.0, 'credit': 0.0, 'date_maturity': False, 'account_id': misc_2.id, 'partner_id': cls.partner_b.id}),
+ ],
+ })
+
+ move_4 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2016-10-05'),
+ 'journal_id': cls.company_data_2['default_journal_purchase'].id,
+ 'line_ids': [
+ # -200.0 in 61 - 90
+ Command.create({'debit': 200.0, 'credit': 0.0, 'date_maturity': '2016-12-02', 'account_id': payable_4.id, 'partner_id': cls.partner_b.id}),
+ # -300.0 in 31 - 60
+ Command.create({'debit': 300.0, 'credit': 0.0, 'date_maturity': '2016-12-03', 'account_id': payable_4.id, 'partner_id': cls.partner_b.id}),
+ # 1000.0 in 91 - 120
+ Command.create({'debit': 0.0, 'credit': 1000.0, 'date_maturity': False, 'account_id': payable_5.id, 'partner_id': cls.partner_b.id}),
+ # 100.0 in all dates
+ Command.create({'debit': 0.0, 'credit': 100.0, 'date_maturity': '2017-02-01', 'account_id': payable_6.id, 'partner_id': cls.partner_b.id}),
+ Command.create({'debit': 0.0, 'credit': 100.0, 'date_maturity': '2017-01-02', 'account_id': payable_6.id, 'partner_id': cls.partner_b.id}),
+ Command.create({'debit': 0.0, 'credit': 100.0, 'date_maturity': '2016-12-03', 'account_id': payable_6.id, 'partner_id': cls.partner_b.id}),
+ Command.create({'debit': 0.0, 'credit': 100.0, 'date_maturity': '2016-11-03', 'account_id': payable_6.id, 'partner_id': cls.partner_b.id}),
+ Command.create({'debit': 0.0, 'credit': 100.0, 'date_maturity': '2016-10-04', 'account_id': payable_6.id, 'partner_id': cls.partner_b.id}),
+ Command.create({'debit': 0.0, 'credit': 100.0, 'date_maturity': '2016-01-01', 'account_id': payable_6.id, 'partner_id': cls.partner_b.id}),
+ # Ignored line.
+ Command.create({'debit': 1100.0, 'credit': 0.0, 'date_maturity': '2016-10-05', 'account_id': misc_2.id, 'partner_id': cls.partner_b.id}),
+ ],
+ })
+ (move_3 + move_4).action_post()
+ (move_3 + move_4).line_ids.filtered(lambda line: line.account_id == payable_4).reconcile()
+ (move_3 + move_4).line_ids.filtered(lambda line: line.account_id == payable_5).reconcile()
+ cls.env['res.currency'].search([('name', '!=', 'USD')]).with_context(force_deactivate=True).active = False
+ cls.env.companies = cls.company_data['company'] + cls.company_data_2['company']
+ cls.report = cls.env.ref('odex30_account_reports.aged_payable_report')
+ cls.parent_line_id = cls._get_basic_line_dict_id_from_report_line_ref("odex30_account_reports.aged_payable_line")
+
+ def test_aged_payable_unfold_1_whole_report(self):
+ """ Test unfolding a line when rendering the whole report. """
+ options = self._generate_options(self.report, fields.Date.from_string('2017-02-01'), fields.Date.from_string('2017-02-01'))
+ partner_a_line_id = self.report._get_generic_line_id('res.partner', self.partner_a.id, parent_line_id=self.parent_line_id, markup={'groupby': 'partner_id'})
+ options['unfolded_lines'] = [partner_a_line_id]
+
+ report_lines = self.report._get_lines(options)
+
+ # Sort by Invoice Date
+ options['order_column'] = {
+ 'expression_label': 'invoice_date',
+ 'direction': 'ASC',
+ }
+
+ sorted_report_lines = self.report.sort_lines(report_lines, options)
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ sorted_report_lines,
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('BILL/2016/11/0001', 0.0, 0.0, 0.0, 500.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 200.0, 0.0, ''),
+ ('BILL/2016/10/0001', 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 100.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, ''),
+ ('Total partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('Total Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ],
+ options,
+ )
+
+ # Sort 61 - 90 decreasing.
+ options['order_column'] = {
+ 'expression_label': 'period3',
+ 'direction': 'DESC',
+ }
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report.sort_lines(sorted_report_lines, options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('BILL/2016/11/0001', 0.0, 0.0, 0.0, 500.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 200.0, 0.0, ''),
+ ('BILL/2016/10/0001', 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 100.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, ''),
+ ('Total partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('Total Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ],
+ options,
+ )
+
+ # Sort 61 - 90 increasing.
+ options['order_column'] = {
+ 'expression_label': 'period3',
+ 'direction': 'ASC',
+ }
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report.sort_lines(sorted_report_lines, options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 200.0, 0.0, ''),
+ ('BILL/2016/10/0001', 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 100.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, ''),
+ ('BILL/2016/11/0001', 0.0, 0.0, 0.0, 500.0, 0.0, 0.0, ''),
+ ('Total partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('Total Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ],
+ options,
+ )
+
+ def test_aged_payable_unfold_all(self):
+ default_options = {
+ 'unfold_all': True,
+ 'order_column': {
+ 'expression_label': 'invoice_date',
+ 'direction': 'ASC',
+ }
+ }
+ options = self._generate_options(self.report, '2017-02-01', '2017-02-01', default_options=default_options)
+
+ report_lines = self.report._get_lines(options)
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report.sort_lines(report_lines, options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('BILL/2016/11/0001', 0.0, 0.0, 0.0, 500.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 200.0, 0.0, ''),
+ ('BILL/2016/10/0001', 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 100.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, ''),
+ ('Total partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('BILL/2016/11/0001', 0.0, 0.0, 0.0, 250.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('BILL/2016/10/0001', 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 50.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 50.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 50.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 50.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 0.0, 50.0, ''),
+ ('Total partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('Total Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ],
+ options
+ )
+
+ def test_aged_payable_unknown_partner(self):
+ """ Test that journal items without a partner in the payable account appear as unknown partner. """
+
+ misc_move = self.env['account.move'].create({
+ 'date': '2017-03-31',
+ 'line_ids': [
+ Command.create({'debit': 0.0, 'credit': 1000.0, 'account_id': self.company_data['default_account_expense'].id}),
+ Command.create({'debit': 1000.0, 'credit': 0.0, 'account_id': self.company_data['default_account_payable'].id}),
+ ],
+ })
+ misc_move.action_post()
+
+ options = self._generate_options(self.report, fields.Date.from_string('2017-03-01'), fields.Date.from_string('2017-04-01'))
+ self.env.company.totals_below_sections = False
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', 0.0, -1000.0, 150.0, 150.0, 150.0, 1500.0, 950.0),
+ ('partner_a', 0.0, 0.0, 100.0, 100.0, 100.0, 1000.0, 1300.0),
+ ('partner_b', 0.0, 0.0, 50.0, 50.0, 50.0, 500.0, 650.0),
+ ('Unknown', 0.0, -1000.0, 0.0, 0.0, 0.0, 0.0, -1000.0),
+ ],
+ options,
+ )
+
+ def test_aged_payable_filter_partners(self):
+ """ Test the filter on top allowing to filter on res.partner. """
+ options = self._generate_options(self.report, fields.Date.from_string('2017-02-01'), fields.Date.from_string('2017-02-01'))
+ options['partner_ids'] = self.partner_a.ids
+ self.env.company.totals_below_sections = False
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Invoice Date Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 1, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', '', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('partner_a', '', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ],
+ options,
+ )
+
+ def test_aged_payable_filter_partner_categories(self):
+ """ Test the filter on top allowing to filter on res.partner.category. """
+ options = self._generate_options(self.report, fields.Date.from_string('2017-02-01'), fields.Date.from_string('2017-02-01'))
+ options['partner_categories'] = self.partner_category_a.ids
+ self.env.company.totals_below_sections = False
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ],
+ options,
+ )
+
+ def test_aged_payable_reconciliation_date(self):
+ """ Check the values at a date before some reconciliations are done. """
+ options = self._generate_options(self.report, fields.Date.from_string('2016-10-31'), fields.Date.from_string('2016-10-31'))
+ self.env.company.totals_below_sections = False
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', -133.33, 1466.67, 0.0, 0.0, 0.0, 133.33, 1466.67),
+ ('partner_a', -100.00, 1100.00, 0.0, 0.0, 0.0, 100.00, 1100.00),
+ ('partner_b', -33.33, 366.67, 0.0, 0.0, 0.0, 33.33, 366.67),
+ ],
+ options,
+ )
+
+ def test_aged_payablesort_lines_by_date(self):
+ """ Test the sort_lines function using date as sort key. """
+ options = self._generate_options(self.report, fields.Date.from_string('2017-02-01'), fields.Date.from_string('2017-02-01'))
+ partner_a_line_id = self.report._get_generic_line_id('res.partner', self.partner_a.id, parent_line_id=self.parent_line_id, markup={'groupby': 'partner_id'})
+ partner_b_line_id = self.report._get_generic_line_id('res.partner', self.partner_b.id, parent_line_id=self.parent_line_id, markup={'groupby': 'partner_id'})
+ options['unfolded_lines'] = [partner_a_line_id, partner_b_line_id]
+
+ # Sort by Invoice Date increasing
+ options['order_column'] = {
+ 'expression_label': 'invoice_date',
+ 'direction': 'ASC',
+ }
+
+ report_lines = self.report._get_lines(options)
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report.sort_lines(report_lines, options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('BILL/2016/11/0001', 0.0, 0.0, 0.0, 500.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 200.0, 0.0, ''),
+ ('BILL/2016/10/0001', 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 100.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, ''),
+ ('Total partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('BILL/2016/11/0001', 0.0, 0.0, 0.0, 250.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('BILL/2016/10/0001', 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 50.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 50.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 50.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 50.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 0.0, 50.0, ''),
+ ('Total partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('Total Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ],
+ options,
+ )
+
+ # Sort by Invoice Date increasing
+ options['order_column'] = {
+ 'expression_label': 'invoice_date',
+ 'direction': 'DESC',
+ }
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report.sort_lines(report_lines, options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('BILL/2016/11/0001', 0.0, 0.0, 0.0, 500.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 200.0, 0.0, ''),
+ ('BILL/2016/10/0001', 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 100.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, ''),
+ ('Total partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('BILL/2016/11/0001', 0.0, 0.0, 0.0, 250.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('BILL/2016/10/0001', 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 50.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 50.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 50.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 50.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 0.0, 50.0, ''),
+ ('Total partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('Total Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ],
+ options,
+ )
+
+ def test_aged_payablesort_lines_by_numeric_value(self):
+ """ Test the sort_lines function using float as sort key. """
+ options = self._generate_options(self.report, fields.Date.from_string('2017-02-01'), fields.Date.from_string('2017-02-01'))
+ partner_a_line_id = self.report._get_generic_line_id('res.partner', self.partner_a.id, parent_line_id=self.parent_line_id, markup={'groupby': 'partner_id'})
+ partner_b_line_id = self.report._get_generic_line_id('res.partner', self.partner_b.id, parent_line_id=self.parent_line_id, markup={'groupby': 'partner_id'})
+ options['unfolded_lines'] = [partner_a_line_id, partner_b_line_id]
+
+ report_lines = self.report._get_lines(options)
+
+ # Sort by Not Due On increasing
+ options['order_column'] = {
+ 'expression_label': 'period0',
+ 'direction': 'ASC',
+ }
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report.sort_lines(report_lines, options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('BILL/2016/11/0001', 0.0, 0.0, 0.0, 250.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 50.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 50.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 50.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 50.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 0.0, 50.0, ''),
+ ('BILL/2016/10/0001', 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('BILL/2016/11/0001', 0.0, 0.0, 0.0, 500.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 200.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 100.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, ''),
+ ('BILL/2016/10/0001', 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('Total Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ],
+ options,
+ )
+
+ # Sort by Not Due On decreasing
+ options['order_column'] = {
+ 'expression_label': 'period0',
+ 'direction': 'DESC',
+ }
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report.sort_lines(report_lines, options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('BILL/2016/10/0001', 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/11/0001', 0.0, 0.0, 0.0, 500.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 200.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 100.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, ''),
+ ('Total partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('BILL/2016/10/0001', 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/11/0001', 0.0, 0.0, 0.0, 250.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 50.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 50.0, 0.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 50.0, 0.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 50.0, 0.0, ''),
+ ('BILL/2016/10/0001', 0.0, 0.0, 0.0, 0.0, 0.0, 50.0, ''),
+ ('Total partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('Total Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ],
+ options,
+ )
+
+ def test_aged_payable_zero_balanced_without_reconciliation(self):
+ options = self._generate_options(self.report, '2010-01-01', '2010-01-01', default_options={'unfold_all': True})
+ invoice = self.env['account.move'].create({
+ 'move_type': 'in_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': '2010-01-01',
+ 'date': '2010-01-01',
+ 'invoice_date_due': '2010-01-01',
+ 'payment_reference': 'I',
+ 'invoice_line_ids': [Command.create({
+ 'name': 'test invoice',
+ 'price_unit': 100,
+ 'tax_ids': [],
+ })]
+ })
+ invoice.action_post()
+
+ refund = self.env['account.move'].create({
+ 'move_type': 'in_refund',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': '2010-01-01',
+ 'date': '2010-01-01',
+ 'invoice_date_due': '2010-01-01',
+ 'payment_reference': 'R',
+ 'invoice_line_ids': [Command.create({
+ 'name': 'test refund',
+ 'price_unit': 100,
+ 'tax_ids': [],
+ })]
+ })
+ refund.action_post()
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('partner_a', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ (f"{refund.name} R", -100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ (f"{invoice.name} I", 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total partner_a', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Total Aged Payable', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ],
+ options,
+ )
+
+ # It should still work if both invoice and refund are partially reconciled with the same amount
+ self.env['account.payment.register'].with_context(active_ids=invoice.ids, active_model='account.move').create({
+ 'amount': 42,
+ 'payment_date': '2010-01-01',
+ 'payment_method_line_id': self.inbound_payment_method_line.id,
+ })._create_payments()
+
+ self.env['account.payment.register'].with_context(active_ids=refund.ids, active_model='account.move').create({
+ 'amount': 42,
+ 'payment_date': '2010-01-01',
+ 'payment_method_line_id': self.inbound_payment_method_line.id,
+ })._create_payments()
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('partner_a', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ (f"{refund.name} R", -58.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ (f"{invoice.name} I", 58.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total partner_a', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Total Aged Payable', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ],
+ options,
+ )
+
+ # It should still work if both invoice and refund are fully reconciled in the future
+ self.env['account.payment.register'].with_context(active_ids=invoice.ids, active_model='account.move').create({
+ 'amount': 58,
+ 'payment_date': '2020-01-01',
+ 'payment_method_line_id': self.inbound_payment_method_line.id,
+ })._create_payments()
+
+ self.env['account.payment.register'].with_context(active_ids=refund.ids, active_model='account.move').create({
+ 'amount': 58,
+ 'payment_date': '2020-01-01',
+ 'payment_method_line_id': self.inbound_payment_method_line.id,
+ })._create_payments()
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('partner_a', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ (f"{refund.name} R", -58.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ (f"{invoice.name} I", 58.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total partner_a', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Total Aged Payable', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ],
+ options,
+ )
+
+ def test_aged_payable_prefix_groups(self):
+ partner_names = [
+ 'A',
+ 'A partner',
+ 'A nice partner',
+ 'A new partner',
+ 'An original partner',
+ 'Another partner',
+ 'Anonymous partner',
+ 'Annoyed partner',
+ 'Brave partner',
+ ]
+
+ test_date = '2010-12-13'
+ invoices_map = {}
+ for name in partner_names:
+ partner = self.env['res.partner'].create({'name': name})
+ invoice = self.init_invoice('in_invoice', partner=partner, invoice_date=test_date, amounts=[42.0], taxes=[], post=True)
+ invoices_map[name] = invoice.name
+
+ # Without prefix groups
+ options = self._generate_options(self.report, test_date, test_date)
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', 378.0, 0.0, 0.0, 0.0, 0.0, 0.0, 378.0),
+ ('A', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('A new partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('A nice partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('A partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('An original partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Annoyed partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Anonymous partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Another partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Brave partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Total Aged Payable', 378.0, 0.0, 0.0, 0.0, 0.0, 0.0, 378.0),
+ ],
+ options,
+ )
+
+ # With prefix groups
+ self.report.prefix_groups_threshold = 3
+ options = self._generate_options(self.report, test_date, test_date, default_options={'unfold_all': True})
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', 378.0, 0.0, 0.0, 0.0, 0.0, 0.0, 378.0),
+ ('A (8 lines)', 336.0, 0.0, 0.0, 0.0, 0.0, 0.0, 336.0),
+ ('A', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ (invoices_map['A'], 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total A', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('A[ ] (3 lines)', 126.0, 0.0, 0.0, 0.0, 0.0, 0.0, 126.0),
+ ('A N (2 lines)', 84.0, 0.0, 0.0, 0.0, 0.0, 0.0, 84.0),
+ ('A new partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ (invoices_map['A new partner'], 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total A new partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('A nice partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ (invoices_map['A nice partner'], 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total A nice partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Total A N (2 lines)', 84.0, 0.0, 0.0, 0.0, 0.0, 0.0, 84.0),
+ ('A P (1 line)', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('A partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ (invoices_map['A partner'], 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total A partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Total A P (1 line)', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Total A[ ] (3 lines)', 126.0, 0.0, 0.0, 0.0, 0.0, 0.0, 126.0),
+ ('AN (4 lines)', 168.0, 0.0, 0.0, 0.0, 0.0, 0.0, 168.0),
+ ('AN[ ] (1 line)', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('An original partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ (invoices_map['An original partner'], 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total An original partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Total AN[ ] (1 line)', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('ANN (1 line)', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Annoyed partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ (invoices_map['Annoyed partner'], 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total Annoyed partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Total ANN (1 line)', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('ANO (2 lines)', 84.0, 0.0, 0.0, 0.0, 0.0, 0.0, 84.0),
+ ('Anonymous partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ (invoices_map['Anonymous partner'], 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total Anonymous partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Another partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ (invoices_map['Another partner'], 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total Another partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Total ANO (2 lines)', 84.0, 0.0, 0.0, 0.0, 0.0, 0.0, 84.0),
+ ('Total AN (4 lines)', 168.0, 0.0, 0.0, 0.0, 0.0, 0.0, 168.0),
+ ('Total A (8 lines)', 336.0, 0.0, 0.0, 0.0, 0.0, 0.0, 336.0),
+ ('B (1 line)', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Brave partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ (invoices_map['Brave partner'], 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total Brave partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Total B (1 line)', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Total Aged Payable', 378.0, 0.0, 0.0, 0.0, 0.0, 0.0, 378.0),
+ ],
+ options,
+ )
+
+ def test_aged_payable_aging_interval(self):
+ options = self._generate_options(self.report, '2017-02-01', '2017-02-01')
+ initial_report_lines = self.report._get_lines(options)
+
+ # With default interval of 30
+ self.assertLinesValues(
+ self.report.sort_lines(initial_report_lines, options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('Total Aged Payable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ],
+ options
+ )
+
+ options['aging_interval'] = 60
+ report_lines = self.report._get_lines(options)
+
+ # With interval of 60
+ self.assertLinesValues(
+ self.report.sort_lines(report_lines, options),
+ # Name Not Due On 1 - 60 61 - 120 121 - 180 181 - 240 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', 150.0, 300.0, 1350.0, 0.0, 0.0, 150.0, 1950.0),
+ ('partner_a', 100.0, 200.0, 900.0, 0.0, 0.0, 100.0, 1300.0),
+ ('partner_b', 50.0, 100.0, 450.0, 0.0, 0.0, 50.0, 650.0),
+ ('Total Aged Payable', 150.0, 300.0, 1350.0, 0.0, 0.0, 150.0, 1950.0),
+ ],
+ options
+ )
+
+ def test_storno_refund_account_payable(self):
+ self.env.company.account_storno = True
+
+ great_partner = self.env['res.partner'].create({'name': 'Great Partner'})
+ refund = self.env['account.move'].create({
+ 'move_type': 'in_refund',
+ 'partner_id': great_partner.id,
+ 'invoice_date': '2010-02-01',
+ 'date': '2010-02-01',
+ 'invoice_date_due': '2010-02-01',
+ 'invoice_line_ids': [Command.create({
+ 'name': 'Great Product',
+ 'price_unit': 100,
+ 'tax_ids': [],
+ })]
+ })
+ refund.action_post()
+ self.env['account.payment.register'].with_context(active_model='account.move', active_ids=refund.ids).create({
+ 'payment_date': '2010-02-01',
+ 'amount': 100.0,
+ })._create_payments()
+
+ options = self._generate_options(self.report, '2010-02-01', '2010-02-01')
+ self.env.company.totals_below_sections = False
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Due Date Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 1, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Payable', '', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ],
+ options,
+ )
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_aged_receivable_report.py b/dev_odex30_accounting/odex30_account_reports/tests/test_aged_receivable_report.py
new file mode 100644
index 0000000..ad14c7c
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_aged_receivable_report.py
@@ -0,0 +1,835 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=C0326
+from .common import TestAccountReportsCommon
+
+from odoo import fields, Command
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestAgedReceivableReport(TestAccountReportsCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.partner_category_a = cls.env['res.partner.category'].create({'name': 'partner_categ_a'})
+ cls.partner_category_b = cls.env['res.partner.category'].create({'name': 'partner_categ_b'})
+
+ cls.partner_a.write({'category_id': [Command.set([cls.partner_category_a.id, cls.partner_category_b.id])]})
+ cls.partner_b.write({'category_id': [Command.set([cls.partner_category_a.id])]})
+
+ receivable_1 = cls.company_data['default_account_receivable']
+ receivable_2 = cls.copy_account(cls.company_data['default_account_receivable'])
+ receivable_3 = cls.copy_account(cls.company_data['default_account_receivable'])
+ receivable_4 = cls.company_data_2['default_account_receivable']
+ receivable_5 = cls.copy_account(cls.company_data_2['default_account_receivable'])
+ receivable_6 = cls.copy_account(cls.company_data_2['default_account_receivable'])
+ misc_1 = cls.company_data['default_account_revenue']
+ misc_2 = cls.company_data_2['default_account_revenue']
+
+ # Test will use the following dates:
+ # As of 2017-02-01
+ # 1 - 30: 2017-01-31 - 2017-01-02
+ # 31 - 60: 2017-01-01 - 2016-12-03
+ # 61 - 90: 2016-12-02 - 2016-11-03
+ # 91 - 120: 2016-11-02 - 2016-10-04
+ # Older: 2016-10-03
+
+ # ==== Journal entries in company_1 for partner_a ====
+
+ move_1 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2016-11-03'),
+ 'invoice_date': fields.Date.from_string('2016-11-03'),
+ 'journal_id': cls.company_data['default_journal_sale'].id,
+ 'line_ids': [
+ # 1000.0 in 61 - 90.
+ Command.create({'debit': 1000.0, 'credit': 0.0, 'date_maturity': False, 'account_id': receivable_1.id, 'partner_id': cls.partner_a.id}),
+ # -800.0 in 31 - 60
+ Command.create({'debit': 0.0, 'credit': 800.0, 'date_maturity': '2017-01-01', 'account_id': receivable_2.id, 'partner_id': cls.partner_a.id}),
+ # Ignored line.
+ Command.create({'debit': 0.0, 'credit': 200.0, 'date_maturity': False, 'account_id': misc_1.id, 'partner_id': cls.partner_a.id}),
+ ],
+ })
+
+ move_2 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2016-10-05'),
+ 'invoice_date': fields.Date.from_string('2016-10-05'),
+ 'journal_id': cls.company_data['default_journal_sale'].id,
+ 'line_ids': [
+ # -200.0 in 61 - 90
+ Command.create({'debit': 0.0, 'credit': 200.0, 'date_maturity': '2016-12-02', 'account_id': receivable_1.id, 'partner_id': cls.partner_a.id}),
+ # -300.0 in 31 - 60
+ Command.create({'debit': 0.0, 'credit': 300.0, 'date_maturity': '2016-12-03', 'account_id': receivable_1.id, 'partner_id': cls.partner_a.id}),
+ # 1000.0 in 91 - 120
+ Command.create({'debit': 1000.0, 'credit': 0.0, 'date_maturity': False, 'account_id': receivable_2.id, 'partner_id': cls.partner_a.id}),
+ # 100.0 in all dates
+ Command.create({'debit': 100.0, 'credit': 0.0, 'date_maturity': '2017-02-01', 'account_id': receivable_3.id, 'partner_id': cls.partner_a.id}),
+ Command.create({'debit': 100.0, 'credit': 0.0, 'date_maturity': '2017-01-02', 'account_id': receivable_3.id, 'partner_id': cls.partner_a.id}),
+ Command.create({'debit': 100.0, 'credit': 0.0, 'date_maturity': '2016-12-03', 'account_id': receivable_3.id, 'partner_id': cls.partner_a.id}),
+ Command.create({'debit': 100.0, 'credit': 0.0, 'date_maturity': '2016-11-03', 'account_id': receivable_3.id, 'partner_id': cls.partner_a.id}),
+ Command.create({'debit': 100.0, 'credit': 0.0, 'date_maturity': '2016-10-04', 'account_id': receivable_3.id, 'partner_id': cls.partner_a.id}),
+ Command.create({'debit': 100.0, 'credit': 0.0, 'date_maturity': '2016-01-01', 'account_id': receivable_3.id, 'partner_id': cls.partner_a.id}),
+ # Ignored line.
+ Command.create({'debit': 0.0, 'credit': 1100.0, 'date_maturity': '2016-10-05', 'account_id': misc_1.id, 'partner_id': cls.partner_a.id}),
+ ],
+ })
+ (move_1 + move_2).action_post()
+ (move_1 + move_2).line_ids.filtered(lambda line: line.account_id == receivable_1).reconcile()
+ (move_1 + move_2).line_ids.filtered(lambda line: line.account_id == receivable_2).reconcile()
+
+ # ==== Journal entries in company_2 for partner_b ====
+
+ move_3 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2016-11-03'),
+ 'invoice_date': fields.Date.from_string('2016-11-03'),
+ 'journal_id': cls.company_data_2['default_journal_sale'].id,
+ 'line_ids': [
+ # 1000.0 in 61 - 90.
+ Command.create({'debit': 1000.0, 'credit': 0.0, 'date_maturity': False, 'account_id': receivable_4.id, 'partner_id': cls.partner_b.id}),
+ # -200.0 in 31 - 60
+ Command.create({'debit': 0.0, 'credit': 800.0, 'date_maturity': '2017-01-01', 'account_id': receivable_5.id, 'partner_id': cls.partner_b.id}),
+ # Ignored line.
+ Command.create({'debit': 0.0, 'credit': 200.0, 'date_maturity': False, 'account_id': misc_2.id, 'partner_id': cls.partner_b.id}),
+ ],
+ })
+
+ move_4 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2016-10-05'),
+ 'invoice_date': fields.Date.from_string('2016-10-05'),
+ 'journal_id': cls.company_data_2['default_journal_sale'].id,
+ 'line_ids': [
+ # -200.0 in 61 - 90
+ Command.create({'debit': 0.0, 'credit': 200.0, 'date_maturity': '2016-12-02', 'account_id': receivable_4.id, 'partner_id': cls.partner_b.id}),
+ # -300.0 in 31 - 60
+ Command.create({'debit': 0.0, 'credit': 300.0, 'date_maturity': '2016-12-03', 'account_id': receivable_4.id, 'partner_id': cls.partner_b.id}),
+ # 1000.0 in 91 - 120
+ Command.create({'debit': 1000.0, 'credit': 0.0, 'date_maturity': False, 'account_id': receivable_5.id, 'partner_id': cls.partner_b.id}),
+ # 100.0 in all dates
+ Command.create({'debit': 100.0, 'credit': 0.0, 'date_maturity': '2017-02-01', 'account_id': receivable_6.id, 'partner_id': cls.partner_b.id}),
+ Command.create({'debit': 100.0, 'credit': 0.0, 'date_maturity': '2017-01-02', 'account_id': receivable_6.id, 'partner_id': cls.partner_b.id}),
+ Command.create({'debit': 100.0, 'credit': 0.0, 'date_maturity': '2016-12-03', 'account_id': receivable_6.id, 'partner_id': cls.partner_b.id}),
+ Command.create({'debit': 100.0, 'credit': 0.0, 'date_maturity': '2016-11-03', 'account_id': receivable_6.id, 'partner_id': cls.partner_b.id}),
+ Command.create({'debit': 100.0, 'credit': 0.0, 'date_maturity': '2016-10-04', 'account_id': receivable_6.id, 'partner_id': cls.partner_b.id}),
+ Command.create({'debit': 100.0, 'credit': 0.0, 'date_maturity': '2016-01-01', 'account_id': receivable_6.id, 'partner_id': cls.partner_b.id}),
+ # Ignored line.
+ Command.create({'debit': 0.0, 'credit': 1100.0, 'date_maturity': False, 'account_id': misc_2.id, 'partner_id': cls.partner_b.id}),
+ ],
+ })
+ (move_3 + move_4).action_post()
+ (move_3 + move_4).line_ids.filtered(lambda line: line.account_id == receivable_4).reconcile()
+ (move_3 + move_4).line_ids.filtered(lambda line: line.account_id == receivable_5).reconcile()
+ cls.env['res.currency'].search([('name', '!=', 'USD')]).with_context(force_deactivate=True).active = False
+ cls.env.companies = cls.company_data['company'] + cls.company_data_2['company']
+ cls.report = cls.env.ref('odex30_account_reports.aged_receivable_report')
+ cls.parent_line_id = cls._get_basic_line_dict_id_from_report_line_ref("odex30_account_reports.aged_receivable_line")
+
+ def test_aged_receivable_unfold_1_whole_report(self):
+ """ Test unfolding a line when rendering the whole report. """
+ options = self._generate_options(self.report, fields.Date.from_string('2017-02-01'), fields.Date.from_string('2017-02-01'))
+ partner_a_line_id = self.report._get_generic_line_id('res.partner', self.partner_a.id, parent_line_id=self.parent_line_id, markup={'groupby': 'partner_id'})
+ options['unfolded_lines'] = [partner_a_line_id]
+
+ # Sort by Invoice Date
+ options['order_column'] = {
+ 'expression_label': 'invoice_date',
+ 'direction': 'ASC',
+ }
+
+ report_lines = self.report._get_lines(options)
+
+ sorted_report_lines = self.report.sort_lines(report_lines, options)
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ sorted_report_lines,
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 200.0, 0.0, ''),
+ ('INV/2016/00001', 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 100.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, ''),
+ ('INV/2016/00002', 0.0, 0.0, 0.0, 500.0, 0.0, 0.0, ''),
+ ('Total partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('Total Aged Receivable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ],
+ options,
+ )
+
+ # Sort 61 - 90 decreasing.
+ options['order_column'] = {
+ 'expression_label': 'period3',
+ 'direction': 'DESC',
+ }
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report.sort_lines(sorted_report_lines, options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('INV/2016/00002', 0.0, 0.0, 0.0, 500.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 200.0, 0.0, ''),
+ ('INV/2016/00001', 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 100.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, ''),
+ ('Total partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('Total Aged Receivable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ],
+ options,
+ )
+
+ # Sort 61 - 90 increasing.
+ options['order_column'] = {
+ 'expression_label': 'period3',
+ 'direction': 'ASC',
+ }
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report.sort_lines(sorted_report_lines, options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 200.0, 0.0, ''),
+ ('INV/2016/00001', 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 100.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, ''),
+ ('INV/2016/00002', 0.0, 0.0, 0.0, 500.0, 0.0, 0.0, ''),
+ ('Total partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('Total Aged Receivable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ],
+ options,
+ )
+
+ def test_aged_receivable_unfold_all(self):
+ default_options = {
+ 'unfold_all': True,
+ 'order_column': {
+ 'expression_label': 'invoice_date',
+ 'direction': 'ASC',
+ }
+ }
+ options = self._generate_options(self.report, '2017-02-01', '2017-02-01', default_options=default_options)
+
+ report_lines = self.report._get_lines(options)
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report.sort_lines(report_lines, options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 200.0, 0.0, ''),
+ ('INV/2016/00001', 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 100.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, ''),
+ ('INV/2016/00002', 0.0, 0.0, 0.0, 500.0, 0.0, 0.0, ''),
+ ('Total partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('INV/2016/00001', 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 50.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 50.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 50.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 50.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 0.0, 50.0, ''),
+ ('INV/2016/00002', 0.0, 0.0, 0.0, 250.0, 0.0, 0.0, ''),
+ ('Total partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('Total Aged Receivable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ],
+ options
+ )
+
+ def test_aged_receivable_unknown_partner(self):
+ """ Test that journal items without a partner in the receivable account appear as unknown partner. """
+
+ misc_move = self.env['account.move'].create({
+ 'date': '2017-03-31',
+ 'line_ids': [
+ Command.create({'debit': 1000.0, 'credit': 0.0, 'account_id': self.company_data['default_account_revenue'].id}),
+ Command.create({'debit': 0.0, 'credit': 1000.0, 'account_id': self.company_data['default_account_receivable'].id}),
+ ],
+ })
+ misc_move.action_post()
+
+ options = self._generate_options(self.report, fields.Date.from_string('2017-03-01'), fields.Date.from_string('2017-04-01'))
+ self.env.company.totals_below_sections = False
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', 0.0, -1000.0, 150.0, 150.0, 150.0, 1500.0, 950.0),
+ ('partner_a', 0.0, 0.0, 100.0, 100.0, 100.0, 1000.0, 1300.0),
+ ('partner_b', 0.0, 0.0, 50.0, 50.0, 50.0, 500.0, 650.0),
+ ('Unknown', 0.0, -1000.0, 0.0, 0.0, 0.0, 0.0, -1000.0),
+ ],
+ options,
+ )
+
+ def test_aged_receivable_filter_partners(self):
+ """ Test the filter on top allowing to filter on res.partner. """
+ options = self._generate_options(self.report, fields.Date.from_string('2017-02-01'), fields.Date.from_string('2017-02-01'))
+ options['partner_ids'] = self.partner_a.ids
+ self.env.company.totals_below_sections = False
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ],
+ options,
+ )
+
+ def test_aged_receivable_filter_partner_categories(self):
+ """ Test the filter on top allowing to filter on res.partner.category. """
+ options = self._generate_options(self.report, fields.Date.from_string('2017-02-01'), fields.Date.from_string('2017-02-01'))
+ options['partner_categories'] = self.partner_category_a.ids
+ self.env.company.totals_below_sections = False
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ],
+ options,
+ )
+
+ def test_aged_receivable_reconciliation_date(self):
+ """ Check the values at a date before some reconciliations are done. """
+ options = self._generate_options(self.report, fields.Date.from_string('2016-10-31'), fields.Date.from_string('2016-10-31'))
+ self.env.company.totals_below_sections = False
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', -133.33, 1466.67, 0.0, 0.0, 0.0, 133.33, 1466.67),
+ ('partner_a', -100.00, 1100.00, 0.0, 0.0, 0.0, 100.00, 1100.00),
+ ('partner_b', -33.33, 366.67, 0.0, 0.0, 0.0, 33.33, 366.67),
+ ],
+ options,
+ )
+
+ # TODO: move these tests into a generic report test class
+ def test_aged_receivable_sort_lines_by_date(self):
+ """ Test the sort_lines function using date as sort key. """
+ options = self._generate_options(self.report, fields.Date.from_string('2017-02-01'), fields.Date.from_string('2017-02-01'))
+ partner_a_line_id = self.report._get_generic_line_id('res.partner', self.partner_a.id, parent_line_id=self.parent_line_id, markup={'groupby': 'partner_id'})
+ partner_b_line_id = self.report._get_generic_line_id('res.partner', self.partner_b.id, parent_line_id=self.parent_line_id, markup={'groupby': 'partner_id'})
+ options['unfolded_lines'] = [partner_a_line_id, partner_b_line_id]
+
+ # Sort by Invoice Date increasing
+ options['order_column'] = {
+ 'expression_label': 'invoice_date',
+ 'direction': 'ASC',
+ }
+
+ report_lines = self.report._get_lines(options)
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report.sort_lines(report_lines, options),
+ # Name Invoice Date Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 1, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', '', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_a', '', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('INV/2016/00001', '10/05/2016', 0.0, 0.0, 0.0, 0.0, 200.0, 0.0, ''),
+ ('INV/2016/00001', '10/05/2016', 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', '10/05/2016', 0.0, 100.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', '10/05/2016', 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', '10/05/2016', 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', '10/05/2016', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('INV/2016/00001', '10/05/2016', 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, ''),
+ ('INV/2016/00002', '11/03/2016', 0.0, 0.0, 0.0, 500.0, 0.0, 0.0, ''),
+ ('Total partner_a', '', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('partner_b', '', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('INV/2016/00001', '10/05/2016', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('INV/2016/00001', '10/05/2016', 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', '10/05/2016', 0.0, 50.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', '10/05/2016', 0.0, 0.0, 50.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', '10/05/2016', 0.0, 0.0, 0.0, 50.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', '10/05/2016', 0.0, 0.0, 0.0, 0.0, 50.0, 0.0, ''),
+ ('INV/2016/00001', '10/05/2016', 0.0, 0.0, 0.0, 0.0, 0.0, 50.0, ''),
+ ('INV/2016/00002', '11/03/2016', 0.0, 0.0, 0.0, 250.0, 0.0, 0.0, ''),
+ ('Total partner_b', '', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('Total Aged Receivable', '', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ],
+ options,
+ )
+
+ # Sort by Invoice Date decreasing
+ options['order_column'] = {
+ 'expression_label': 'invoice_date',
+ 'direction': 'DESC',
+ }
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report.sort_lines(report_lines, options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('INV/2016/00002', 0.0, 0.0, 0.0, 500.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 200.0, 0.0, ''),
+ ('INV/2016/00001', 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 100.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, ''),
+ ('Total partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('INV/2016/00002', 0.0, 0.0, 0.0, 250.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('INV/2016/00001', 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 50.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 50.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 50.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 50.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 0.0, 50.0, ''),
+ ('Total partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('Total Aged Receivable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ],
+ options,
+ )
+
+ def test_aged_receivable_sort_lines_by_numeric_value(self):
+ """ Test the sort_lines function using float as sort key. """
+ options = self._generate_options(self.report, fields.Date.from_string('2017-02-01'), fields.Date.from_string('2017-02-01'))
+ partner_a_line_id = self.report._get_generic_line_id('res.partner', self.partner_a.id, parent_line_id=self.parent_line_id, markup={'groupby': 'partner_id'})
+ partner_b_line_id = self.report._get_generic_line_id('res.partner', self.partner_b.id, parent_line_id=self.parent_line_id, markup={'groupby': 'partner_id'})
+ options['unfolded_lines'] = [partner_a_line_id, partner_b_line_id]
+
+ # Sort by Not Due On increasing
+ options['order_column'] = {
+ 'expression_label': 'period0',
+ 'direction': 'ASC',
+ }
+
+ report_lines = self.report._get_lines(options)
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report.sort_lines(report_lines, options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('INV/2016/00002', 0.0, 0.0, 0.0, 250.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 50.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 50.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 50.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 50.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 0.0, 50.0, ''),
+ ('INV/2016/00001', 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('INV/2016/00002', 0.0, 0.0, 0.0, 500.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 200.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 100.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, ''),
+ ('INV/2016/00001', 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('Total Aged Receivable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ],
+ options,
+ )
+
+ # Sort by Not Due On decreasing
+ options['order_column'] = {
+ 'expression_label': 'period0',
+ 'direction': 'DESC',
+ }
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report.sort_lines(report_lines, options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('INV/2016/00001', 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00002', 0.0, 0.0, 0.0, 500.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 200.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 100.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, ''),
+ ('Total partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('INV/2016/00001', 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00002', 0.0, 0.0, 0.0, 250.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 50.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 50.0, 0.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 50.0, 0.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 50.0, 0.0, ''),
+ ('INV/2016/00001', 0.0, 0.0, 0.0, 0.0, 0.0, 50.0, ''),
+ ('Total partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('Total Aged Receivable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ],
+ options,
+ )
+
+ def test_aged_receivable_zero_balanced_without_reconciliation(self):
+ options = self._generate_options(self.report, '2010-01-01', '2010-01-01', default_options={'unfold_all': True})
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': '2010-01-01',
+ 'invoice_date_due': '2010-01-01',
+ 'payment_reference': 'I',
+ 'invoice_line_ids': [Command.create({
+ 'name': 'test invoice',
+ 'price_unit': 100,
+ 'tax_ids': [],
+ })]
+ })
+ invoice.action_post()
+
+ refund = self.env['account.move'].create({
+ 'move_type': 'out_refund',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': '2010-01-01',
+ 'invoice_date_due': '2010-01-01',
+ 'payment_reference': 'R',
+ 'invoice_line_ids': [Command.create({
+ 'name': 'test refund',
+ 'price_unit': 100,
+ 'tax_ids': [],
+ })]
+ })
+ refund.action_post()
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('partner_a', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ (f"{refund.name} R", -100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ (f"{invoice.name} I", 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total partner_a', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Total Aged Receivable', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ],
+ options,
+ )
+
+ # It should still work if both invoice and refund are partially reconciled with the same amount
+ self.env['account.payment.register'].with_context(active_ids=invoice.ids, active_model='account.move').create({
+ 'amount': 42,
+ 'payment_date': '2010-01-01',
+ 'payment_method_line_id': self.outbound_payment_method_line.id,
+ })._create_payments()
+
+ self.env['account.payment.register'].with_context(active_ids=refund.ids, active_model='account.move').create({
+ 'amount': 42,
+ 'payment_date': '2010-01-01',
+ 'payment_method_line_id': self.outbound_payment_method_line.id,
+ })._create_payments()
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('partner_a', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ (f"{refund.name} R", -58.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ (f"{invoice.name} I", 58.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total partner_a', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Total Aged Receivable', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ],
+ options,
+ )
+
+ # It should still work if both invoice and refund are fully reconciled in the future
+ self.env['account.payment.register'].with_context(active_ids=invoice.ids, active_model='account.move').create({
+ 'amount': 58,
+ 'payment_date': '2020-01-01',
+ 'payment_method_line_id': self.outbound_payment_method_line.id,
+ })._create_payments()
+
+ self.env['account.payment.register'].with_context(active_ids=refund.ids, active_model='account.move').create({
+ 'amount': 58,
+ 'payment_date': '2020-01-01',
+ 'payment_method_line_id': self.outbound_payment_method_line.id,
+ })._create_payments()
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('partner_a', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ (f"{refund.name} R", -58.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ (f"{invoice.name} I", 58.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total partner_a', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Total Aged Receivable', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ],
+ options,
+ )
+
+ def test_aged_receivable_prefix_groups(self):
+ partner_names = [
+ 'A',
+ 'A partner',
+ 'A nice partner',
+ 'A new partner',
+ 'An original partner',
+ 'Another partner',
+ 'Anonymous partner',
+ 'Annoyed partner',
+ 'Brave partner',
+ ]
+
+ test_date = '2010-12-13'
+ invoices_map = {}
+ for name in partner_names:
+ partner = self.env['res.partner'].create({'name': name})
+ invoice = self.init_invoice('out_invoice', partner=partner, invoice_date=test_date, amounts=[42.0], taxes=[], post=True)
+ invoices_map[name] = self.env['account.move.line']._format_aml_name(invoice.payment_reference, False, invoice.name)
+
+ # Without prefix groups
+ options = self._generate_options(self.report, test_date, test_date)
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', 378.0, 0.0, 0.0, 0.0, 0.0, 0.0, 378.0),
+ ('A', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('A new partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('A nice partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('A partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('An original partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Annoyed partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Anonymous partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Another partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Brave partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Total Aged Receivable', 378.0, 0.0, 0.0, 0.0, 0.0, 0.0, 378.0),
+ ],
+ options,
+ )
+
+ # With prefix groups
+ self.report.prefix_groups_threshold = 3
+ options = self._generate_options(self.report, test_date, test_date, default_options={'unfold_all': True})
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Not Due On 1 - 30 31 - 60 61 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', 378.0, 0.0, 0.0, 0.0, 0.0, 0.0, 378.0),
+ ('A (8 lines)', 336.0, 0.0, 0.0, 0.0, 0.0, 0.0, 336.0),
+ ('A', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ (invoices_map['A'], 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total A', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('A[ ] (3 lines)', 126.0, 0.0, 0.0, 0.0, 0.0, 0.0, 126.0),
+ ('A N (2 lines)', 84.0, 0.0, 0.0, 0.0, 0.0, 0.0, 84.0),
+ ('A new partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ (invoices_map['A new partner'], 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total A new partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('A nice partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ (invoices_map['A nice partner'], 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total A nice partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Total A N (2 lines)', 84.0, 0.0, 0.0, 0.0, 0.0, 0.0, 84.0),
+ ('A P (1 line)', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('A partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ (invoices_map['A partner'], 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total A partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Total A P (1 line)', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Total A[ ] (3 lines)', 126.0, 0.0, 0.0, 0.0, 0.0, 0.0, 126.0),
+ ('AN (4 lines)', 168.0, 0.0, 0.0, 0.0, 0.0, 0.0, 168.0),
+ ('AN[ ] (1 line)', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('An original partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ (invoices_map['An original partner'], 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total An original partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Total AN[ ] (1 line)', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('ANN (1 line)', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Annoyed partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ (invoices_map['Annoyed partner'], 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total Annoyed partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Total ANN (1 line)', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('ANO (2 lines)', 84.0, 0.0, 0.0, 0.0, 0.0, 0.0, 84.0),
+ ('Anonymous partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ (invoices_map['Anonymous partner'], 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total Anonymous partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Another partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ (invoices_map['Another partner'], 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total Another partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Total ANO (2 lines)', 84.0, 0.0, 0.0, 0.0, 0.0, 0.0, 84.0),
+ ('Total AN (4 lines)', 168.0, 0.0, 0.0, 0.0, 0.0, 0.0, 168.0),
+ ('Total A (8 lines)', 336.0, 0.0, 0.0, 0.0, 0.0, 0.0, 336.0),
+ ('B (1 line)', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Brave partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ (invoices_map['Brave partner'], 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, ''),
+ ('Total Brave partner', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Total B (1 line)', 42.0, 0.0, 0.0, 0.0, 0.0, 0.0, 42.0),
+ ('Total Aged Receivable', 378.0, 0.0, 0.0, 0.0, 0.0, 0.0, 378.0),
+ ],
+ options,
+ )
+
+ def test_aged_receivable_partial_reconcile_currency(self):
+ """ Check that 'Amount Currency' column values are displayed and computed correctly. """
+ foreign_partner = self.env['res.partner'].create({'name': 'foreign_partner'})
+ currency = self.other_currency
+ currency.active = True
+ self.env.company.totals_below_sections = False
+
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2023-05-01',
+ 'invoice_date_due': '2023-05-01',
+ 'partner_id': foreign_partner.id,
+ 'currency_id': currency.id,
+ 'invoice_line_ids': [Command.create({
+ 'name': 'test',
+ 'quantity': 1,
+ 'price_unit': 100.0,
+ 'tax_ids': [],
+ })],
+ })
+ invoice.action_post()
+
+ self.env['account.payment.register'].with_context(
+ active_model='account.move',
+ active_ids=invoice.ids,
+ ).create({
+ 'amount': 10.0,
+ 'currency_id': currency.id,
+ 'payment_date': '2023-05-05',
+ 'partner_id': foreign_partner.id,
+ })._create_payments()
+
+ line_id = self.report._get_generic_line_id('res.partner', foreign_partner.id, markup={'groupby': 'partner_id'}, parent_line_id=self.parent_line_id)
+ options = self._generate_options(self.report, '2023-01-01', '2023-05-01')
+ options['unfolded_lines'] = [line_id]
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_unfolded_lines(self.report._get_lines(options), line_id),
+ # Name Due Date Amount Currency Currency As Of Total
+ [ 0, 1, 2, 3, 5, 11],
+ [
+ ('foreign_partner', '', '', '', 50.0, 50.0),
+ ('INV/2023/00001', '05/01/2023', 100.0, 'CAD', 50.0, ''),
+ ],
+ options,
+ currency_map={
+ 2: {'currency': currency},
+ },
+ )
+
+ new_options = self._generate_options(self.report, '2023-01-01', '2023-05-05')
+ new_options['unfolded_lines'] = [line_id]
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_unfolded_lines(self.report._get_lines(new_options), line_id),
+ # Name Due Date Amount Currency Currency As Of 1-30 Total
+ [ 0, 1, 2, 3, 5, 6, 11],
+ [
+ ('foreign_partner', '', '', '', 0.0, 45.0, 45.0),
+ ('INV/2023/00001', '05/01/2023', 90.0, 'CAD', 0.0, 45.0, ''),
+ ],
+ options,
+ currency_map={
+ 2: {'currency': currency},
+ },
+ )
+
+ def test_aged_receivable_aging_interval(self):
+ options = self._generate_options(self.report, '2017-02-01', '2017-02-01')
+ initial_report_lines = self.report._get_lines(options)
+
+ # With the default interval of 30
+ self.assertLinesValues(
+ self.report.sort_lines(initial_report_lines, options),
+ # Name Not Due On 1 - 30 31 - 60 62 - 90 91 - 120 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ('partner_a', 100.0, 100.0, 100.0, 600.0, 300.0, 100.0, 1300.0),
+ ('partner_b', 50.0, 50.0, 50.0, 300.0, 150.0, 50.0, 650.0),
+ ('Total Aged Receivable', 150.0, 150.0, 150.0, 900.0, 450.0, 150.0, 1950.0),
+ ],
+ options
+ )
+
+ options['aging_interval'] = 60
+ report_lines = self.report._get_lines(options)
+
+ # With the interval of 60
+ self.assertLinesValues(
+ self.report.sort_lines(report_lines, options),
+ # Name Not Due On 1 - 60 61 - 120 121 - 180 181 - 240 Older Total
+ [ 0, 3, 4, 5, 6, 7, 8, 9],
+ [
+ ('Aged Receivable', 150.0, 300.0, 1350.0, 0.0, 0.0, 150.0, 1950.0),
+ ('partner_a', 100.0, 200.0, 900.0, 0.0, 0.0, 100.0, 1300.0),
+ ('partner_b', 50.0, 100.0, 450.0, 0.0, 0.0, 50.0, 650.0),
+ ('Total Aged Receivable', 150.0, 300.0, 1350.0, 0.0, 0.0, 150.0, 1950.0),
+ ],
+ options
+ )
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_all_reports_generation.py b/dev_odex30_accounting/odex30_account_reports/tests/test_all_reports_generation.py
new file mode 100644
index 0000000..f1d783f
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_all_reports_generation.py
@@ -0,0 +1,171 @@
+# -*- coding: utf-8 -*-
+
+import json
+
+from collections import defaultdict
+from unittest.mock import patch
+
+from odoo.addons.account.tests.common import AccountTestInvoicingCommon
+from odoo.tests.common import tagged
+
+@tagged('post_install_l10n', 'post_install', '-at_install')
+class TestAllReportsGeneration(AccountTestInvoicingCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ # do not disable buttons because of multiple companies selected
+ cls.env = cls.env(context={'allowed_company_ids': cls.env.company.ids})
+ cls.setup_other_currency('EUR')
+
+ cls.reports = cls.env['account.report'].with_context(active_test=False).search([])
+
+ # Make the reports always available, so that they don't clash with the comany's country
+ cls.reports.availability_condition = 'always'
+ # We keep the country set on each of these reports, so that we can load the proper test data when testing exports
+
+ def test_open_all_reports(self):
+ # 'unfold_all' is forced on all reports (even if they don't support it), so that we really open it entirely
+ self.reports.filter_unfold_all = True
+
+ for report in self.reports:
+ with self.subTest(report=report.name, country=report.country_id.name):
+ # 'report_id' key is forced so that we don't open a variant when calling a root report
+ options = report.get_options({'selected_variant_id': report.id, 'unfold_all': True})
+
+ if report.use_sections:
+ self.assertNotEqual(options['report_id'], report.id, "Composite reports should always reroute.")
+ self.env['account.report'].browse(options['report_id']).get_report_information(options)
+ else:
+ report.get_report_information(options)
+
+ def test_generate_all_export_files(self):
+ # Test values for the fields that become mandatory when doing exports on the reports, depending on the country
+ l10n_pl_reports_tax_office = self.env.ref('l10n_pl.pl_tax_office_0215', raise_if_not_found=False)
+ l10n_bd_corporate_tax_liability = self.env['account.account'].search([('account_type', '=', 'liability_current')], limit=1)
+ l10n_ae_liability_account = self.env['account.account'].search([('account_type', '=', 'liability_current')], limit=1)
+ l10n_cz_reports_tax_office = self.env.ref('l10n_cz_reports_2025.tax_office_1', raise_if_not_found=False)
+ company_test_values = {
+ 'LU': {'vat': 'LU12345613'},
+ 'BR': {'vat': '01234567891251'},
+ 'AR': {'vat': '30714295698'},
+ 'AU': {'vat': '11225459588', 'street': 'Arrow Street', 'zip': '1348', 'city': 'Starling City', 'state_id': self.env.ref('base.state_au_1').id},
+ 'DE': {'vat': 'DE123456788', 'l10n_de_stnr': '151/815/08156', 'state_id': self.env.ref('base.state_de_th').id},
+ 'NO': {'vat': 'NO123456785', 'l10n_no_bronnoysund_number': '987654325'},
+ 'PL': {'l10n_pl_reports_tax_office_id': l10n_pl_reports_tax_office and l10n_pl_reports_tax_office.id},
+ 'BD': {'l10n_bd_corporate_tax_liability': l10n_bd_corporate_tax_liability, 'l10n_bd_corporate_tax_expense': l10n_bd_corporate_tax_liability},
+ 'AE': {'l10n_ae_tax_report_liabilities_account': l10n_ae_liability_account, 'l10n_ae_tax_report_counterpart_account': l10n_ae_liability_account},
+ 'CZ': {'l10n_cz_tax_office_id': l10n_cz_reports_tax_office and l10n_cz_reports_tax_office.id}
+ }
+
+ if self.env['ir.module.module']._get('l10n_lu_reports').state == 'installed':
+ company_test_values['LU'].update({
+ 'ecdf_prefix': '1234AB',
+ 'matr_number': '1111111111111',
+ })
+
+ partner_test_values = {
+ 'AR': {'l10n_latam_identification_type_id': (self.env.ref('l10n_ar.it_cuit', raise_if_not_found=False) or {'id': None})['id']},
+ }
+
+ # Some root reports are made for just one country and require test fields to be set the right way to generate their exports properly.
+ # Since they are root reports and are always available, they normally have no country set; we assign one here (only for the ones requiring it)
+ reports_forced_countries = [
+ ('AU', 'l10n_au_reports.tpar_report'),
+ ]
+ for country_code, report_ref in reports_forced_countries:
+ country = self.env['res.country'].search([('code', '=', country_code)], limit=1)
+ report = self.env.ref(report_ref, raise_if_not_found=False)
+ if report:
+ report.country_id = country
+
+ # Check buttons of every report
+ for report in self.reports:
+ with self.subTest(report=report.name, country=report.country_id.name):
+ # Setup some generic data on the company that could be needed for some file export
+ self.env.company.write({
+ 'vat': "VAT123456789",
+ 'email': "dummy@email.com",
+ 'phone': "01234567890",
+ 'company_registry': '42',
+ **company_test_values.get(report.country_id.code, {}),
+ })
+
+ self.env.company.partner_id.write(partner_test_values.get(report.country_id.code, {}))
+
+ options = report.get_options({'selected_variant_id': report.id, '_running_export_test': True})
+
+ if report.use_sections:
+ self.assertNotEqual(options['report_id'], report.id, "Composite reports should always reroute.")
+
+ for option_button in options['buttons']:
+ if option_button['name'] in ('PDF', 'Download Excel'): # keep "Copy to Documents" and other actions
+ # TODO remove me
+ # This test seems to have some trouble on runbot. It is running for way longer than
+ # locally. Freeze is coming, explanation are missing... Sorry 😟
+ continue
+ if report.custom_handler_model_name == 'l10n_fr.report.handler' and option_button['name'] == 'EDI VAT':
+ # This button requires a tax closing entry to be called. It doesn't make sense to test this button
+ # without a tax closing entry, so we will exclude it from testing here.
+ continue
+ with self.subTest(button=option_button['name']):
+ with patch.object(type(self.env['ir.actions.report']), '_run_wkhtmltopdf', lambda *args, **kwargs: b"This is a pdf"):
+ if not option_button.get('client_tag'):
+ action_dict = report.dispatch_report_action(
+ options,
+ option_button['action'],
+ action_param=option_button.get('action_param'),
+ on_sections_source=True,
+ )
+
+ if action_dict['type'] == 'ir_actions_account_report_download':
+ file_gen_res = report.dispatch_report_action(
+ json.loads(action_dict['data']['options']),
+ action_dict['data']['file_generator'],
+ on_sections_source=True,
+ )
+ self.assertEqual(
+ set(file_gen_res.keys()), {'file_name', 'file_content', 'file_type'},
+ "File generator's result should always contain the same 3 keys."
+ )
+
+ # Unset the test values, in case they are used in conditions to define custom behaviors
+ self.env.company.write({
+ field_name: None
+ for field_name in company_test_values.get(report.country_id.code, {}).keys()
+ })
+
+ self.env.company.partner_id.write({
+ field_name: None
+ for field_name in partner_test_values.get(report.country_id.code, {}).keys()
+ })
+
+ def test_custom_engines_related_groupby(self):
+ blacklist_xmlids = [
+ 'odex30_account_reports.account_financial_report_executivesummary_avdebt0_ndays',
+ 'odex30_account_reports.account_financial_report_executivesummary_avgcre0_ndays',
+ 'odex30_account_reports.last_statement_balance_amount',
+ 'odex30_account_reports.last_statement_balance_forced_currency_amount',
+ ]
+
+ custom_engine_expressions = self.env['account.report.expression'].search([
+ ('engine', '=', 'custom'),
+ ('id', 'not in', tuple(self.env['ir.model.data']._xmlid_to_res_id(xmlid) for xmlid in blacklist_xmlids)),
+ ])
+
+ expressions_per_engine = defaultdict(lambda: self.env['account.report.expression'])
+ for expression in custom_engine_expressions:
+ expressions_per_engine[(expression.report_line_id.report_id, expression.formula)] += expression
+
+ for (report, _formula), expressions in expressions_per_engine.items():
+ with self.subTest(report=report.name):
+ options = report.get_options({})
+
+ # Remove aggregations and external value expressions from those report lines, so that we always can set a groupby on the line
+ (expressions.report_line_id.expression_ids.filtered(lambda x: x.engine in ('aggregation', 'external'))).unlink()
+
+ expressions.report_line_id.user_groupby = 'account_code' # account_code is a non-stored related field on aml
+ self.env.flush_all()
+ report._compute_expression_totals_for_each_column_group(expressions, options, groupby_to_expand='account_code')
+ # This computation fill fail if the custom engine forgot to handle such groupby, typically via a call to _field_to_sql
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_analytic_reports.py b/dev_odex30_accounting/odex30_account_reports/tests/test_analytic_reports.py
new file mode 100644
index 0000000..ca9011b
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_analytic_reports.py
@@ -0,0 +1,708 @@
+from odoo import Command
+from odoo.tests import tagged
+
+from .common import TestAccountReportsCommon
+
+
+@tagged('post_install', '-at_install')
+class TestAnalyticReport(TestAccountReportsCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.env.user.groups_id += cls.env.ref(
+ 'analytic.group_analytic_accounting')
+ cls.report = cls.env.ref('odex30_account_reports.profit_and_loss')
+ cls.report.write({'filter_analytic': True})
+
+ cls.analytic_plan_parent = cls.env['account.analytic.plan'].create({
+ 'name': 'Plan Parent',
+ })
+ cls.analytic_plan_child = cls.env['account.analytic.plan'].create({
+ 'name': 'Plan Child',
+ 'parent_id': cls.analytic_plan_parent.id,
+ })
+
+ cls.analytic_account_parent = cls.env['account.analytic.account'].create({
+ 'name': 'Account 1',
+ 'plan_id': cls.analytic_plan_parent.id
+ })
+ cls.analytic_account_parent_2 = cls.env['account.analytic.account'].create({
+ 'name': 'Account 2',
+ 'plan_id': cls.analytic_plan_parent.id
+ })
+ cls.analytic_account_child = cls.env['account.analytic.account'].create({
+ 'name': 'Account 3',
+ 'plan_id': cls.analytic_plan_child.id
+ })
+ cls.analytic_account_parent_3 = cls.env['account.analytic.account'].create({
+ 'name': 'Account 4',
+ 'plan_id': cls.analytic_plan_parent.id
+ })
+
+ def test_report_group_by_analytic_plan(self):
+
+ out_invoice = self.env['account.move'].create([{
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'date': '2019-05-01',
+ 'invoice_date': '2019-05-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'product_id': self.product_a.id,
+ 'price_unit': 200.0,
+ 'analytic_distribution': {
+ self.analytic_account_parent.id: 100,
+ },
+ }),
+ Command.create({
+ 'product_id': self.product_b.id,
+ 'price_unit': 200.0,
+ 'analytic_distribution': {
+ self.analytic_account_child.id: 100,
+ },
+ }),
+ ]
+ }])
+ out_invoice.action_post()
+
+ options = self._generate_options(
+ self.report,
+ '2019-01-01',
+ '2019-12-31',
+ default_options={
+ 'analytic_plans_groupby': [self.analytic_plan_parent.id, self.analytic_plan_child.id],
+ }
+ )
+
+ lines = self.report._get_lines(options)
+
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ lines,
+ [ 0, 1, 2],
+ [
+ ['Revenue', 400.00, 200.00],
+ ['Less Costs of Revenue', 0.00, 0.00],
+ ['Gross Profit', 400.00, 200.00],
+ ['Less Operating Expenses', 0.00, 0.00],
+ ['Operating Income (or Loss)', 400.00, 200.00],
+ ['Plus Other Income', 0.00, 0.00],
+ ['Less Other Expenses', 0.00, 0.00],
+ ['Net Profit', 400.00, 200.00],
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.env.company.currency_id},
+ 2: {'currency': self.env.company.currency_id},
+ },
+ )
+
+ def test_report_analytic_filter(self):
+
+ out_invoice = self.env['account.move'].create([{
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'date': '2023-02-01',
+ 'invoice_date': '2023-02-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'product_id': self.product_a.id,
+ 'price_unit': 1000.0,
+ 'analytic_distribution': {
+ self.analytic_account_parent.id: 100,
+ },
+ })
+ ]
+ }])
+ out_invoice.action_post()
+
+ options = self._generate_options(
+ self.report,
+ '2023-01-01',
+ '2023-12-31',
+ default_options={
+ 'analytic_accounts': [self.analytic_account_parent.id],
+ }
+ )
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ # pylint: disable=bad-whitespace
+ self.report._get_lines(options),
+ [ 0, 1],
+ [
+ ['Revenue', 1000.00],
+ ['Less Costs of Revenue', 0.00],
+ ['Gross Profit', 1000.00],
+ ['Less Operating Expenses', 0.00],
+ ['Operating Income (or Loss)', 1000.00],
+ ['Plus Other Income', 0.00],
+ ['Less Other Expenses', 0.00],
+ ['Net Profit', 1000.00],
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.env.company.currency_id},
+ 2: {'currency': self.env.company.currency_id},
+ },
+ )
+
+ # Set the unused analytic account in filter, as no move is
+ # using this account, the column should be empty
+ options['analytic_accounts'] = [self.analytic_account_child.id]
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ # pylint: disable=bad-whitespace
+ self.report._get_lines(options),
+ [ 0, 1],
+ [
+ ['Revenue', 0.00],
+ ['Less Costs of Revenue', 0.00],
+ ['Gross Profit', 0.00],
+ ['Less Operating Expenses', 0.00],
+ ['Operating Income (or Loss)', 0.00],
+ ['Plus Other Income', 0.00],
+ ['Less Other Expenses', 0.00],
+ ['Net Profit', 0.00],
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.env.company.currency_id},
+ 2: {'currency': self.env.company.currency_id},
+ },
+ )
+
+ def test_report_audit_analytic_filter(self):
+ out_invoice = self.env['account.move'].create([{
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'date': '2023-02-01',
+ 'invoice_date': '2023-02-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'product_id': self.product_a.id,
+ 'price_unit': 1000.0,
+ 'analytic_distribution': {
+ self.analytic_account_parent.id: 100,
+ },
+ }),
+ Command.create({
+ 'product_id': self.product_a.id,
+ 'price_unit': 500.0,
+ 'analytic_distribution': {
+ self.analytic_account_child.id: 100,
+ },
+ }),
+ ],
+ }])
+ out_invoice.action_post()
+
+ options = self._generate_options(
+ self.report,
+ '2023-01-01',
+ '2023-12-31',
+ default_options={
+ 'analytic_accounts': [self.analytic_account_parent.id],
+ }
+ )
+
+ lines = self.report._get_lines(options)
+
+ report_line = self.report.line_ids[0]
+ report_line_dict = next(x for x in lines if x['name'] == report_line.name)
+
+ action_dict = self.report.action_audit_cell(
+ options,
+ self._get_audit_params_from_report_line(options, report_line, report_line_dict),
+ )
+
+ audited_lines = self.env['account.move.line'].search(action_dict['domain'])
+ self.assertEqual(audited_lines, out_invoice.invoice_line_ids[0], "Only the line with the parent account should be shown")
+
+ def test_report_analytic_groupby_and_filter(self):
+ """
+ Test that the analytic filter is applied on the groupby columns
+ """
+
+ out_invoice = self.env['account.move'].create([{
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'date': '2023-02-01',
+ 'invoice_date': '2023-02-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'product_id': self.product_a.id,
+ 'price_unit': 1000.0,
+ 'analytic_distribution': {
+ self.analytic_account_parent.id: 40,
+ self.analytic_account_child.id: 60,
+ },
+ })
+ ]
+ }])
+ out_invoice.action_post()
+
+ # Test with only groupby
+ options = self._generate_options(
+ self.report,
+ '2023-01-01',
+ '2023-12-31',
+ default_options={
+ 'analytic_accounts_groupby': [self.analytic_account_parent.id, self.analytic_account_child.id],
+ }
+ )
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ # pylint: disable=bad-whitespace
+ self.report._get_lines(options),
+ [ 0, 1, 2, 3],
+ [
+ ['Revenue', 400.00, 600.00, 1000.00],
+ ['Less Costs of Revenue', 0.00, 0.00, 0.00],
+ ['Gross Profit', 400.00, 600.00, 1000.00],
+ ['Less Operating Expenses', 0.00, 0.00, 0.00],
+ ['Operating Income (or Loss)', 400.00, 600.00, 1000.00],
+ ['Plus Other Income', 0.00, 0.00, 0.00],
+ ['Less Other Expenses', 0.00, 0.00, 0.00],
+ ['Net Profit', 400.00, 600.00, 1000.00],
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.env.company.currency_id},
+ 2: {'currency': self.env.company.currency_id},
+ },
+ )
+
+ # Adding analytic filter for the two analytic accounts used on the invoice line
+ # The two groupby columns should still be filled
+ options['analytic_accounts'] = [self.analytic_account_parent.id, self.analytic_account_child.id]
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ # pylint: disable=bad-whitespace
+ self.report._get_lines(options),
+ [ 0, 1, 2, 3],
+ [
+ ['Revenue', 400.00, 600.00, 1000.00],
+ ['Less Costs of Revenue', 0.00, 0.00, 0.00],
+ ['Gross Profit', 400.00, 600.00, 1000.00],
+ ['Less Operating Expenses', 0.00, 0.00, 0.00],
+ ['Operating Income (or Loss)', 400.00, 600.00, 1000.00],
+ ['Plus Other Income', 0.00, 0.00, 0.00],
+ ['Less Other Expenses', 0.00, 0.00, 0.00],
+ ['Net Profit', 400.00, 600.00, 1000.00],
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.env.company.currency_id},
+ 2: {'currency': self.env.company.currency_id},
+ },
+ )
+ # Keep only first analytic account on filter, the groupby column
+ # for this account should still be filled, unlike the other
+ options['analytic_accounts'] = [self.analytic_account_parent.id]
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ # pylint: disable=bad-whitespace
+ self.report._get_lines(options),
+ [ 0, 1, 2, 3],
+ [
+ ['Revenue', 400.00, 0.00, 1000.00],
+ ['Less Costs of Revenue', 0.00, 0.00, 0.00],
+ ['Gross Profit', 400.00, 0.00, 1000.00],
+ ['Less Operating Expenses', 0.00, 0.00, 0.00],
+ ['Operating Income (or Loss)', 400.00, 0.00, 1000.00],
+ ['Plus Other Income', 0.00, 0.00, 0.00],
+ ['Less Other Expenses', 0.00, 0.00, 0.00],
+ ['Net Profit', 400.00, 0.00, 1000.00],
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.env.company.currency_id},
+ 2: {'currency': self.env.company.currency_id},
+ },
+ )
+
+ # Keep only first analytic account on filter, the groupby column
+ # for this account should still be filled, unlike the other
+ options['analytic_accounts'] = [self.analytic_account_child.id]
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ # pylint: disable=bad-whitespace
+ self.report._get_lines(options),
+ [ 0, 1, 2, 3],
+ [
+ ['Revenue', 0.00, 600.00, 1000.00],
+ ['Less Costs of Revenue', 0.00, 0.00, 0.00],
+ ['Gross Profit', 0.00, 600.00, 1000.00],
+ ['Less Operating Expenses', 0.00, 0.00, 0.00],
+ ['Operating Income (or Loss)', 0.00, 600.00, 1000.00],
+ ['Plus Other Income', 0.00, 0.00, 0.00],
+ ['Less Other Expenses', 0.00, 0.00, 0.00],
+ ['Net Profit', 0.00, 600.00, 1000.00],
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.env.company.currency_id},
+ 2: {'currency': self.env.company.currency_id},
+ },
+ )
+
+ # Set an unused analytic account in filter, all the columns
+ # should be empty, as no move is using this account
+ options['analytic_accounts'] = [self.analytic_account_parent_2.id]
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ # pylint: disable=bad-whitespace
+ self.report._get_lines(options),
+ [ 0, 1, 2, 3],
+ [
+ ['Revenue', 0.00, 0.00, 0.00],
+ ['Less Costs of Revenue', 0.00, 0.00, 0.00],
+ ['Gross Profit', 0.00, 0.00, 0.00],
+ ['Less Operating Expenses', 0.00, 0.00, 0.00],
+ ['Operating Income (or Loss)', 0.00, 0.00, 0.00],
+ ['Plus Other Income', 0.00, 0.00, 0.00],
+ ['Less Other Expenses', 0.00, 0.00, 0.00],
+ ['Net Profit', 0.00, 0.00, 0.00],
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.env.company.currency_id},
+ 2: {'currency': self.env.company.currency_id},
+ },
+ )
+
+ def test_audit_cell_analytic_groupby_and_filter(self):
+ """
+ Test that the analytic filters are applied on the auditing of the cells
+ """
+ def _get_action_dict(options, column_index):
+ lines = self.report._get_lines(options)
+ report_line = self.report.line_ids[0]
+ report_line_dict = next(x for x in lines if x['name'] == report_line.name)
+ audit_param = self._get_audit_params_from_report_line(options, report_line, report_line_dict, column_group_key=list(options['column_groups'])[column_index])
+ return self.report.action_audit_cell(options, audit_param)
+
+ other_plan = self.env['account.analytic.plan'].create({'name': "Other Plan"})
+ other_account = self.env['account.analytic.account'].create({'name': "Other Account", 'plan_id': other_plan.id, 'active': True})
+
+ out_invoices = self.env['account.move'].create([
+ {
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'date': '2023-02-01',
+ 'invoice_date': '2023-02-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'product_id': self.product_a.id,
+ 'price_unit': 1000.0,
+ 'analytic_distribution': {
+ self.analytic_account_parent.id: 40,
+ self.analytic_account_child.id: 60,
+ }
+ }),
+ ]
+ },
+ {
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'date': '2023-02-01',
+ 'invoice_date': '2023-02-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'product_id': self.product_a.id,
+ 'price_unit': 2000.0,
+ 'analytic_distribution': {
+ f'{self.analytic_account_parent.id},{other_account.id}': 100,
+ },
+ }),
+ ]
+ }
+ ])
+ out_invoices.action_post()
+ out_invoices = out_invoices.with_context(analytic_plan_id=self.analytic_plan_parent.id)
+ analytic_lines_parent = out_invoices.invoice_line_ids.analytic_line_ids.filtered(lambda line: line.auto_account_id == self.analytic_account_parent)
+ analytic_lines_other = out_invoices.with_context(analytic_plan_id=other_plan.id).invoice_line_ids.analytic_line_ids.filtered(lambda line: line.auto_account_id == other_account)
+
+ # Test with only groupby
+ options = self._generate_options(
+ self.report,
+ '2023-01-01',
+ '2023-12-31',
+ default_options={
+ 'analytic_accounts_groupby': [self.analytic_account_parent.id, other_account.id],
+ }
+ )
+ action_dict = _get_action_dict(options, 0) # First Column => Parent
+ self.assertEqual(
+ self.env['account.analytic.line'].search(action_dict['domain']),
+ analytic_lines_parent,
+ "Only the Analytic Line related to the Parent should be shown",
+ )
+ action_dict = _get_action_dict(options, 1) # Second Column => Other
+ self.assertEqual(
+ self.env['account.analytic.line'].search(action_dict['domain']),
+ analytic_lines_other,
+ "Only the Analytic Line related to the Parent should be shown",
+ )
+
+ action_dict = _get_action_dict(options, 2) # Third Column => AMLs
+ self.assertEqual(
+ out_invoices.line_ids.filtered_domain(action_dict['domain']),
+ out_invoices.invoice_line_ids,
+ "Both amls should be shown",
+ )
+
+ # Adding analytic filter for the two analytic accounts used on the invoice line
+ options['analytic_accounts'] = [self.analytic_account_parent.id, other_account.id]
+ action_dict = _get_action_dict(options, 0) # First Column => Parent
+ self.assertEqual(
+ self.env['account.analytic.line'].search(action_dict['domain']),
+ analytic_lines_parent,
+ "Still only the Analytic Line related to the Parent should be shown",
+ )
+ action_dict = _get_action_dict(options, 1) # Second Column => Other
+ self.assertEqual(
+ self.env['account.analytic.line'].search(action_dict['domain']),
+ analytic_lines_other,
+ "Still only the Analytic Line related to the Parent should be shown",
+ )
+
+ action_dict = _get_action_dict(options, 2) # Third Column => AMLs
+ self.assertEqual(
+ out_invoices.line_ids.search(action_dict['domain']),
+ out_invoices.invoice_line_ids,
+ "Both amls should be shown",
+ )
+
+ def test_general_ledger_analytic_filter(self):
+ analytic_plan = self.env["account.analytic.plan"].create({
+ "name": "Default Plan",
+ })
+ analytic_account = self.env["account.analytic.account"].create({
+ "name": "Test Account",
+ "plan_id": analytic_plan.id,
+ })
+
+ invoice = self.init_invoice(
+ "out_invoice",
+ amounts=[100, 200],
+ invoice_date="2023-01-01",
+ )
+ invoice.action_post()
+ invoice.invoice_line_ids[0].analytic_distribution = {analytic_account.id: 100}
+
+ general_ledger_report = self.env.ref("odex30_account_reports.general_ledger_report")
+ options = self._generate_options(
+ general_ledger_report,
+ "2023-01-01",
+ "2023-01-01",
+ default_options={
+ 'analytic_accounts': [analytic_account.id],
+ 'unfold_all': True,
+ }
+ )
+
+ self.assertLinesValues(
+ general_ledger_report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 5, 6, 7],
+ [
+ ['400000 Product Sales', 0.00, 100.00, -100.00],
+ ['INV/2023/00001', 0.00, 100.00, -100.00],
+ ['Total 400000 Product Sales', 0.00, 100.00, -100.00],
+ ['Total', 0.00, 100.00, -100.00],
+ ],
+ options,
+ )
+
+ def test_analytic_groupby_with_horizontal_groupby(self):
+
+ out_invoice_1 = self.env['account.move'].create([{
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'date': '2024-07-01',
+ 'invoice_date': '2024-07-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'product_id': self.product_b.id,
+ 'price_unit': 500.0,
+ 'analytic_distribution': {
+ self.analytic_account_parent_2.id: 80,
+ self.analytic_account_parent_3.id: -10,
+ },
+ }),
+ ]
+ }])
+ out_invoice_1.action_post()
+
+ out_invoice_2 = self.env['account.move'].create([{
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'date': '2024-07-01',
+ 'invoice_date': '2024-07-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'product_id': self.product_a.id,
+ 'price_unit': 100.0,
+ 'analytic_distribution': {
+ self.analytic_account_parent.id: 100,
+ },
+ }),
+ ]
+ }])
+ out_invoice_2.action_post()
+
+ horizontal_group = self.env['account.report.horizontal.group'].create({
+ 'name': 'Horizontal Group Journal Entries',
+ 'report_ids': [self.report.id],
+ 'rule_ids': [
+ Command.create({
+ 'field_name': 'move_id', # this field is specific to account.move.line and not in account.analytic.line
+ 'domain': f"[('id', 'in', {(out_invoice_1 + out_invoice_2).ids})]",
+ }),
+ ],
+ })
+
+ options = self._generate_options(
+ self.report,
+ '2024-01-01',
+ '2024-12-31',
+ default_options={
+ 'analytic_accounts_groupby': [self.analytic_account_parent.id, self.analytic_account_parent_2.id, self.analytic_account_parent_3.id],
+ 'selected_horizontal_group_id': horizontal_group.id,
+ }
+ )
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Horizontal groupby [ Move 2 ] [ Move 1 ]
+ # Analytic groupby A1 A2 A3 Balance A1 A2 A3 Balance
+ [ 0, 1, 2, 3, 4, 5, 6, 7, 8],
+ [
+ ['Revenue', 100.00, 0.00, 0.00, 100.00, 0.00, 400.00, -50.00, 500.00],
+ ['Less Costs of Revenue', 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
+ ['Gross Profit', 100.00, 0.00, 0.00, 100.00, 0.00, 400.00, -50.00, 500.00],
+ ['Less Operating Expenses', 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
+ ['Operating Income (or Loss)', 100.00, 0.00, 0.00, 100.00, 0.00, 400.00, -50.00, 500.00],
+ ['Plus Other Income', 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
+ ['Less Other Expenses', 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00],
+ ['Net Profit', 100.00, 0.00, 0.00, 100.00, 0.00, 400.00, -50.00, 500.00],
+ ],
+ options,
+ )
+
+ def test_analytic_groupby_with_analytic_simulations(self):
+ """
+ Create an analytic simulation (analytic line without a move line)
+ and check that it is taken into account in the report
+ """
+
+ self.env['account.analytic.line'].create({
+ 'name': 'Simulation',
+ 'date': '2019-05-01',
+ 'amount': 100.0,
+ 'unit_amount': 1.0,
+ 'company_id': self.env.company.id,
+ self.analytic_plan_parent._column_name(): self.analytic_account_parent.id,
+ 'general_account_id': self.company_data['default_account_revenue'].id,
+ })
+
+ options = self._generate_options(
+ self.report,
+ '2019-01-01',
+ '2019-12-31',
+ default_options={
+ 'analytic_plans_groupby': [self.analytic_plan_parent.id, self.analytic_plan_child.id],
+ 'include_analytic_without_aml': True,
+ }
+ )
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ [ 0, 1, 2],
+ [
+ ('Revenue', 100.00, 0.00),
+ ('Less Costs of Revenue', 0.00, 0.00),
+ ('Gross Profit', 100.00, 0.00),
+ ('Less Operating Expenses', 0.00, 0.00),
+ ('Operating Income (or Loss)', 100.00, 0.00),
+ ('Plus Other Income', 0.00, 0.00),
+ ('Less Other Expenses', 0.00, 0.00),
+ ('Net Profit', 100.00, 0.00),
+ ],
+ options,
+ )
+
+ def test_analytic_groupby_plans_without_analytic_accounts(self):
+ """
+ Ensure that grouping on several analytic plans without any analytic accounts works as expected
+ """
+ analytic_plans_without_accounts = self.env['account.analytic.plan'].create([
+ {'name': 'Plan 1'},
+ {'name': 'Plan 2'},
+ ])
+
+ options = self._generate_options(
+ self.report, '2019-01-01', '2019-12-31',
+ default_options={'analytic_plans_groupby': analytic_plans_without_accounts.ids}
+ )
+
+ self.assertEqual(
+ len(options['column_groups']), 3,
+ "the number of column groups should be 3, despite the 2 analytic plans having the exact same analytic accounts list"
+ )
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Plan 1 Plan 2 Total
+ [ 0, 1, 2, 3],
+ [
+ ('Revenue', 0.00, 0.00, 0.00),
+ ('Less Costs of Revenue', 0.00, 0.00, 0.00),
+ ('Gross Profit', 0.00, 0.00, 0.00),
+ ('Less Operating Expenses', 0.00, 0.00, 0.00),
+ ('Operating Income (or Loss)', 0.00, 0.00, 0.00),
+ ('Plus Other Income', 0.00, 0.00, 0.00),
+ ('Less Other Expenses', 0.00, 0.00, 0.00),
+ ('Net Profit', 0.00, 0.00, 0.00),
+ ],
+ options,
+ )
+
+ def test_profit_and_loss_multicompany_access_rights(self):
+ branch = self.env['res.company'].create([{
+ 'name': "My Test Branch",
+ 'parent_id': self.env.company.id,
+ }])
+ other_currency = self.setup_other_currency('EUR', rounding=0.001)
+ test_journal = self.env['account.journal'].create({
+ 'name': 'Test Journal',
+ 'code': 'TEST',
+ 'type': 'sale',
+ 'company_id': self.env.company.id,
+ 'currency_id': other_currency.id,
+ })
+ test_user = self.env['res.users'].create({
+ 'login': 'test',
+ 'name': 'The King',
+ 'email': 'noop@example.com',
+ 'groups_id': [Command.link(self.env.ref('account.group_account_manager').id)],
+ 'company_ids': [Command.link(self.env.company.id), Command.link(branch.id)],
+ })
+ self.env.invalidate_all()
+
+ options = self._generate_options(
+ self.report.with_user(test_user).with_company(branch), '2019-01-01', '2019-12-31',
+ )
+ lines = self.report._get_lines(options)
+ self.assertTrue(lines)
+ self.assertEqual(test_journal.display_name, "Test Journal (EUR)")
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_balance_sheet_balanced.py b/dev_odex30_accounting/odex30_account_reports/tests/test_balance_sheet_balanced.py
new file mode 100644
index 0000000..ff962de
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_balance_sheet_balanced.py
@@ -0,0 +1,813 @@
+# pylint: disable=C0326
+import logging
+import itertools
+import contextlib
+
+from odoo.addons.odex30_account_reports.tests.common import TestAccountReportsCommon
+from odoo.addons.account.models.chart_template import SYSCOHADA_LIST
+
+from odoo.tests import tagged, new_test_user
+from odoo import fields, Command
+
+
+_logger = logging.getLogger(__name__)
+syscohada_coas = [country_code.lower() for country_code in SYSCOHADA_LIST]
+syscebnl_coas = [f'{country_code.lower}_syscebnl' for country_code in SYSCOHADA_LIST]
+
+
+# === Set this variable to something truthy if you want the test to identify any incorrectly-referenced accounts for you. === #
+IDENTIFY_INCORRECT_ACCOUNTS = False
+# === Set this to True (if the above is set to True) if you want the test to show you journal entries for reproducing the imbalance === #
+EXTRA_DETAIL = False
+
+# === When creating a new Balance Sheet, please add its config here. === #
+''' Example config:
+REPORT_CONFIG = {
+ : {
+ 'asset_line_ref':
+ 'liability_line_ref':
+ 'equity_line_ref': (optional) (if not included in Liabilities)
+ 'balance_col_label': (optional) (if different from 'balance')
+ 'chart_template_refs': (optional)
+ },
+} '''
+
+REPORT_CONFIG = {
+ 'odex30_account_reports.balance_sheet': {
+ 'asset_line_ref': 'odex30_account_reports.account_financial_report_total_assets0',
+ 'liability_line_ref': 'odex30_account_reports.account_financial_report_liabilities_and_equity_view0',
+ },
+ 'l10n_at_reports.account_financial_report_l10n_at_paragraph_224_ugb': {
+ 'asset_line_ref': 'l10n_at_reports.account_financial_report_l10n_at_paragraph_224_ugb_line_activa',
+ 'liability_line_ref': 'l10n_at_reports.account_financial_report_l10n_at_paragraph_224_ugb_line_passiva',
+ },
+ 'l10n_be_reports.account_financial_report_bs_asso_a': {
+ 'asset_line_ref': 'l10n_be_reports.account_financial_report_bs_asso_a_a_tot',
+ 'liability_line_ref': 'l10n_be_reports.account_financial_report_bs_asso_a_el_tot',
+ },
+ 'l10n_be_reports.account_financial_report_bs_asso_f': {
+ 'asset_line_ref': 'l10n_be_reports.account_financial_report_bs_asso_f_a_tot',
+ 'liability_line_ref': 'l10n_be_reports.account_financial_report_bs_asso_f_el_tot',
+ },
+ 'l10n_be_reports.account_financial_report_bs_comp_acap': {
+ 'asset_line_ref': 'l10n_be_reports.account_financial_report_bs_comp_acap_a_tot',
+ 'liability_line_ref': 'l10n_be_reports.account_financial_report_bs_comp_acap_el_tot',
+ },
+ 'l10n_be_reports.account_financial_report_bs_comp_acon': {
+ 'asset_line_ref': 'l10n_be_reports.account_financial_report_bs_comp_acon_a_tot',
+ 'liability_line_ref': 'l10n_be_reports.account_financial_report_bs_comp_acon_el_tot',
+ },
+ 'l10n_be_reports.account_financial_report_bs_comp_fcap': {
+ 'asset_line_ref': 'l10n_be_reports.account_financial_report_bs_comp_fcap_a_tot',
+ 'liability_line_ref': 'l10n_be_reports.account_financial_report_bs_comp_fcap_el_tot',
+ },
+ 'l10n_be_reports.account_financial_report_bs_comp_fcon': {
+ 'asset_line_ref': 'l10n_be_reports.account_financial_report_bs_comp_fcon_a_tot',
+ 'liability_line_ref': 'l10n_be_reports.account_financial_report_bs_comp_fcon_el_tot',
+ },
+ 'l10n_bg_reports.l10n_bg_bs': {
+ 'asset_line_ref': 'l10n_bg_reports.l10n_bg_bs_assets',
+ 'liability_line_ref': 'l10n_bg_reports.l10n_bg_bs_liabilities',
+ },
+ 'l10n_bo_reports.l10n_bo_bs': {
+ 'asset_line_ref': 'l10n_bo_reports.l10n_bo_bs_assets',
+ 'liability_line_ref': 'l10n_bo_reports.l10n_bo_bs_liabilities_plus_equity',
+ },
+ 'l10n_br_reports.account_financial_report_br_balancesheet0': {
+ 'asset_line_ref': 'l10n_br_reports.account_financial_report_total_assets0',
+ 'liability_line_ref': 'l10n_br_reports.account_financial_report_liabilities_view0',
+ },
+ 'l10n_ca_reports.l10n_ca_balance_sheet': {
+ 'asset_line_ref': 'l10n_ca_reports.l10n_ca_bs_assets',
+ 'liability_line_ref': 'l10n_ca_reports.l10n_ca_bs_equity_liability',
+ },
+ 'l10n_ch_reports.account_financial_report_l10n_ch_bilan': {
+ 'asset_line_ref': 'l10n_ch_reports.account_financial_report_line_ch_1',
+ 'liability_line_ref': 'l10n_ch_reports.account_financial_report_line_ch_2',
+ },
+ 'l10n_cl_reports.cl_eightcolumns_report': {
+ 'chart_template_refs': [],
+ },
+ 'l10n_co_reports.l10n_co_bs_report': {
+ 'asset_line_ref': 'l10n_co_reports.l10n_co_bs_report_assets',
+ 'liability_line_ref': 'l10n_co_reports.l10n_co_bs_report_liabilities_equity',
+ },
+ 'l10n_co_reports.l10n_co_reports_libro_inv_blc': {
+ 'asset_line_ref': 'l10n_co_reports.l10n_co_inv_blc_assets_expenses',
+ 'liability_line_ref': 'l10n_co_reports.l10n_co_inv_blc_equity_liability',
+ },
+ 'l10n_cy_reports.l10n_cy_balance_sheet': {
+ 'asset_line_ref': 'l10n_cy_reports.account_financial_report_cy_active_line',
+ 'liability_line_ref': 'l10n_cy_reports.account_financial_report_cy_passive_line',
+ },
+ 'l10n_cz_reports.balance_sheet_l10n_cz_reports': {
+ 'asset_line_ref': 'l10n_cz_reports.l10n_cz_reports_bs_aktiva',
+ 'liability_line_ref': 'l10n_cz_reports.l10n_cz_reports_bs_pasiva',
+ 'balance_col_label': 'net',
+ },
+ 'l10n_de_reports.balance_sheet_l10n_de': {
+ 'asset_line_ref': 'l10n_de_reports.skr_asset_total',
+ 'liability_line_ref': 'l10n_de_reports.skr_liabilities_total',
+ },
+ 'l10n_dk_reports.account_balance_report_l10n_dk_balance': {
+ 'asset_line_ref': 'l10n_dk_reports.account_balance_report_l10n_dk_active',
+ 'liability_line_ref': 'l10n_dk_reports.account_balance_report_l10n_dk_passiv',
+ },
+ 'l10n_dk_reports.account_balance_report_l10n_dk_balance_minimal': {
+ 'asset_line_ref': 'l10n_dk_reports.account_balance_report_minimal_l10n_dk_active',
+ 'liability_line_ref': 'l10n_dk_reports.account_balance_report_minimal_l10n_dk_passive',
+ },
+ 'l10n_do_reports.l10n_do_bs': {
+ 'asset_line_ref': 'l10n_do_reports.l10n_do_bs_assets',
+ 'liability_line_ref': 'l10n_do_reports.l10n_do_bs_liabilities_plus_equity',
+ },
+ 'l10n_dz_reports.l10n_dz_bs': {
+ 'asset_line_ref': 'l10n_dz_reports.l10n_dz_bs_assets',
+ 'liability_line_ref': 'l10n_dz_reports.l10n_dz_bs_liabilities',
+ 'balance_col_label': 'net',
+ },
+ 'l10n_ec_reports.l10n_ec_balance_sheet': {
+ 'asset_line_ref': 'l10n_ec_reports.l10n_ec_balance_sheet_assets',
+ 'liability_line_ref': 'l10n_ec_reports.l10n_ec_balance_sheet_liabilities_and_equity',
+ },
+ 'l10n_ee_reports.account_financial_report_bs': {
+ 'asset_line_ref': 'l10n_ee_reports.account_financial_report_bs_assets',
+ 'liability_line_ref': 'l10n_ee_reports.account_financial_report_bs_liabilities_equity',
+ },
+ 'l10n_es_reports.financial_report_balance_sheet_assoc': {
+ 'asset_line_ref': 'l10n_es_reports.balance_assoc_line_10000',
+ 'liability_line_ref': 'l10n_es_reports.balance_assoc_line_30000',
+ },
+ 'l10n_es_reports.financial_report_balance_sheet_full': {
+ 'asset_line_ref': 'l10n_es_reports.balance_full_line_10000',
+ 'liability_line_ref': 'l10n_es_reports.balance_full_line_30000',
+ },
+ 'l10n_es_reports.financial_report_balance_sheet_pymes': {
+ 'asset_line_ref': 'l10n_es_reports.balance_pymes_line_10000',
+ 'liability_line_ref': 'l10n_es_reports.balance_pymes_line_30000',
+ },
+ 'l10n_fi_reports.account_financial_report_l10n_fi_bs': {
+ 'asset_line_ref': 'l10n_fi_reports.account_financial_report_l10n_fi_bs_line_1',
+ 'liability_line_ref': 'l10n_fi_reports.account_financial_report_l10n_fi_bs_line_2',
+ },
+ 'l10n_fr_reports.account_financial_report_l10n_fr_bilan': {
+ 'asset_line_ref': 'l10n_fr_reports.account_financial_report_fr_bilan_actif_total',
+ 'liability_line_ref': 'l10n_fr_reports.account_financial_report_fr_bilan_passif_total',
+ 'balance_col_label': 'net',
+ },
+ 'l10n_fr_reports.account_financial_report_l10n_fr_bilan_2024': {
+ 'asset_line_ref': 'l10n_fr_reports.account_financial_report_fr_bilan_actif_total_2024',
+ 'liability_line_ref': 'l10n_fr_reports.account_financial_report_fr_bilan_passif_total_2024',
+ 'balance_col_label': 'net',
+ },
+ 'l10n_gr_reports.l10n_gr_bs_accounting_report': {
+ 'asset_line_ref': 'l10n_gr_reports.l10n_gr_bs_assets',
+ 'liability_line_ref': 'l10n_gr_reports.l10n_gr_bs_equity_provisions_liabilities',
+ },
+ 'l10n_hr_reports.l10n_hr_balance_sheet': {
+ 'asset_line_ref': 'l10n_hr_reports.account_financial_report_hr_active_title0',
+ 'liability_line_ref': 'l10n_hr_reports.account_financial_report_hr_passif_title0',
+ 'chart_template_refs': ['hr'], # don't test the hr_kuna CoA anymore (obsolete)
+ },
+ 'l10n_hu_reports.l10n_hu_balance_sheet': {
+ 'asset_line_ref': 'l10n_hu_reports.l10n_hu_balance_sheet_assets',
+ 'liability_line_ref': 'l10n_hu_reports.l10n_hu_balance_sheet_liabilities',
+ },
+ 'l10n_ie_reports.l10n_ie_bs': {
+ 'asset_line_ref': 'l10n_ie_reports.l10n_ie_bs_assets_total',
+ 'liability_line_ref': 'l10n_ie_reports.l10n_ie_bs_liabilities_total',
+ },
+ 'l10n_it_reports.account_financial_report_it_sp': {
+ 'asset_line_ref': 'l10n_it_reports.account_financial_report_line_it_sp_assets_total',
+ 'liability_line_ref': 'l10n_it_reports.account_financial_report_line_it_sp_passif_total',
+ },
+ 'l10n_it_reports.account_financial_report_it_sp_reduce': {
+ 'asset_line_ref': 'l10n_it_reports.account_financial_report_line_it_sp_reduce_assets_total',
+ 'liability_line_ref': 'l10n_it_reports.account_financial_report_line_it_sp_reduce_passif_total',
+ },
+ 'l10n_ke_reports.account_financial_report_ke_bs': {
+ 'asset_line_ref': 'l10n_ke_reports.account_financial_report_ke_bs_A',
+ 'liability_line_ref': 'l10n_ke_reports.account_financial_report_ke_bs_B',
+ },
+ 'l10n_kh_reports.l10n_kh_balance_sheet': {
+ 'asset_line_ref': 'l10n_kh_reports.l10n_kh_balance_sheet_kh_bs_a',
+ 'liability_line_ref': 'l10n_kh_reports.l10n_kh_balance_sheet_kh_bs_el',
+ },
+ 'l10n_kz_reports.l10n_kz_bl_report': {
+ 'asset_line_ref': 'l10n_kz_reports.l10n_kz_bl_assets',
+ 'liability_line_ref': 'l10n_kz_reports.l10n_kz_bl_equity_liabilities',
+ },
+ 'l10n_lt_reports.account_financial_report_balancesheet_lt': {
+ 'asset_line_ref': 'l10n_lt_reports.account_financial_html_report_line_bs_lt_debit',
+ 'liability_line_ref': 'l10n_lt_reports.account_financial_html_report_line_bs_lt_credit',
+ },
+ 'l10n_lu_reports.account_financial_report_l10n_lu_bs_abr': {
+ 'asset_line_ref': 'l10n_lu_reports.account_financial_report_l10n_lu_bs_abr_line_1_6',
+ 'liability_line_ref': 'l10n_lu_reports.account_financial_report_l10n_lu_bs_abr_line_2_5',
+ },
+ 'l10n_lu_reports.account_financial_report_l10n_lu_bs': {
+ 'asset_line_ref': 'l10n_lu_reports.account_financial_report_l10n_lu_bs_line_1_6',
+ 'liability_line_ref': 'l10n_lu_reports.account_financial_report_l10n_lu_bs_line_2_5',
+ },
+ 'l10n_lv_reports.l10n_lv_balance_sheet': {
+ 'asset_line_ref': 'l10n_lv_reports.account_financial_report_lv_active_title',
+ 'liability_line_ref': 'l10n_lv_reports.account_financial_report_lv_passif_title',
+ },
+ 'l10n_ma_reports.account_financial_report_bs': {
+ 'asset_line_ref': 'l10n_ma_reports.account_financial_report_bs_a_total',
+ 'liability_line_ref': 'l10n_ma_reports.account_financial_report_bs_p_total',
+ 'balance_col_label': 'net',
+ },
+ 'l10n_mn_reports.account_report_balancesheet': {
+ 'asset_line_ref': 'l10n_mn_reports.report_line_balanceta',
+ 'liability_line_ref': 'l10n_mn_reports.report_line_balancele',
+ },
+ 'l10n_mr_reports.l10n_mr_balance_sheet': {
+ 'asset_line_ref': 'l10n_mr_reports.account_financial_report_mr_active_title',
+ 'liability_line_ref': 'l10n_mr_reports.account_financial_report_mr_passive_title',
+ },
+ 'l10n_mt_reports.l10n_mt_balance_sheet': {
+ 'asset_line_ref': 'l10n_mt_reports.account_financial_report_mt_active_title',
+ 'liability_line_ref': 'l10n_mt_reports.account_financial_report_mt_passif_title',
+ },
+ 'l10n_mz_reports.l10_mz_bs': {
+ 'asset_line_ref': 'l10n_mz_reports.l10n_mz_bs_line_1',
+ 'liability_line_ref': 'l10n_mz_reports.l10n_mz_bs_line_2',
+ },
+ 'l10n_nl_reports.account_financial_report_bs': {
+ 'asset_line_ref': 'l10n_nl_reports.account_financial_report_bs_assets',
+ 'liability_line_ref': 'l10n_nl_reports.account_financial_report_bs_leq',
+ },
+ 'l10n_nl_reports.account_financial_report_bs_tags': {
+ 'asset_line_ref': 'l10n_nl_reports.account_financial_report_bs_tags_assets',
+ 'liability_line_ref': 'l10n_nl_reports.account_financial_report_bs_tags_leq',
+ },
+ 'l10n_no_reports.account_financial_report_NO_balancesheet': {
+ 'asset_line_ref': 'l10n_no_reports.account_financial_report_NO_active',
+ 'liability_line_ref': 'l10n_no_reports.account_financial_report_NO_passive',
+ },
+ 'l10n_pe_reports.account_financial_report_bs': {
+ 'asset_line_ref': 'l10n_pe_reports.account_financial_report_bs_A_TOTAL',
+ 'liability_line_ref': 'l10n_pe_reports.account_financial_report_bs_EL',
+ },
+ 'l10n_pk_reports.account_financial_report_pk_balancesheet0': {
+ 'asset_line_ref': 'l10n_pk_reports.account_balance_report_pk_asset',
+ 'liability_line_ref': 'l10n_pk_reports.account_balance_report_pk_equity_plus_liabilities',
+ },
+ 'l10n_pl_reports.l10n_pl_micro_bs': {
+ 'asset_line_ref': 'l10n_pl_reports.l10n_pl_micro_bs_assets',
+ 'liability_line_ref': 'l10n_pl_reports.l10n_pl_micro_bs_passives',
+ },
+ 'l10n_pl_reports.l10n_pl_small_bs': {
+ 'asset_line_ref': 'l10n_pl_reports.l10n_pl_small_bs_assets',
+ 'liability_line_ref': 'l10n_pl_reports.l10n_pl_small_bs_passives',
+ },
+ 'l10n_pt_reports.account_financial_report_line_pt_balanco': {
+ 'asset_line_ref': 'l10n_pt_reports.account_financial_report_line_pt_balanco_total_do_ativo',
+ 'liability_line_ref': 'l10n_pt_reports.account_financial_report_line_pt_balanco_total_do_capital_proprio_e_do_passivo',
+ },
+ 'l10n_ro_reports.account_financial_report_ro_bs_smle': {
+ 'asset_line_ref': 'l10n_ro_reports.account_financial_report_ro_bs_smle_total_assets',
+ 'liability_line_ref': 'l10n_ro_reports.account_financial_report_ro_bs_smle_total_liabilities',
+ },
+ 'l10n_ro_reports.account_financial_report_ro_bs_large': {
+ 'asset_line_ref': 'l10n_ro_reports.account_financial_report_ro_bs_large_total_assets',
+ 'liability_line_ref': 'l10n_ro_reports.account_financial_report_ro_bs_large_total_liabilities',
+ },
+ 'l10n_rs_reports.account_financial_report_rs_BS': {
+ 'asset_line_ref': 'l10n_rs_reports.account_financial_report_rs_BS_assets',
+ 'liability_line_ref': 'l10n_rs_reports.account_financial_report_rs_BS_equity_liabilities',
+ },
+ 'l10n_rw_reports.l10n_rw_balance_sheet': {
+ 'asset_line_ref': 'l10n_rw_reports.account_financial_report_rw_active',
+ 'liability_line_ref': 'l10n_rw_reports.account_financial_report_rw_passive',
+ },
+ 'l10n_se_reports.account_financial_report_bs': {
+ 'asset_line_ref': 'l10n_se_reports.account_financial_report_bs_A_TOTAL',
+ 'liability_line_ref': 'l10n_se_reports.account_financial_report_bs_EL_TOTAL',
+ },
+ 'l10n_si_reports.l10n_si_balance_sheet': {
+ 'asset_line_ref': 'l10n_si_reports.l10n_si_balance_sheet_resources',
+ 'liability_line_ref': 'l10n_si_reports.l10n_si_balance_sheet_liabilities',
+ },
+ 'l10n_syscohada_reports.account_financial_report_syscohada_bilan': {
+ 'asset_line_ref': 'l10n_syscohada_reports.account_financial_report_line_03_3_11_syscohada_bilan_actif',
+ 'liability_line_ref': 'l10n_syscohada_reports.account_financial_report_line_03_3_11_syscohada_bilan_passif',
+ 'chart_template_refs': syscohada_coas,
+ },
+ 'l10n_syscohada_reports.account_financial_report_syscebnl_bilan_assoc': {
+ 'asset_line_ref': 'l10n_syscohada_reports.account_financial_report_syscohada_bilan_actif_total',
+ 'liability_line_ref': 'l10n_syscohada_reports.account_financial_report_syscohada_bilan_passif_total',
+ 'chart_template_refs': syscebnl_coas,
+ },
+ 'l10n_tn_reports.l10n_tn_bs_account_report': {
+ 'asset_line_ref': 'l10n_tn_reports.l10n_tn_bs_assets',
+ 'liability_line_ref': 'l10n_tn_reports.l10n_tn_bs_liabilities_equity',
+ },
+ 'l10n_tr_reports.account_report_l10n_tr_balance_sheet': {
+ 'asset_line_ref': 'l10n_tr_reports.account_report_line_trbs_active',
+ 'liability_line_ref': 'l10n_tr_reports.account_report_line_trbs_pasive',
+ },
+ 'l10n_tw_reports.balance_sheet_l10n_tw': {
+ 'asset_line_ref': 'l10n_tw_reports.account_financial_report_total_assets0_l10n_tw',
+ 'liability_line_ref': 'l10n_tw_reports.account_financial_report_libailities_and_equity',
+ },
+ 'l10n_tz_reports.l10n_tz_balance_sheet': {
+ 'asset_line_ref': 'l10n_tz_reports.account_financial_report_tz_active',
+ 'liability_line_ref': 'l10n_tz_reports.account_financial_report_tz_passive',
+ },
+ 'l10n_vn_reports.balance_sheet_l10n_vn': {
+ 'asset_line_ref': 'l10n_vn_reports.account_financial_report_l10n_vn_bs_ta',
+ 'liability_line_ref': 'l10n_vn_reports.account_financial_report_l10n_vn_bs_tos',
+ },
+ 'l10n_zm_reports.balance_sheet_zm': {
+ 'asset_line_ref': 'l10n_zm_reports.balance_sheet_zm_assets',
+ 'liability_line_ref': 'l10n_zm_reports.balance_sheet_zm_liabilities_and_equities',
+ },
+ 'l10n_kr_reports.l10_kr_bs': {
+ 'asset_line_ref': 'l10n_kr_reports.l10n_kr_bs_ta',
+ 'liability_line_ref': 'l10n_kr_reports.l10n_kr_bs_le',
+ },
+ 'l10n_cn_reports.l10n_cn_asbe_bs': {
+ 'asset_line_ref': 'l10n_cn_reports.l10n_cn_asbe_bs_cn_a',
+ 'liability_line_ref': 'l10n_cn_reports.l10n_cn_asbe_bs_cn_tle',
+ },
+ 'l10n_cn_reports.l10n_cn_assbe_bs': {
+ 'asset_line_ref': 'l10n_cn_reports.l10n_cn_assbe_bs_cns_ta',
+ 'liability_line_ref': 'l10n_cn_reports.l10n_cn_assbe_bs_cns_tle',
+ },
+}
+
+# === If some accounts should be excluded from the testing, specify them here === #
+# Accounts starting with 99 are excluded anyway: users should change their codes
+# to something sensible in order for them to be taken into account in the Balance Sheet.
+'''
+NON_TESTED_ACCOUNTS = {
+ : [account_code_1, account_code_2, ...]
+}
+'''
+NON_TESTED_ACCOUNTS = {
+ 'all': [
+ '99', # 99 account codes are placeholders and should normally be changed by the user.
+ '123456' # hr.payroll creates an account 123456 Account Payslip Houserental for demo companies; don't test it
+ ],
+ **{coa: ['13'] for coa in syscohada_coas},
+ 'at': ['98'],
+ 'mn': ['9301'],
+ 'it': ['412', '413', '9102'],
+ 'pt': ['811'],
+}
+
+
+def log_incorrect_accounts_quiet(report_setup_data, amls, totals, is_first_call):
+ if is_first_call:
+ _logger.error("""
+ The Balance Sheet %s is not balanced.
+ These accounts are incorrectly used in the Balance Sheet
+ or in the Profit & Loss, if inserted into the Balance Sheet via cross-report).
+ To show the journal entries that cause the imbalance,
+ set the EXTRA_DETAIL global to True at the top of the test file.
+ """,
+ report_setup_data['report_ref'],
+ )
+
+ _logger.error('- %s %s', amls[0].account_id.code, amls[0].account_id.name)
+
+
+def log_incorrect_accounts_detailed(report_setup_data, amls, totals, is_first_call):
+ if is_first_call:
+ _logger.error("""
+ The Balance Sheet %s is not balanced.
+ If you construct any of the following journal entries, Assets != Liabilities + Equity.
+ """,
+ report_setup_data['report_ref'],
+ )
+
+ format_params = []
+ currency = amls.currency_id
+ for aml in amls:
+ format_params += [
+ aml.date,
+ f'{aml.account_id.code} {aml.account_id.name}'[:50],
+ currency.format(aml.debit),
+ currency.format(aml.credit),
+ ]
+ format_params += [
+ report_setup_data['report_date'],
+ currency.format(totals['total_asset']),
+ currency.format(totals['total_liability']),
+ ]
+ error_msg = '''
+ +------------+----------------------------------------------------+------------------+------------------+
+ | Date | Account | Debit | Credit |
+ +------------+----------------------------------------------------+------------------+------------------+
+ | {:10} | {:<50} | {:<16} | {:<16} |
+ | {:10} | {:<50} | {:<16} | {:<16} |
+ +------------+----------------------------------------------------+------------------+------------------+
+ Balance Sheet on {}: Total Assets: {} ; Total Liabilities + Equity: {}
+ '''.format(*format_params)
+ _logger.error(error_msg)
+
+
+@tagged('post_install_l10n', 'post_install', '-at_install')
+class TestBalanceSheetBalanced(TestAccountReportsCommon):
+ ''' Diagnose unbalanced Balance Sheets.
+
+ We do this by creating a journal entry with two journal items in each account:
+ one debit and one credit. We then generate the Balance Sheet report, and check
+ whether the Assets line is equal to the sum of the Liabilities and Equity lines.
+
+ Some accounts are not checked:
+ - those starting with '99' (automatically created in order to enable some
+ functionalities to work, but the user is expected to change their code)
+ - those included in the NON_TESTED_ACCOUNTS dict.
+
+ The test can optionally identify guilty accounts using binary search; this can
+ be toggled on by setting IDENTIFY_INCORRECT_ACCOUNTS = True.
+ '''
+ @classmethod
+ def setup_independent_user(cls):
+ # OVERRIDE: let the user have access to all existing companies.
+ # This allows us to use demo companies for a significant speedup.
+ cls.existing_companies = cls.env['res.company'].search([])
+ return new_test_user(
+ cls.env,
+ name='Because I am accountman!',
+ login='accountman',
+ password='accountman',
+ email='accountman@test.com',
+ groups_id=cls.get_default_groups().ids,
+ company_ids=[Command.link(company.id) for company in cls.existing_companies],
+ )
+
+ @classmethod
+ def setup_independent_company(cls):
+ # OVERRIDE: Don't create an independent company by default
+ pass
+
+ def test_balance_sheet_balanced(self):
+ ''' The main test function: check whether every installed balance sheet is balanced. '''
+ installed_modules = self.env['ir.module.module'].search([('state', '=', 'installed')])
+ installed_coas = [
+ name
+ for mapping in installed_modules.mapped('account_templates')
+ for name, template in mapping.items()
+ if template['visible'] and name not in ('syscohada', 'syscebnl') # Syscohada template is visible but deprecated since odoo#163350
+ ]
+
+ self.existing_companies = self.env['res.company'].search([])
+
+ self.env.flush_all()
+
+ for coa in installed_coas:
+ with contextlib.closing(self.env.cr.savepoint(flush=False)), self.subTest(CoA=coa):
+ # === 1. Set-up localization === #
+ available_reports, aml_pairs, accounts_by_aml = self._set_up_localization(coa)
+ self.env.cr.execute("ANALYZE account_account, account_move, account_move_line")
+
+ # Test each of the Balance Sheet reports available for the CoA.
+ for report in available_reports:
+ report_ref = report.get_external_id()[report.id]
+ with self.subTest(Report=report_ref):
+ _logger.info('Testing report %s with CoA %s', report_ref, coa)
+
+ # === 2. Set-up report === #
+ report_setup_data = self._set_up_report(report)
+
+ # === 3. Test that the report is balanced with both the debits journal entry and the credits journal entry. === #
+ if not IDENTIFY_INCORRECT_ACCOUNTS:
+ self._check_balance_sheet_balanced(report_setup_data, aml_pairs)
+ else:
+ bad_account_ids = set()
+ logging_fn = log_incorrect_accounts_detailed if EXTRA_DETAIL else log_incorrect_accounts_quiet
+
+ self._identify_incorrect_accounts(
+ report_setup_data,
+ aml_pairs,
+ accounts_by_aml,
+ bad_account_ids,
+ logging_fn,
+ )
+ if bad_account_ids:
+ self.fail('Balance Sheet not balanced.')
+
+ def _set_up_localization(self, coa):
+ ''' Set up a localization for testing.
+
+ This identifies the company to use (creating it if needed),
+ sets self.env.company to it, and generates AMLs for testing.
+
+ If a (demo) company already exists with the CoA, we'll just use it
+ rather than create a new company and load a new CoA for it.
+
+ :param account.chart.template coa: the Chart of Accounts to install
+
+ :return: (available_reports, aml_pairs, accounts_by_aml), where:
+ * available_reports are the reports that can be tested for this localization
+ * aml_pairs is a list of (aml_id, counterpart_aml_id) that were generated
+ * accounts_by_aml is a map {aml_id: account_id} for the generated AMLs
+ '''
+ # Always reset company, as the one used in the previous subtest might not exist anymore
+ self.env.company = self.existing_companies[0]
+
+ coa_setup_data = {}
+ if coa in self.existing_companies.mapped('chart_template'):
+ self.env.company = next(iter(self.existing_companies.filtered(lambda c: c.chart_template == coa)))
+
+ coa_setup_data['counterpart_account'] = self.env['account.account'].search([
+ ('company_ids', '=', self.env.company.id),
+ ('account_type', '=', 'asset_receivable'),
+ ], limit=1)
+ coa_setup_data['income_account'] = self.env['account.account'].search([
+ ('company_ids', '=', self.env.company.id),
+ ('internal_group', '=', 'income'),
+ ], limit=1)
+ coa_setup_data['journal'] = self.env['account.journal'].search([
+ ('company_id', '=', self.env.company.id),
+ ('type', '=', 'general')
+ ], limit=1)
+ else:
+ self.__class__.chart_template = coa
+ company_data = self.setup_other_company(name='company_3') # This uses cls.chart_template to load the right CoA
+ self.env.company = company_data['company']
+
+ coa_setup_data['counterpart_account'] = company_data['default_account_receivable']
+ coa_setup_data['income_account'] = company_data['default_account_revenue']
+ coa_setup_data['journal'] = company_data['default_journal_misc']
+
+ # Find the available Balance Sheets for the current company.
+ generic_balance_sheet = self.env.ref('odex30_account_reports.balance_sheet').with_company(self.env.company)
+ generic_balance_sheet.with_context(active_test=False).variant_report_ids.write({'active': True})
+ available_report_ids = [variant['id'] for variant in generic_balance_sheet.get_options({})['available_variants']]
+ available_reports = self.env['account.report'].browse(available_report_ids)
+
+ # Don't test Balance Sheets for which the REPORT_CONFIG specifies a chart_template_refs that differs from this CoA.
+ available_report_refs = available_reports.get_external_id()
+ report_ids_to_skip = [id_ for id_, report_ref in available_report_refs.items()
+ if coa not in REPORT_CONFIG.get(report_ref, {}).get('chart_template_refs', [coa])]
+ available_reports -= self.env['account.report'].browse(report_ids_to_skip)
+ available_reports = available_reports.with_company(self.env.company)
+
+ # Choose an account to be a counterpart account. Every AML we generate will have a counterpart in this account.
+ # We use the Accounts Receivable account (which in general should be well-configured.)
+ # However, if the Balance Sheet is incorrect for the counterpart account, the test will give weird results.
+ tested_accounts_domain = [
+ ('company_ids', '=', self.env.company.id),
+ ('internal_group', '!=', 'off_balance'),
+ ]
+ for code in NON_TESTED_ACCOUNTS['all'] + NON_TESTED_ACCOUNTS.get(coa, []):
+ tested_accounts_domain += ['!', ('code', '=like', f'{code}%')]
+ tested_accounts = self.env['account.account'].search(tested_accounts_domain)
+ coa_setup_data['tested_accounts'] = tested_accounts - coa_setup_data['counterpart_account']
+
+ # Create two test journal entries: one with debits in each account (other than the counterpart account), the other with credits.
+ debit_move_aml_pairs, debit_accounts_by_aml = self._create_balance_sheet_test_move(coa_setup_data)
+ credit_move_aml_pairs, credit_accounts_by_aml = self._create_balance_sheet_test_move(coa_setup_data, create_credits=True)
+
+ aml_pairs = debit_move_aml_pairs + credit_move_aml_pairs
+ accounts_by_aml = {**debit_accounts_by_aml, **credit_accounts_by_aml}
+
+ return available_reports, aml_pairs, accounts_by_aml
+
+ def _set_up_report(self, report):
+ ''' Set-up a Balance Sheet report for testing.
+
+ :param account.report report: the Balance Sheet report to set-up
+
+ The method sets the following keys of coa_setup_data:
+ report_ref: the XMLID of the report currently being tested
+ report: the report currently being tested
+ report_options: the report options for the test
+ report_column_balance_idx: the index of the column containing the balance
+ asset_line: the Total Assets report line
+ liability_line: the Total Liabilities report line
+ equity_line: the Total Equity report line (if separate from Liabilities)
+ '''
+ report_setup_data = {'report': report}
+
+ report_ref = report.get_external_id()[report.id]
+ if report_ref not in REPORT_CONFIG:
+ self.fail(f'''
+ The following Balance Sheet report is installed but is not configured to be tested:
+ {report_ref}
+ Please add it to the REPORT_CONFIG global at the beginning of this file.''')
+ report_config = REPORT_CONFIG[report_ref]
+
+ report_setup_data['report_ref'] = report_ref
+
+ report_setup_data['report_date'] = '2022-12-31'
+ report_setup_data['report_options'] = self._generate_options(report, False, fields.Date.to_date(report_setup_data['report_date']))
+
+ # Find the report column containing the total figures.
+ for idx, col in enumerate(report_setup_data['report_options']['columns']):
+ if col['expression_label'] in report_config.get('balance_col_label', 'balance'):
+ report_setup_data['report_column_balance_idx'] = idx
+ break
+ else:
+ self.fail(f'Could not identify totals column for report {report_ref} '
+ '- please add its expression_label in the REPORT_CONFIG global at the top of this file.')
+
+ report_setup_data['asset_line'] = self.env.ref(report_config['asset_line_ref'])
+ report_setup_data['liability_line'] = self.env.ref(report_config['liability_line_ref'])
+ report_setup_data['equity_line'] = self.env.ref(report_config['equity_line_ref']) if 'equity_line_ref' in report_config else None
+
+ return report_setup_data
+
+ def _create_balance_sheet_test_move(self, coa_setup_data, create_credits=False):
+ ''' Create a journal entry that will be the basis for testing the Balance Sheet.
+ The created journal entry will have one AML in each account in coa_setup_data['tested_accounts'],
+ and corresponding counterpart AMLs in coa_setup_data['counterpart_account'].
+ We create it in SQL to improve performance (since this gets run a lot on runbot).
+
+ :param bool create_credits: If true, the AMLs will be created with credits (instead of debits)
+ and the counterpart AMLs will be created with debits.
+
+ :return: (aml_pairs, accounts_by_aml), where:
+ - aml_pairs is a list of tuples (aml_id, counterpart_aml_id) containing the AMLs of the journal entry that was created.
+ - accounts_by_aml is a map of AML id to account id.
+ '''
+
+ def get_move_line_sql_create_vals(move, account, balance):
+ debit, credit = (balance, 0.0) if balance > 0.0 else (0.0, -balance)
+ return {
+ 'account_id': account.id,
+ 'balance': balance,
+ 'debit': debit,
+ 'credit': credit,
+ 'company_id': move.company_id.id,
+ 'currency_id': move.company_id.currency_id.id,
+ 'date': move.date,
+ 'display_type': 'product',
+ 'journal_id': move.journal_id.id,
+ 'move_id': move.id,
+ }
+
+ move = self.env['account.move'].create({
+ 'name': False,
+ 'date': '2022-06-01',
+ 'move_type': 'entry',
+ 'journal_id': coa_setup_data['journal'].id,
+ })
+
+ # For each account, we create 2 AMLs:
+ # - one AML in that account (either a debit or a credit, depending on the create_credits param)
+ # - one counterpart AML in the counterpart account
+ amls_vals = []
+ for i, account in enumerate(coa_setup_data['tested_accounts']):
+ balance = self.env.company.currency_id.round(i + 1)
+ if not create_credits:
+ balance = -balance
+ amls_vals += [
+ get_move_line_sql_create_vals(move, account, balance),
+ get_move_line_sql_create_vals(move, coa_setup_data['counterpart_account'], -balance),
+ ]
+
+ move_previous_year = self.env['account.move'].create({
+ 'name': False,
+ 'date': '2021-12-31',
+ 'move_type': 'entry',
+ 'journal_id': coa_setup_data['journal'].id,
+ })
+
+ # Additionally, in one Income/Expense account, create an AML in the previous fiscal year
+ # in order to test the unaffected earnings
+ amls_vals += [
+ get_move_line_sql_create_vals(move_previous_year, coa_setup_data['income_account'], balance),
+ get_move_line_sql_create_vals(move_previous_year, coa_setup_data['counterpart_account'], -balance),
+ ]
+
+ # Create the AMLs
+ query_columns = ', '.join(amls_vals[0].keys())
+ query_placeholder = ', '.join("%s" for d in amls_vals)
+ query_params = [tuple(d.values()) for d in amls_vals]
+
+ self.env['account.move.line'].invalidate_model()
+ self.env.cr.execute(
+ f'INSERT INTO "account_move_line" ({query_columns}) VALUES {query_placeholder} RETURNING "id"',
+ query_params,
+ )
+ aml_ids = [aml_id for aml_id, in self.env.cr.fetchall()]
+
+ # Create a map of AMLs to accounts, to avoid having to fetch this info from the DB
+ accounts_by_aml = {aml_ids[i]: aml_vals['account_id'] for i, aml_vals in enumerate(amls_vals)}
+
+ # Group AMLs in account/counterpart pairs
+ aml_pairs = []
+ for i, account in enumerate(coa_setup_data['tested_accounts']):
+ aml_id = aml_ids[2 * i]
+ counterpart_aml_id = aml_ids[2 * i + 1]
+ aml_pairs.append((aml_id, counterpart_aml_id))
+
+ return aml_pairs, accounts_by_aml
+
+ def _check_balance_sheet_balanced(self, report_setup_data, aml_pairs):
+ ''' Check whether the Balance Sheet is balanced. '''
+ # Set 'parent_state' to 'posted' on the AMLs of the debits journal entry, and to 'draft' on all other AMLs.
+ with self._activate_lines(aml_pairs):
+ totals = self._get_report_totals(report_setup_data)
+ if not totals['is_balanced']:
+ self.fail(f'''
+ The balance sheet {report_setup_data['report_ref']} is not balanced.
+ Total Assets: {totals['total_asset']}; Total Liabilities + Equity: {totals['total_liability']}.
+ This test can also find out for you which accounts are incorrectly used in the Balance Sheet.
+ To do this, set the IDENTIFY_INCORRECT_ACCOUNTS variable at the top of this file to something truthy.
+ ''')
+
+ @contextlib.contextmanager
+ def _activate_lines(self, aml_pairs):
+ ''' On entry: set the 'parent_state' of the specified AMLs to 'posted'.
+ On exit: set the 'parent_state' of these AMLs to 'draft'.
+
+ :param aml_pairs: list of tuples (aml_id, counterpart_aml_id) whose parent_state should be set to 'posted'.
+ '''
+ aml_ids = tuple(itertools.chain(*aml_pairs))
+ self.env['account.move.line'].browse(aml_ids).invalidate_recordset()
+ self.env.cr.execute(
+ '''
+ UPDATE account_move_line
+ SET parent_state = 'posted'
+ WHERE id IN %s
+ ''',
+ [aml_ids]
+ )
+ yield
+
+ self.env.cr.execute(
+ '''
+ UPDATE account_move_line
+ SET parent_state = 'draft'
+ WHERE id IN %s
+ ''',
+ [aml_ids]
+ )
+
+ def _get_report_totals(self, report_setup_data):
+ ''' Get the Assets, Liabilities and Equity totals of the Balance Sheet report. '''
+
+ def get_line_amount(lines, report_line, balance_column_idx):
+ line = next(filter(lambda line: self.env['account.report']._get_res_id_from_line_id(line['id'], 'account.report.line') == report_line.id, lines))
+ balance = line['columns'][balance_column_idx]['no_format']
+ self.assertIsNotNone(balance, f'Report line "{report_line.name}" does not set a value in the column which should contain the balance.')
+ return balance
+
+ lines = report_setup_data['report']._get_lines(report_setup_data['report_options'])
+
+ balance_column_idx = report_setup_data['report_column_balance_idx']
+
+ total_asset = get_line_amount(lines, report_setup_data['asset_line'], balance_column_idx)
+ total_liability = (
+ get_line_amount(lines, report_setup_data['liability_line'], balance_column_idx)
+ + (get_line_amount(lines, report_setup_data['equity_line'], balance_column_idx) if report_setup_data['equity_line'] else 0.0)
+ )
+
+ return {
+ 'total_asset': total_asset,
+ 'total_liability': total_liability,
+ 'is_balanced': self.env.company.currency_id.compare_amounts(total_asset, total_liability) == 0
+ }
+
+ def _identify_incorrect_accounts(self, report_setup_data, aml_pairs, accounts_by_aml, bad_account_ids, logging_fn):
+ ''' Identify accounts that are incorrectly used in the Balance Sheet, using a binary search.
+ This is a recursive function.
+
+ :param report_setup_data: The report configuration to test
+ :param aml_pairs: a list of tuples (aml_id, counterpart_aml_id)
+ account.move.lines which cause the Balance Sheet to be unbalanced.
+ :param accounts_by_aml: a map {aml_id: account_id} of the AMLs to test
+ :param bad_account_ids: a set of account ids that are determined to be badly referenced,
+ can be used to avoid testing a bad account twice
+ :param logging_fn: a function to call when an account causing an unbalance is found. This should take
+ as first argument a pair (aml_id, counterpart_aml_id) and
+ as second argument a dict containing information about the report totals
+ '''
+
+ # No need to further test AMLs in accounts already determined to be badly referenced.
+ aml_pairs = [
+ (aml_id, counterpart_aml_id)
+ for aml_id, counterpart_aml_id in aml_pairs
+ if accounts_by_aml[aml_id] not in bad_account_ids
+ ]
+ if not aml_pairs:
+ return
+ with self._activate_lines(aml_pairs):
+ totals = self._get_report_totals()
+
+ if totals['is_balanced']:
+ # Valid subtree case. There are no badly-referenced accounts in the subtree, just return (no need to search deeper)
+ return
+ elif len(aml_pairs) == 1:
+ # Leaf case. This account is badly-referenced: log and return.
+ logging_fn(
+ report_setup_data,
+ amls=self.env['account.move.line'].browse(aml_pairs[0]),
+ totals=totals,
+ is_first_call=not bad_account_ids,
+ )
+ bad_account_ids.add(accounts_by_aml[aml_pairs[0][0]])
+ else:
+ # Non-leaf case. Some accounts in the subtree are badly-referenced: bisect it into two halves, and check each half.
+ middle_index = len(aml_pairs) // 2
+ aml_pairs_left = aml_pairs[:middle_index]
+ aml_pairs_right = aml_pairs[middle_index:]
+ self._identify_incorrect_accounts(report_setup_data, aml_pairs_left, accounts_by_aml, bad_account_ids, logging_fn)
+ self._identify_incorrect_accounts(report_setup_data, aml_pairs_right, accounts_by_aml, bad_account_ids, logging_fn)
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_balance_sheet_report.py b/dev_odex30_accounting/odex30_account_reports/tests/test_balance_sheet_report.py
new file mode 100644
index 0000000..73b2910
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_balance_sheet_report.py
@@ -0,0 +1,247 @@
+
+from .common import TestAccountReportsCommon
+
+from odoo import fields, Command
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestBalanceSheetReport(TestAccountReportsCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.report = cls.env.ref('odex30_account_reports.balance_sheet')
+
+ def test_report_lines_ordering(self):
+ """ Check that the report lines are correctly ordered with nested account groups """
+ self.env['account.group'].create([{
+ 'name': 'A',
+ 'code_prefix_start': '101401',
+ 'code_prefix_end': '101601',
+ }, {
+ 'name': 'A1',
+ 'code_prefix_start': '1014010',
+ 'code_prefix_end': '1015010',
+ }])
+
+ cid = self.env.company.id
+ account_bank = self.env.ref(f"account.{cid}_bank").default_account_id
+ account_cash = self.env.ref(f"account.{cid}_cash").default_account_id
+ account_a = self.env['account.account'].create([{'code': '1014010', 'name': 'A', 'account_type': 'asset_cash'}])
+ account_c = self.env['account.account'].create([{'code': '101600', 'name': 'C', 'account_type': 'asset_cash'}])
+
+ # Create a journal lines for each account
+ move = self.env['account.move'].create({
+ 'date': '2020-02-02',
+ 'line_ids': [
+ Command.create({
+ 'account_id': account.id,
+ 'name': 'name',
+ })
+ for account in [account_a, account_c, account_bank, account_cash]
+ ],
+ })
+ move.action_post()
+ move.line_ids.flush_recordset()
+
+ # Create the report hierarchy with the Bank and Cash Accounts lines unfolded
+ line_id = self._get_basic_line_dict_id_from_report_line_ref('odex30_account_reports.account_financial_report_bank_view0')
+ options = self._generate_options(
+ self.report,
+ fields.Date.from_string('2020-02-01'),
+ fields.Date.from_string('2020-02-28')
+ )
+ options['unfolded_lines'] = [line_id]
+ options['hierarchy'] = True
+ self.env.company.totals_below_sections = False
+ lines = self.report._get_lines(options)
+
+ # The Bank and Cash Accounts section start at index 2
+ # Since we created 4 lines + 2 groups, we keep the 6 following lines
+ unfolded_lines = self.report._get_unfolded_lines(lines, line_id)
+ unfolded_lines = [{'name': line['name'], 'level': line['level']} for line in unfolded_lines]
+
+ self.assertEqual(
+ unfolded_lines,
+ [
+ {'level': 5, 'name': 'Bank and Cash Accounts'},
+ {'level': 6, 'name': '101401-101601 A'},
+ {'level': 7, 'name': '101401 Bank'},
+ {'level': 7, 'name': '1014010-1015010 A1'},
+ {'level': 8, 'name': '1014010 A'},
+ {'level': 8, 'name': '101501 Cash'},
+ {'level': 7, 'name': '101600 C'},
+ ]
+ )
+
+ def test_balance_sheet_custom_date(self):
+ line_id = self.env.ref('odex30_account_reports.account_financial_report_bank_view0').id
+ self.report.filter_multi_company = 'disabled'
+ options = self._generate_options(self.report, fields.Date.from_string('2020-02-01'), fields.Date.from_string('2020-02-28'))
+ options['date']['filter'] = 'custom'
+ options['unfolded_lines'] = [line_id]
+
+ invoices = self.env['account.move'].create([{
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'date': '2020-0%s-15' % i,
+ 'invoice_date': '2020-0%s-15' % i,
+ 'invoice_line_ids': [(0, 0, {
+ 'product_id': self.product_a.id,
+ 'price_unit': 1000.0,
+ 'tax_ids': [(6, 0, self.tax_sale_a.ids)],
+ })],
+ } for i in range(1, 4)])
+ invoices.action_post()
+
+ lines = self.report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Balance
+ [ 0, 1],
+ [
+ ('ASSETS', 2300.00),
+ ('Current Assets', 2300.00),
+ ('Bank and Cash Accounts', 0.00),
+ ('Receivables', 2300.00),
+ ('Current Assets', 0.00),
+ ('Prepayments', 0.00),
+ ('Total Current Assets', 2300.00),
+ ('Plus Fixed Assets', 0.00),
+ ('Plus Non-current Assets', 0.00),
+ ('Total ASSETS', 2300.00),
+
+ ('LIABILITIES', 300.00),
+ ('Current Liabilities', 300.00),
+ ('Current Liabilities', 300.00),
+ ('Payables', 0.00),
+ ('Total Current Liabilities', 300.00),
+ ('Plus Non-current Liabilities', 0.00),
+ ('Total LIABILITIES', 300.00),
+
+ ('EQUITY', 2000.00),
+ ('Unallocated Earnings', 2000.00),
+ ('Current Year Unallocated Earnings', 2000.00),
+ ('Previous Years Unallocated Earnings', 0.00),
+ ('Total Unallocated Earnings', 2000.00),
+ ('Retained Earnings', 0.00),
+ ('Current Year Retained Earnings', 0.00),
+ ('Previous Years Retained Earnings', 0.00),
+ ('Total Retained Earnings', 0.00),
+ ('Total EQUITY', 2000.00),
+ ('LIABILITIES + EQUITY', 2300.00),
+ ],
+ options,
+ )
+
+ def test_unfold_all_and_total_lines(self):
+ """ Check that exactly the total lines we want exist when we use the 'unfold_all' option. I.e. empty sections should not have total lines (since there are not lines to total).
+ This test only tests the function '_get_lines'. It does not test that the total lines are always handled correctly for manual unfolds in the web UI. """
+
+ self.env.company.totals_below_sections = True
+ options = self._generate_options(
+ self.report,
+ fields.Date.from_string('1990-01-01'),
+ fields.Date.from_string('1990-01-01')
+ )
+
+ bank_journal = self.company_data['default_journal_bank']
+ account_bank = bank_journal.default_account_id
+ account_other = self.env['account.account'].create({
+ 'account_type': 'asset_current',
+ 'name': 'account_other',
+ 'code': '121040',
+ 'reconcile': True,
+ })
+ self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '1990-01-01',
+ 'journal_id': bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 200.0, 'credit': 0.0, 'account_id': account_bank.id}),
+ (0, 0, {'debit': 0.0, 'credit': 200.0, 'account_id': account_other.id}),
+ ],
+ }).action_post()
+
+ # Note that assertLinesValues filters / ignores children lines of folded lines.
+ # The total lines for the 2 visible lines with groupbys already exist in 'folded_lines' but are filtered / ignored by assertLinesValues.
+ # (Thus they do not appear in the expected result below either.)
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name
+ [ 0],
+ [
+ ('ASSETS',),
+ ('Current Assets',),
+ ('Bank and Cash Accounts',),
+ ('Receivables',),
+ ('Current Assets',),
+ ('Prepayments',),
+ ('Total Current Assets',),
+ ('Plus Fixed Assets',),
+ ('Plus Non-current Assets',),
+ ('Total ASSETS',),
+ ('LIABILITIES',),
+ ('Current Liabilities',),
+ ('Current Liabilities',),
+ ('Payables',),
+ ('Total Current Liabilities',),
+ ('Plus Non-current Liabilities',),
+ ('Total LIABILITIES',),
+ ('EQUITY',),
+ ('Unallocated Earnings',),
+ ('Current Year Unallocated Earnings',),
+ ('Previous Years Unallocated Earnings',),
+ ('Total Unallocated Earnings',),
+ ('Retained Earnings',),
+ ('Current Year Retained Earnings',),
+ ('Previous Years Retained Earnings',),
+ ('Total Retained Earnings',),
+ ('Total EQUITY',),
+ ('LIABILITIES + EQUITY',),
+ ],
+ options,
+ )
+
+ options['unfold_all'] = True
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name
+ [ 0],
+ [
+ ('ASSETS',),
+ ('Current Assets',),
+ ('Bank and Cash Accounts',),
+ (account_bank.display_name,),
+ ('Total Bank and Cash Accounts',),
+ ('Receivables',),
+ ('Current Assets',),
+ (account_other.display_name,),
+ ('Total Current Assets',),
+ ('Prepayments',),
+ ('Total Current Assets',),
+ ('Plus Fixed Assets',),
+ ('Plus Non-current Assets',),
+ ('Total ASSETS',),
+ ('LIABILITIES',),
+ ('Current Liabilities',),
+ ('Current Liabilities',),
+ ('Payables',),
+ ('Total Current Liabilities',),
+ ('Plus Non-current Liabilities',),
+ ('Total LIABILITIES',),
+ ('EQUITY',),
+ ('Unallocated Earnings',),
+ ('Current Year Unallocated Earnings',),
+ ('Previous Years Unallocated Earnings',),
+ ('Total Unallocated Earnings',),
+ ('Retained Earnings',),
+ ('Current Year Retained Earnings',),
+ ('Previous Years Retained Earnings',),
+ ('Total Retained Earnings',),
+ ('Total EQUITY',),
+ ('LIABILITIES + EQUITY',),
+ ],
+ options,
+ )
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_budget.py b/dev_odex30_accounting/odex30_account_reports/tests/test_budget.py
new file mode 100644
index 0000000..c4c8d4d
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_budget.py
@@ -0,0 +1,716 @@
+from odoo import Command, fields
+from odoo.tests import tagged
+from odoo.tools import date_utils
+from odoo.addons.odex30_account_reports.tests.common import TestAccountReportsCommon
+
+
+@tagged('post_install', '-at_install')
+class TestBudgetReport(TestAccountReportsCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.company_data['company'].totals_below_sections = False
+
+ cls.account_1 = cls.company_data['default_account_revenue']
+ cls.account_2 = cls.copy_account(cls.account_1)
+ cls.account_3 = cls.copy_account(cls.account_1)
+ cls.account_4 = cls.copy_account(cls.account_1)
+
+ # Create a test report
+ cls.report = cls.env['account.report'].create({
+ 'name': "Budget Test",
+ 'filter_date_range': True,
+ 'filter_budgets': True,
+ 'filter_period_comparison': True,
+ 'column_ids': [
+ Command.create({
+ 'name': "Balance",
+ 'expression_label': 'balance',
+ }),
+ ],
+ 'line_ids': [
+ Command.create({
+ 'name': 'line_domain',
+ 'groupby': 'account_id',
+ 'foldable': False,
+ 'expression_ids': [
+ Command.create({
+ 'label': 'balance',
+ 'formula': "[('account_id.account_type', '=', 'income')]",
+ 'subformula': 'sum',
+ 'engine': 'domain',
+ }),
+ ],
+ }),
+ Command.create({
+ 'name': 'line_account_codes',
+ 'groupby': 'account_id',
+ 'foldable': False,
+ 'expression_ids': [
+ Command.create({
+ 'label': 'balance',
+ 'formula': cls.account_1.code[:3],
+ 'engine': 'account_codes',
+ }),
+ ],
+ }),
+ ],
+ })
+
+ # Create budgets
+ cls.budget_1 = cls._create_budget(
+ {
+ cls.account_1.id: 1000,
+ cls.account_3.id: 100,
+ cls.account_4.id: 10,
+ },
+ '2020-01-01',
+ '2020-01-01',
+ )
+ cls.budget_2 = cls._create_budget(
+ {
+ cls.account_2.id: 10,
+ },
+ '2020-01-01',
+ '2020-01-01',
+ )
+
+ @classmethod
+ def _create_budget(cls, amount_per_account_ids=None, date_from=None, date_to=None):
+ date_from, date_to = fields.Date.to_date(date_from), fields.Date.to_date(date_to)
+ items = []
+ for account_id, amount in amount_per_account_ids.items():
+ for item_date in date_utils.date_range(date_from, date_to):
+ items.append(Command.create({
+ 'amount': amount,
+ 'account_id': account_id,
+ 'date': date_utils.start_of(item_date, 'month'),
+ }))
+
+ return cls.env['account.report.budget'].create({
+ 'name': 'Budget',
+ 'item_ids': items,
+ })
+
+ @classmethod
+ def _create_moves(cls, amount_per_account_ids=None, date_from=None, date_to=None, to_post=True):
+ date_from, date_to = fields.Date.to_date(date_from), fields.Date.to_date(date_to)
+ moves_to_create = []
+ for move_date in date_utils.date_range(date_from, date_to):
+ move_to_create = {
+ 'date': move_date,
+ 'journal_id': cls.company_data['default_journal_misc'].id,
+ 'line_ids': [],
+ }
+ for account_id, amount in amount_per_account_ids.items():
+ move_to_create['line_ids'].extend([
+ Command.create({
+ 'debit': amount,
+ 'account_id': account_id,
+ }),
+ Command.create({
+ 'credit': amount,
+ 'account_id': cls.company_data['default_account_assets'].id,
+ })
+ ])
+ moves_to_create.append(move_to_create)
+ moves = cls.env['account.move'].create(moves_to_create)
+ if to_post:
+ moves.action_post()
+ return moves
+
+ def test_reports_single_budget(self):
+ self._create_moves(
+ {
+ self.account_1.id: 100,
+ self.account_2.id: 200,
+ self.account_3.id: 300,
+ },
+ '2020-01-01',
+ '2020-01-01',
+ )
+
+ options = self._generate_options(
+ self.report,
+ '2020-01-01',
+ '2020-12-31',
+ default_options={'budgets': [{'id': self.budget_1.id, 'selected': True}]},
+ )
+
+ lines = self.report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ [ 0, 1, 2, 3],
+ [
+ ('line_domain', 600, 1110, '54.1%'),
+ (self.account_1.display_name, 100, 1000, '10.0%'),
+ (self.account_2.display_name, 200, 0, 'n/a'),
+ (self.account_3.display_name, 300, 100, '300.0%'),
+ (self.account_4.display_name, 0, 10, '0.0%'),
+ ('line_account_codes', 600, 1110, '54.1%'),
+ (self.account_1.display_name, 100, 1000, '10.0%'),
+ (self.account_2.display_name, 200, 0, 'n/a'),
+ (self.account_3.display_name, 300, 100, '300.0%'),
+ (self.account_4.display_name, 0, 10, '0.0%'),
+ ],
+ options,
+ )
+
+ def test_reports_multiple_budgets(self):
+ """ This test verifies the report when we have several budgets selected.
+ The report should have 2 columns per budget, the budget itself and
+ the comparison column.
+ """
+ self._create_moves(
+ {
+ self.account_1.id: 100,
+ self.account_2.id: 200,
+ self.account_3.id: 300,
+ },
+ '2020-01-01',
+ '2020-01-01',
+ )
+
+ options = self._generate_options(
+ self.report,
+ '2020-01-01',
+ '2020-12-31',
+ default_options={'budgets': [{'id': self.budget_1.id, 'selected': True}, {'id': self.budget_2.id, 'selected': True}]},
+ )
+
+ lines = self.report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ [ 0, 1, 2, 3, 4, 5],
+ [
+ ('line_domain', 600, 1110, '54.1%', 10, '6000.0%'),
+ (self.account_1.display_name, 100, 1000, '10.0%', 0, 'n/a'),
+ (self.account_2.display_name, 200, 0, 'n/a', 10, '2000.0%'),
+ (self.account_3.display_name, 300, 100, '300.0%', 0, 'n/a'),
+ (self.account_4.display_name, 0, 10, '0.0%', 0, 'n/a'),
+ ('line_account_codes', 600, 1110, '54.1%', 10, '6000.0%'),
+ (self.account_1.display_name, 100, 1000, '10.0%', 0, 'n/a'),
+ (self.account_2.display_name, 200, 0, 'n/a', 10, '2000.0%'),
+ (self.account_3.display_name, 300, 100, '300.0%', 0, 'n/a'),
+ (self.account_4.display_name, 0, 10, '0.0%', 0, 'n/a'),
+ ],
+ options,
+ )
+
+ def test_reports_budget_with_comparison_period(self):
+ budget_2024 = self._create_budget({self.account_1.id: 200}, '2024-01-01', '2024-12-31')
+ moves = self._create_moves({self.account_1.id: 200}, '2024-01-01', '2024-12-31', to_post=False)
+ moves += self._create_moves({self.account_1.id: 600}, '2024-06-22', '2024-06-22', to_post=False)
+ moves.action_post()
+
+ options = self._generate_options(
+ self.report,
+ '2024-06-01',
+ '2024-06-30',
+ default_options={
+ 'budgets': [{'id': budget_2024.id, 'selected': True}],
+ 'comparison': {'filter': 'previous_period', 'number_period': 1},
+ },
+ )
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ [ 0, 1, 2, 3, 4, 5, 6],
+ [
+ ('line_domain', 800, 200, '400.0%', 200, 200, '100.0%'),
+ (self.account_1.display_name, 800, 200, '400.0%', 200, 200, '100.0%'),
+ ('line_account_codes', 800, 200, '400.0%', 200, 200, '100.0%'),
+ (self.account_1.display_name, 800, 200, '400.0%', 200, 200, '100.0%'),
+ ],
+ options,
+ )
+
+ def test_reports_budget_with_comparison_period_and_multiple_budgets(self):
+ budget_2024 = self._create_budget({self.account_1.id: 200}, '2024-01-01', '2024-12-31')
+ budget_2023 = self._create_budget({self.account_1.id: 400}, '2023-01-01', '2023-12-31')
+ moves = self._create_moves({self.account_1.id: 200}, '2024-01-01', '2024-12-31', to_post=False)
+ moves += self._create_moves({self.account_1.id: 400}, '2023-01-01', '2023-12-31', to_post=False)
+ moves.action_post()
+
+ options = self._generate_options(
+ self.report,
+ '2024-01-01',
+ '2024-12-31',
+ default_options={
+ 'budgets': [{'id': budget_2024.id, 'selected': True}, {'id': budget_2023.id, 'selected': True}],
+ 'comparison': {'filter': 'previous_period', 'number_period': 1},
+ },
+ )
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
+ # [ Period 2024 + Budget 2024 + % + Budget 2023 + %] [ Period 2023 + Budget 2024 + % + Budget 2023 + %]
+ [
+ ('line_domain', 2400, 2400, '100.0%', 0, 'n/a', 4800, 0, 'n/a', 4800, '100.0%'),
+ (self.account_1.display_name, 2400, 2400, '100.0%', 0, 'n/a', 4800, 0, 'n/a', 4800, '100.0%'),
+ ('line_account_codes', 2400, 2400, '100.0%', 0, 'n/a', 4800, 0, 'n/a', 4800, '100.0%'),
+ (self.account_1.display_name, 2400, 2400, '100.0%', 0, 'n/a', 4800, 0, 'n/a', 4800, '100.0%'),
+ ],
+ options,
+ )
+
+ def test_report_budget_items_with_different_date_filters(self):
+ """ The aim of this test is checking that we get the correct budget items
+ according to the selected dates.
+ """
+ budget_2024 = self._create_budget({self.account_1.id: 300}, '2024-01-01', '2024-12-31')
+ self._create_moves({self.account_1.id: 200}, '2024-01-01', '2024-12-31')
+
+ # Case when we display a whole year in the report
+ options = self._generate_options(
+ self.report,
+ '2024-01-01',
+ '2024-12-31',
+ default_options={'budgets': [{'id': budget_2024.id, 'selected': True}]},
+ )
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ [ 0, 1, 2],
+ [
+ ('line_domain', 2400, 3600),
+ (self.account_1.display_name, 2400, 3600),
+ ('line_account_codes', 2400, 3600),
+ (self.account_1.display_name, 2400, 3600),
+ ],
+ options,
+ )
+
+ # Case when we display a whole quarter
+ options = self._generate_options(
+ self.report,
+ '2024-01-01',
+ '2024-03-31',
+ default_options={'budgets': [{'id': budget_2024.id, 'selected': True}]},
+ )
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ [ 0, 1, 2],
+ [
+ ('line_domain', 600, 900),
+ (self.account_1.display_name, 600, 900),
+ ('line_account_codes', 600, 900),
+ (self.account_1.display_name, 600, 900),
+ ],
+ options,
+ )
+
+ # Case when we display a whole month
+ options = self._generate_options(
+ self.report,
+ '2024-03-01',
+ '2024-03-31',
+ default_options={'budgets': [{'id': budget_2024.id, 'selected': True}]},
+ )
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ [ 0, 1, 2],
+ [
+ ('line_domain', 200, 300),
+ (self.account_1.display_name, 200, 300),
+ ('line_account_codes', 200, 300),
+ (self.account_1.display_name, 200, 300),
+ ],
+ options,
+ )
+
+ def test_report_budget_edit_items(self):
+ """ This test verifies the way we're modifying budget items.
+ The system calculates the difference between the old and new values
+ and distributes this difference proportionally over the number of
+ months in the selected period.
+
+ The test checks the following scenarios:
+ 1. Adding a rounded value (1200) for an entire year (12 months).
+ 2. Setting the value to 300 for 3 months (no change expected).
+ 3. Setting the value to 900 for 3 months, resulting in three new values of 200 each.
+ 4. Reducing the value for a whole year to a non-rounded value and checking if the
+ remainder is correctly applied to the last period.
+ """
+ def set_budget_item(value, account_id):
+ budget_2024._create_or_update_budget_items(
+ value_to_set=value,
+ account_id=account_id,
+ rounding=self.env.company.currency_id.decimal_places,
+ date_from=options['date']['date_from'],
+ date_to=options['date']['date_to'],
+ )
+
+ budget_2024 = self.env['account.report.budget'].create({'name': "Budget 2024"})
+ self._create_moves({self.account_1.id: 200}, '2024-01-01', '2024-12-31')
+
+ options = self._generate_options(
+ self.report,
+ '2024-01-01',
+ '2024-12-31',
+ default_options={'budgets': [{'id': budget_2024.id, 'selected': True}]},
+ )
+ set_budget_item(1200, self.account_1.id)
+ expected_items = [
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-01-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-02-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-03-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-04-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-05-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-06-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-07-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-08-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-09-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-10-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-11-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-12-01')},
+ ]
+
+ self.assertRecordValues(
+ budget_2024.item_ids,
+ expected_items,
+ )
+ options = self._generate_options(
+ self.report,
+ '2024-01-01',
+ '2024-03-31',
+ default_options={'budgets': [{'id': budget_2024.id, 'selected': True}]},
+ )
+ set_budget_item(300, self.account_1.id)
+ # Nothing should be added as we don't change anything
+ self.assertRecordValues(
+ budget_2024.item_ids,
+ expected_items,
+ )
+
+ set_budget_item(900, self.account_1.id)
+ expected_items = [
+ {'amount': 300.0, 'date': fields.Date.to_date('2024-01-01')},
+ {'amount': 300.0, 'date': fields.Date.to_date('2024-02-01')},
+ {'amount': 300.0, 'date': fields.Date.to_date('2024-03-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-04-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-05-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-06-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-07-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-08-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-09-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-10-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-11-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2024-12-01')},
+ ]
+ self.assertRecordValues(
+ budget_2024.item_ids,
+ expected_items,
+ )
+
+ options = self._generate_options(
+ self.report,
+ '2024-01-01',
+ '2024-12-31',
+ default_options={'budgets': [{'id': budget_2024.id, 'selected': True}]},
+ )
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ [ 0, 1, 2],
+ [
+ ('line_domain', 2400, 1800),
+ (self.account_1.display_name, 2400, 1800),
+ ('line_account_codes', 2400, 1800),
+ (self.account_1.display_name, 2400, 1800),
+ ],
+ options,
+ )
+
+ # Test the case with a remainder
+ set_budget_item(1000, self.account_1.id)
+ expected_items = [
+ {'amount': 300.0 - 66.66, 'date': fields.Date.to_date('2024-01-01')},
+ {'amount': 300.0 - 66.66, 'date': fields.Date.to_date('2024-02-01')},
+ {'amount': 300.0 - 66.66, 'date': fields.Date.to_date('2024-03-01')},
+ {'amount': 100.0 - 66.66, 'date': fields.Date.to_date('2024-04-01')},
+ {'amount': 100.0 - 66.66, 'date': fields.Date.to_date('2024-05-01')},
+ {'amount': 100.0 - 66.66, 'date': fields.Date.to_date('2024-06-01')},
+ {'amount': 100.0 - 66.66, 'date': fields.Date.to_date('2024-07-01')},
+ {'amount': 100.0 - 66.66, 'date': fields.Date.to_date('2024-08-01')},
+ {'amount': 100.0 - 66.66, 'date': fields.Date.to_date('2024-09-01')},
+ {'amount': 100.0 - 66.66, 'date': fields.Date.to_date('2024-10-01')},
+ {'amount': 100.0 - 66.66, 'date': fields.Date.to_date('2024-11-01')},
+ {'amount': 100.0 - 66.74, 'date': fields.Date.to_date('2024-12-01')},
+ ]
+ self.assertRecordValues(
+ budget_2024.item_ids,
+ expected_items,
+ )
+
+ def test_report_budget_show_all_accounts_filter(self):
+ """ The aim of this test is checking that the show all accounts filter
+ is returning all the income accounts (as our test report is working with
+ income accounts only).
+ """
+ budget_2024 = self._create_budget({self.account_1.id: 300}, '2024-01-01', '2024-12-31')
+
+ options = self._generate_options(
+ self.report,
+ '2024-01-01',
+ '2024-12-31',
+ default_options={
+ 'budgets': [{'id': budget_2024.id, 'selected': True}],
+ 'show_all_accounts': True,
+ },
+ )
+ expected_lines = [
+ ('line_domain', 0, 3600),
+ (self.account_1.display_name, 0, 3600),
+ ]
+ expected_lines += [
+ (account['display_name'], 0, 0)
+ for account in self.env['account.account'].search_read([
+ ('id', '!=', self.account_1.id),
+ ('account_type', '=', 'income'),
+ ('company_ids', 'in', self.report.get_report_company_ids(options)),
+ ], ['display_name'], order='code')
+ ]
+ expected_lines += [
+ ('line_account_codes', 0, 3600),
+ (self.account_1.display_name, 0, 3600),
+ ]
+ expected_lines += [
+ (account['display_name'], 0, 0)
+ for account in self.env['account.account'].search_read([
+ ('id', '!=', self.account_1.id),
+ ('code', '=like', f'{self.account_1.code[:3]}%'),
+ ('company_ids', 'in', self.report.get_report_company_ids(options)),
+ ], ['display_name'], order='code')
+ ]
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ [0, 1, 2],
+ expected_lines,
+ options,
+ )
+
+ def test_financial_budget_with_analytic_groupby(self):
+ """ Ensure that when budgets and analytics groupby filters are both active, then their headers are
+ displayed on the same level, and values in the report are properly computed"""
+
+ budget_a = self.env['account.report.budget'].create([{'name': "Budget A"}])
+ budget_b = self.env['account.report.budget'].create([{'name': "Budget B"}])
+
+ budget_a._create_or_update_budget_items(
+ value_to_set=2400, # 100 each month
+ account_id=self.account_1.id,
+ rounding=self.env.company.currency_id.decimal_places,
+ date_from='2024-01-01',
+ date_to='2025-12-31',
+ )
+ budget_b._create_or_update_budget_items(
+ value_to_set=3000, # 125 each month
+ account_id=self.account_1.id,
+ rounding=self.env.company.currency_id.decimal_places,
+ date_from='2024-01-01',
+ date_to='2025-12-31',
+ )
+
+ self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting')
+ self.report.write({'filter_analytic_groupby': True})
+
+ analytic_plan_a = self.env['account.analytic.plan'].create([{
+ 'name': 'Plan A',
+ }])
+ analytic_account_1 = self.env['account.analytic.account'].create([{
+ 'name': 'Account A1',
+ 'plan_id': analytic_plan_a.id
+ }])
+ analytic_account_2 = self.env['account.analytic.account'].create([{
+ 'name': 'Account A2',
+ 'plan_id': analytic_plan_a.id
+ }])
+
+ moves_2024 = self._create_moves({self.account_1.id: 100}, '2024-01-01', '2024-12-31', to_post=False)
+ moves_2025 = self._create_moves({self.account_1.id: 150}, '2025-01-01', '2025-12-31', to_post=False)
+
+ for move_line in (moves_2024 + moves_2025).line_ids:
+ move_line.analytic_distribution = {str(analytic_account_1.id): 75.0, str(analytic_account_2.id): 25.0}
+
+ (moves_2024 + moves_2025).action_post()
+
+ options = self._generate_options(
+ self.report,
+ '2025-01-01',
+ '2025-12-31',
+ default_options={
+ 'comparison': {'filter': 'previous_period', 'number_period': 1},
+ 'analytic_accounts_groupby': [analytic_account_1.id, analytic_account_2.id],
+ 'budgets': [{'id': budget_a.id, 'selected': True}, {'id': budget_b.id, 'selected': True}],
+ }
+ )
+
+ self.assertEqual(
+ [header['name'] for header in options['column_headers'][1]],
+ ['Account A1', 'Account A2', '', 'Budget A', '%', 'Budget B', '%']
+ )
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # [ 2025 ] [ 2024 ]
+ # [ A1 ] [ A2 ] [ Total ] [ Budget A ] [ % ] [ Budget B ] [ % ] [ A1 ] [ A2 ] [ Total ] [ Budget A ] [ % ] [ Budget B ] [ % ]
+ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14],
+ [
+ ('line_domain', 1350, 450, 1800, 1200, '150.0%', 1500, '120.0%', 900, 300, 1200, 1200, '100.0%', 1500, '80.0%'),
+ (self.account_1.display_name, 1350, 450, 1800, 1200, '150.0%', 1500, '120.0%', 900, 300, 1200, 1200, '100.0%', 1500, '80.0%'),
+ ('line_account_codes', 1350, 450, 1800, 1200, '150.0%', 1500, '120.0%', 900, 300, 1200, 1200, '100.0%', 1500, '80.0%'),
+ (self.account_1.display_name, 1350, 450, 1800, 1200, '150.0%', 1500, '120.0%', 900, 300, 1200, 1200, '100.0%', 1500, '80.0%'),
+ ],
+ options,
+ )
+
+ def test_financial_budget_with_several_columns(self):
+ """ Ensure that financial budget feature works properly on reports with several columns,
+ and that percentage column of budget is hidden is the case where multiple monetary columns exist """
+ self.report.write({
+ 'column_ids': [
+ Command.create({
+ 'name': "Column test",
+ 'expression_label': 'column_test',
+ }),
+ ],
+ })
+
+ self._create_moves(
+ {
+ self.account_1.id: 100,
+ self.account_2.id: 200,
+ self.account_3.id: 300,
+ },
+ '2020-01-01',
+ '2020-01-01',
+ )
+
+ options = self._generate_options(
+ self.report,
+ '2020-01-01',
+ '2020-12-31',
+ default_options={
+ 'comparison': {'filter': 'previous_period', 'number_period': 1},
+ 'budgets': [{'id': self.budget_1.id, 'selected': True}],
+ }
+ )
+
+ # Ensure level header colspan is 3 for top header, 2 for the columns + 1 selected budget
+ self.assertEqual(
+ self.report._get_column_headers_render_data(options),
+ {'level_colspan': [3, 2], 'level_repetitions': [1, 2], 'custom_subheaders': []}
+ )
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # [ 2020 ] [ 2019 ]
+ # [ col 1 ] [ col 2 ] [ budget 1 ] [ col 1 ] [ col 2 ] [ budget 1 ]
+ [0, 1, 2, 3, 4, 5, 6],
+ [
+ ('line_domain', 600, '', 1110, 0, '', 0),
+ (self.account_1.display_name, 100, '', 1000, 0, '', 0),
+ (self.account_2.display_name, 200, '', 0, 0, '', 0),
+ (self.account_3.display_name, 300, '', 100, 0, '', 0),
+ (self.account_4.display_name, 0, '', 10, 0, '', 0),
+ ('line_account_codes', 600, '', 1110, 0, '', 0),
+ (self.account_1.display_name, 100, '', 1000, 0, '', 0),
+ (self.account_2.display_name, 200, '', 0, 0, '', 0),
+ (self.account_3.display_name, 300, '', 100, 0, '', 0),
+ (self.account_4.display_name, 0, '', 10, 0, '', 0),
+ ],
+ options,
+ )
+
+ def test_hierarchy_with_budget(self):
+ """ Check that the report lines are correct with a budget and the option "Hierarchy and subtotals" is ticked"""
+ self.env['account.group'].create({
+ 'name': 'Sales',
+ 'code_prefix_start': '1',
+ 'code_prefix_end': '9',
+ })
+
+ move = self.env['account.move'].create({
+ 'date': '2020-02-02',
+ 'line_ids': [
+ Command.create({
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'name': 'name',
+ })
+ ],
+ })
+ move.action_post()
+ move.line_ids.flush_recordset()
+ profit_and_loss_report = self.env.ref('odex30_account_reports.profit_and_loss')
+ line_id = self._get_basic_line_dict_id_from_report_line_ref('odex30_account_reports.account_financial_report_revenue0')
+ options = self._generate_options(profit_and_loss_report, '2020-02-01', '2024-12-28', default_options={'budgets': [{'id': self.budget_1.id, 'selected': True}]})
+ options['unfolded_lines'] = [line_id]
+ options['hierarchy'] = True
+ options['unfold_all'] = True
+
+ lines = profit_and_loss_report._get_lines(options)
+ unfolded_lines = profit_and_loss_report._get_unfolded_lines(lines, line_id)
+ unfolded_lines = [{'name': line['name'], 'level': line['level']} for line in unfolded_lines]
+
+ self.assertEqual(
+ unfolded_lines,
+ [
+ {'level': 1, 'name': 'Revenue'},
+ {'level': 2, 'name': '1-9 Sales'},
+ {'level': 3, 'name': '400000 Product Sales'},
+ ]
+ )
+
+ def test_budget_report_edit_items_with_date(self):
+ """
+ Ensure budget items are created or updated correctly
+ when editing across varying date ranges
+ """
+ def set_budget_item(value, account_id, date_from, date_to):
+ budget._create_or_update_budget_items(
+ value_to_set=value,
+ account_id=account_id,
+ rounding=self.env.company.currency_id.decimal_places,
+ date_from=date_from,
+ date_to=date_to,
+ )
+
+ budget = self.env['account.report.budget'].create({'name': "Budget"})
+ set_budget_item(1200, self.account_1.id, '2025-01-01', '2025-12-01')
+ expected_items = [
+ {'amount': 100.0, 'date': fields.Date.to_date('2025-01-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2025-02-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2025-03-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2025-04-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2025-05-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2025-06-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2025-07-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2025-08-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2025-09-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2025-10-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2025-11-01')},
+ {'amount': 100.0, 'date': fields.Date.to_date('2025-12-01')},
+ ]
+ self.assertRecordValues(budget.item_ids, expected_items)
+
+ # Update the second and third months to total of 900 (450 per month)
+ set_budget_item(900, self.account_1.id, '2025-01-10', '2025-3-10')
+ # Now the second and third months each have an amount of 450
+
+ set_budget_item(1000, self.account_1.id, '2025-01-01', '2025-12-01')
+ expected_items = [
+ {'amount': 100.0 - 75.00, 'date': fields.Date.to_date('2025-01-01')},
+ {'amount': 450.0 - 75.00, 'date': fields.Date.to_date('2025-02-01')},
+ {'amount': 450.0 - 75.00, 'date': fields.Date.to_date('2025-03-01')},
+ {'amount': 100.0 - 75.00, 'date': fields.Date.to_date('2025-04-01')},
+ {'amount': 100.0 - 75.00, 'date': fields.Date.to_date('2025-05-01')},
+ {'amount': 100.0 - 75.00, 'date': fields.Date.to_date('2025-06-01')},
+ {'amount': 100.0 - 75.00, 'date': fields.Date.to_date('2025-07-01')},
+ {'amount': 100.0 - 75.00, 'date': fields.Date.to_date('2025-08-01')},
+ {'amount': 100.0 - 75.00, 'date': fields.Date.to_date('2025-09-01')},
+ {'amount': 100.0 - 75.00, 'date': fields.Date.to_date('2025-10-01')},
+ {'amount': 100.0 - 75.00, 'date': fields.Date.to_date('2025-11-01')},
+ {'amount': 100.0 - 75.00, 'date': fields.Date.to_date('2025-12-01')},
+ ]
+ self.assertRecordValues(budget.item_ids, expected_items)
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_cash_flow_report.py b/dev_odex30_accounting/odex30_account_reports/tests/test_cash_flow_report.py
new file mode 100644
index 0000000..6ef2d08
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_cash_flow_report.py
@@ -0,0 +1,1348 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=C0326
+from .common import TestAccountReportsCommon
+
+from odoo import fields
+from odoo.tests import tagged
+
+from odoo import Command
+
+@tagged('post_install', '-at_install')
+class TestCashFlowReport(TestAccountReportsCommon):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.report = cls.env.ref('odex30_account_reports.cash_flow_report')
+
+ cls.misc_journal = cls.company_data['default_journal_misc']
+ cls.cash_journal = cls.company_data['default_journal_cash']
+ cls.bank_journal = cls.company_data['default_journal_bank']
+
+ cls.account_cash = cls.cash_journal.default_account_id
+ cls.account_bank = cls.bank_journal.default_account_id
+ cls.account_receivable_1 = cls.company_data['default_account_receivable']
+ cls.account_receivable_2 = cls.env['account.account'].create({
+ 'account_type': 'asset_receivable',
+ 'name': 'Account Receivable 2',
+ 'code': '121020',
+ 'reconcile': True,
+ })
+ cls.account_receivable_3 = cls.env['account.account'].create({
+ 'account_type': 'asset_receivable',
+ 'name': 'Account Receivable 3',
+ 'code': '121030',
+ 'reconcile': True,
+ })
+
+ cls.account_no_tag = cls.env['account.account'].create({
+ 'account_type': 'asset_current',
+ 'name': 'account_no_tag',
+ 'code': '121040',
+ 'reconcile': True,
+ })
+ cls.account_financing = cls.env['account.account'].create({
+ 'account_type': 'asset_current',
+ 'name': 'account_financing',
+ 'code': '121050',
+ 'reconcile': True,
+ 'tag_ids': cls.env.ref('account.account_tag_financing'),
+ })
+ cls.account_operating = cls.env['account.account'].create({
+ 'account_type': 'asset_current',
+ 'name': 'account_operating',
+ 'code': '121060',
+ 'reconcile': True,
+ 'tag_ids': cls.env.ref('account.account_tag_operating'),
+ })
+ cls.account_investing = cls.env['account.account'].create({
+ 'account_type': 'asset_current',
+ 'name': 'account_investing',
+ 'code': '121070',
+ 'reconcile': True,
+ 'tag_ids': cls.env.ref('account.account_tag_investing'),
+ })
+
+ def _reconcile_on(self, lines, account):
+ lines.filtered(lambda line: line.account_id == account and not line.reconciled).reconcile()
+
+ def test_growth_comparison(self):
+ """ Enables period comparison and tests the growth comparison column; in order to ensure this feature works on reports with dynamic lines.
+ """
+ self.report.filter_period_comparison = True
+ options = self._generate_options(self.report, fields.Date.from_string('2016-01-01'), fields.Date.from_string('2016-12-31'))
+ options = self._update_comparison_filter(options, self.report, 'previous_period', 1)
+
+ move_1 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2015-01-01',
+ 'journal_id': self.misc_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'account_id': self.account_cash.id}),
+ (0, 0, {'debit': 0.0, 'credit': 200.0, 'account_id': self.account_no_tag.id}),
+ ],
+ })
+ move_1.action_post()
+
+ move_2 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': self.misc_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'account_id': self.account_cash.id}),
+ (0, 0, {'debit': 0.0, 'credit': 2000.0, 'account_id': self.account_no_tag.id}),
+ ],
+ })
+ move_2.action_post()
+
+ self.assertColumnPercentComparisonValues(
+ self.report._get_lines(options),
+ [
+ ('Cash and cash equivalents, beginning of period', 'n/a', 'muted'),
+ ('Net increase in cash and cash equivalents', '900.0%', 'green'),
+ ('Cash flows from operating activities', 'n/a', 'muted'),
+ ('Advance Payments received from customers', 'n/a', 'muted'),
+ ('Cash received from operating activities', 'n/a', 'muted'),
+ ('Advance payments made to suppliers', 'n/a', 'muted'),
+ ('Cash paid for operating activities', 'n/a', 'muted'),
+ ('Cash flows from investing & extraordinary activities', 'n/a', 'muted'),
+ ('Cash in', 'n/a', 'muted'),
+ ('Cash out', 'n/a', 'muted'),
+ ('Cash flows from financing activities', 'n/a', 'muted'),
+ ('Cash in', 'n/a', 'muted'),
+ ('Cash out', 'n/a', 'muted'),
+ ('Cash flows from unclassified activities', '900.0%', 'green'),
+ ('Cash in', '900.0%', 'green'),
+ ('Cash out', 'n/a', 'muted'),
+ ('Cash and cash equivalents, closing balance', '1000.0%', 'green'),
+ ]
+ )
+
+ def test_cash_flow_journals(self):
+ options = self._generate_options(self.report, fields.Date.from_string('2016-01-01'), fields.Date.from_string('2017-01-01'))
+
+ misc_move = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-07-01',
+ 'journal_id': self.misc_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'account_id': self.account_cash.id}),
+ (0, 0, {'debit': 0.0, 'credit': 200.0, 'account_id': self.account_no_tag.id}),
+ ],
+ })
+ misc_move.action_post()
+
+ bank_move = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-07-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 200.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ (0, 0, {'debit': 0.0, 'credit': 200.0, 'account_id': self.account_no_tag.id}),
+ ],
+ })
+ bank_move.action_post()
+
+ cash_move = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-07-01',
+ 'journal_id': self.cash_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 200.0, 'credit': 0.0, 'account_id': self.account_cash.id}),
+ (0, 0, {'debit': 0.0, 'credit': 200.0, 'account_id': self.account_no_tag.id}),
+ ],
+ })
+ cash_move.action_post()
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', 600.0],
+ ['Cash flows from operating activities', 0.0],
+ ['Advance Payments received from customers', 0.0],
+ ['Cash received from operating activities', 0.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from unclassified activities', 600.0],
+ ['Cash in', 600.0],
+ ['Cash out', 0.0],
+ ['Cash and cash equivalents, closing balance', 600.0],
+ ], options)
+
+ # This move should not appear since it does not use a bank or cash account
+ receivable_move = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-07-01',
+ 'journal_id': self.misc_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 200.0, 'credit': 0.0, 'account_id': self.account_receivable_1.id}),
+ (0, 0, {'debit': 0.0, 'credit': 200.0, 'account_id': self.account_no_tag.id}),
+ ],
+ })
+ # Default journal account that hasn't "Bank and Cash" or "Credit Card" type should not appear
+ self.misc_journal.default_account_id = self.account_no_tag
+ receivable_move.action_post()
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', 600.0],
+ ['Cash flows from operating activities', 0.0],
+ ['Advance Payments received from customers', 0.0],
+ ['Cash received from operating activities', 0.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from unclassified activities', 600.0],
+ ['Cash in', 600.0],
+ ['Cash out', 0.0],
+ ['Cash and cash equivalents, closing balance', 600.0],
+ ], options)
+
+ def test_cash_flow_comparison(self):
+ self.report.filter_period_comparison = True
+ self.report.default_opening_date_filter = 'this_year'
+
+ options = self._generate_options(self.report, fields.Date.from_string('2016-01-01'), fields.Date.from_string('2016-12-31'))
+ options = self._update_comparison_filter(options, self.report, comparison_type='previous_period', number_period=1)
+ options['filter_period_comparison'] = True
+
+ # Current period
+ invoice_current_period = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-08',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 230.0, 'credit': 0.0, 'account_id': self.account_receivable_1.id}),
+ (0, 0, {'debit': 0.0, 'credit': 230.0, 'account_id': self.account_no_tag.id}),
+ ],
+ })
+ invoice_current_period.action_post()
+
+ payment_current_period = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-16',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 230.0, 'account_id': self.account_receivable_1.id}),
+ (0, 0, {'debit': 230.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ ],
+ })
+ payment_current_period.action_post()
+
+ self._reconcile_on((invoice_current_period + payment_current_period).line_ids, self.account_receivable_1)
+
+ # Past period
+ invoice_past_period = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2015-01-08',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'account_id': self.account_receivable_1.id}),
+ (0, 0, {'debit': 0.0, 'credit': 100.0, 'account_id': self.account_no_tag.id}),
+ ],
+ })
+ invoice_past_period.action_post()
+
+ payment_past_period = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2015-01-16',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 100.0, 'account_id': self.account_receivable_1.id}),
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ ],
+ })
+ payment_past_period.action_post()
+
+ self._reconcile_on((invoice_past_period + payment_past_period).line_ids, self.account_receivable_1)
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1, 2], [
+ ['Cash and cash equivalents, beginning of period', 100.0, 0.0],
+ ['Net increase in cash and cash equivalents', 230.0, 100.0],
+ ['Cash flows from operating activities', 0.0, 0.0],
+ ['Advance Payments received from customers', 0.0, 0.0],
+ ['Cash received from operating activities', 0.0, 0.0],
+ ['Advance payments made to suppliers', 0.0, 0.0],
+ ['Cash paid for operating activities', 0.0, 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0, 0.0],
+ ['Cash in', 0.0, 0.0],
+ ['Cash out', 0.0, 0.0],
+ ['Cash flows from financing activities', 0.0, 0.0],
+ ['Cash in', 0.0, 0.0],
+ ['Cash out', 0.0, 0.0],
+ ['Cash flows from unclassified activities', 230.0, 100.0],
+ ['Cash in', 230.0, 100.0],
+ ['Cash out', 0.0, 0.0],
+ ['Cash and cash equivalents, closing balance', 330.0, 100.0],
+ ], options)
+
+ def test_cash_flow_column_groups(self):
+ self.report.filter_period_comparison = True
+ options = self._generate_options(self.report, fields.Date.from_string('2016-01-01'), fields.Date.from_string('2016-01-31'))
+ options = self._update_comparison_filter(options, self.report, comparison_type='previous_period', number_period=1)
+ options['filter_period_comparison'] = True
+
+ invoice_current_period = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-08',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 1150.0, 'credit': 0.0, 'account_id': self.account_receivable_1.id}),
+ (0, 0, {'debit': 0.0, 'credit': 150.0, 'account_id': self.account_no_tag.id}),
+ (0, 0, {'debit': 0.0, 'credit': 1000.0, 'account_id': self.account_operating.id}),
+ ],
+ })
+ invoice_current_period.action_post()
+
+ payment_current_period = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-16',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 230.0, 'account_id': self.account_receivable_1.id}),
+ (0, 0, {'debit': 230.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ ],
+ })
+ payment_current_period.action_post()
+
+ self._reconcile_on((invoice_current_period + payment_current_period).line_ids, self.account_receivable_1)
+
+ invoice_previous_period = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2015-12-08',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 575.0, 'credit': 0.0, 'account_id': self.account_receivable_1.id}),
+ (0, 0, {'debit': 0.0, 'credit': 75.0, 'account_id': self.account_no_tag.id}),
+ (0, 0, {'debit': 0.0, 'credit': 500.0, 'account_id': self.account_operating.id}),
+ ],
+ })
+ invoice_previous_period.action_post()
+
+ payment_previous_period = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2015-12-16',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 115.0, 'account_id': self.account_receivable_1.id}),
+ (0, 0, {'debit': 115.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ ],
+ })
+ payment_previous_period.action_post()
+
+ self._reconcile_on((invoice_previous_period + payment_previous_period).line_ids, self.account_receivable_1)
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1, 2], [
+ ['Cash and cash equivalents, beginning of period', 115.0, 0.0],
+ ['Net increase in cash and cash equivalents', 230.0, 115.0],
+ ['Cash flows from operating activities', 200.0, 100.0],
+ ['Advance Payments received from customers', 0.0, 0.0],
+ ['Cash received from operating activities', 200.0, 100.0],
+ ['Advance payments made to suppliers', 0.0, 0.0],
+ ['Cash paid for operating activities', 0.0, 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0, 0.0],
+ ['Cash in', 0.0, 0.0],
+ ['Cash out', 0.0, 0.0],
+ ['Cash flows from financing activities', 0.0, 0.0],
+ ['Cash in', 0.0, 0.0],
+ ['Cash out', 0.0, 0.0],
+ ['Cash flows from unclassified activities', 30.0, 15.0],
+ ['Cash in', 30.0, 15.0],
+ ['Cash out', 0.0, 0.0],
+ ['Cash and cash equivalents, closing balance', 345.0, 115.0],
+ ], options)
+
+ def test_cash_flow_multi_company_multi_currency_unfolding(self):
+ company_data_3 = self.setup_other_company(name="Company 3")
+ options = self._generate_options(self.report, fields.Date.from_string('2016-01-01'), fields.Date.from_string('2017-01-01'))
+ options['unfold_all'] = True
+ self.company_data_2['default_journal_bank'].default_account_id.code = '101411'
+
+ for company_data in (self.company_data, self.company_data_2, company_data_3):
+ account_operating = self.env['account.account'].with_company(company_data['company']).create({
+ 'account_type': 'asset_current',
+ 'name': 'Account Operating',
+ 'code': '121160',
+ 'reconcile': True,
+ 'tag_ids': self.env.ref('account.account_tag_operating'),
+ })
+ invoice = self.env['account.move'].with_company(company_data['company']).create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': company_data['default_journal_bank'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 1150.0, 'credit': 0.0, 'account_id': company_data['default_account_receivable'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 1150.0, 'account_id': account_operating.id}),
+ ],
+ })
+ invoice.action_post()
+ payment = self.env['account.move'].with_company(company_data['company']).create({
+ 'move_type': 'entry',
+ 'date': '2017-01-01',
+ 'journal_id': company_data['default_journal_bank'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 230.0, 'account_id': company_data['default_account_receivable'].id}),
+ (0, 0, {'debit': 230.0, 'credit': 0.0, 'account_id': company_data['default_journal_bank'].default_account_id.id}),
+ ],
+ })
+ payment.action_post()
+ self._reconcile_on((invoice + payment).line_ids, company_data['default_account_receivable'])
+
+ lines = self.report._get_lines(options)
+ self.assertLinesValues(lines, [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', 575.0],
+ ['Cash flows from operating activities', 575.0],
+ ['Advance Payments received from customers', 0.0],
+ ['Cash received from operating activities', 575.0],
+ ['121160 Account Operating', 230.0], # Company 1
+ ['Account Operating', 115.0], # Company 2 (rate 2.0)
+ ['Account Operating', 230.0], # Company 3
+ ['Total Cash received from operating activities', 575.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from unclassified activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash and cash equivalents, closing balance', 575.0],
+ ['101401 Bank', 230.0], # Company 1
+ ['101411 Bank', 115.0], # Company 2 (rate 2.0)
+ ['Bank', 230.0], # Company 3
+ ['Total Cash and cash equivalents, closing balance', 575.0],
+ ], options)
+
+ def test_cash_flow_tricky_case_1(self):
+ ''' Test how the cash flow report is involved:
+ - when reconciling multiple payments.
+ - when dealing with multiple receivable lines.
+ - when dealing with multiple partials on the same line.
+ - When making an advance payment.
+ - when adding entries after the report date.
+ '''
+ options = self._generate_options(self.report, fields.Date.from_string('2016-01-01'), fields.Date.from_string('2017-01-01'))
+
+ # First invoice, two receivable lines on the same account.
+ invoice = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 345.0, 'credit': 0.0, 'account_id': self.account_receivable_1.id}),
+ (0, 0, {'debit': 805.0, 'credit': 0.0, 'account_id': self.account_receivable_1.id}),
+ (0, 0, {'debit': 0.0, 'credit': 150.0, 'account_id': self.account_no_tag.id}),
+ (0, 0, {'debit': 0.0, 'credit': 1000.0, 'account_id': self.account_operating.id}),
+ ],
+ })
+ invoice.action_post()
+
+ # First payment (20% of the invoice).
+ payment_1 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-02-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 230.0, 'account_id': self.account_receivable_1.id}),
+ (0, 0, {'debit': 230.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ ],
+ })
+ payment_1.action_post()
+
+ self._reconcile_on((invoice + payment_1).line_ids, self.account_receivable_1)
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', 230.0],
+ ['Cash flows from operating activities', 200.0],
+ ['Advance Payments received from customers', 0.0],
+ ['Cash received from operating activities', 200.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from unclassified activities', 30.0],
+ ['Cash in', 30.0],
+ ['Cash out', 0.0],
+ ['Cash and cash equivalents, closing balance', 230.0],
+ ], options)
+
+ # Second payment (also 20% but will produce two partials, one on each receivable line).
+ payment_2 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-03-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 230.0, 'account_id': self.account_receivable_1.id}),
+ (0, 0, {'debit': 230.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ ],
+ })
+ payment_2.action_post()
+
+ self._reconcile_on((invoice + payment_2).line_ids, self.account_receivable_1)
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', 460.0],
+ ['Cash flows from operating activities', 400.0],
+ ['Advance Payments received from customers', 0.0],
+ ['Cash received from operating activities', 400.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from unclassified activities', 60.0],
+ ['Cash in', 60.0],
+ ['Cash out', 0.0],
+ ['Cash and cash equivalents, closing balance', 460.0],
+ ], options)
+
+ # Third payment (residual invoice amount + 1000.0).
+ payment_3 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-04-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 1690.0, 'account_id': self.account_receivable_1.id}),
+ (0, 0, {'debit': 1690.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ ],
+ })
+ payment_3.action_post()
+
+ self._reconcile_on((invoice + payment_3).line_ids, self.account_receivable_1)
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', 2150.0],
+ ['Cash flows from operating activities', 2000.0],
+ ['Advance Payments received from customers', 1000.0],
+ ['Cash received from operating activities', 1000.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from unclassified activities', 150.0],
+ ['Cash in', 150.0],
+ ['Cash out', 0.0],
+ ['Cash and cash equivalents, closing balance', 2150.0],
+ ], options)
+
+ # Second invoice.
+ invoice_2 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2018-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'account_id': self.account_receivable_1.id}),
+ (0, 0, {'debit': 0.0, 'credit': 1000.0, 'account_id': self.account_operating.id}),
+ ],
+ })
+ invoice_2.action_post()
+
+ self._reconcile_on((invoice_2 + payment_3).line_ids, self.account_receivable_1)
+
+ # Exceed the report date, should not affect the report.
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', 2150.0],
+ ['Cash flows from operating activities', 2000.0],
+ ['Advance Payments received from customers', 1000.0],
+ ['Cash received from operating activities', 1000.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from unclassified activities', 150.0],
+ ['Cash in', 150.0],
+ ['Cash out', 0.0],
+ ['Cash and cash equivalents, closing balance', 2150.0],
+ ], options)
+
+ options['date']['date_to'] = '2018-01-01'
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', 2150.0],
+ ['Cash flows from operating activities', 2000.0],
+ ['Advance Payments received from customers', 0.0],
+ ['Cash received from operating activities', 2000.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from unclassified activities', 150.0],
+ ['Cash in', 150.0],
+ ['Cash out', 0.0],
+ ['Cash and cash equivalents, closing balance', 2150.0],
+ ], options)
+
+ def test_cash_flow_tricky_case_2(self):
+ ''' Test how the cash flow report is involved:
+ - when dealing with multiple receivable account.
+ - when making reconciliation involving multiple liquidity moves.
+ '''
+ options = self._generate_options(self.report, fields.Date.from_string('2016-01-01'), fields.Date.from_string('2017-01-01'))
+
+ # First liquidity move.
+ liquidity_move_1 = self.env['account.move'].create({
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 800.0, 'credit': 0.0, 'account_id': self.account_receivable_1.id}),
+ (0, 0, {'debit': 0.0, 'credit': 250.0, 'account_id': self.account_receivable_3.id}),
+ (0, 0, {'debit': 0.0, 'credit': 250.0, 'account_id': self.account_no_tag.id}),
+ (0, 0, {'debit': 0.0, 'credit': 300.0, 'account_id': self.account_bank.id}),
+ ],
+ })
+ liquidity_move_1.action_post()
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', -300.0],
+ ['Cash flows from operating activities', -550.0],
+ ['Advance Payments received from customers', -550.0],
+ ['Cash received from operating activities', 0.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from unclassified activities', 250.0],
+ ['Cash in', 250.0],
+ ['Cash out', 0.0],
+ ['Cash and cash equivalents, closing balance', -300.0],
+ ], options)
+
+ # Misc. move to be reconciled at 800 / (1000 + 3000) = 20%.
+
+ misc_move = self.env['account.move'].create({
+ 'date': '2016-02-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 1000.0, 'account_id': self.account_receivable_1.id}),
+ (0, 0, {'debit': 0.0, 'credit': 500.0, 'account_id': self.account_no_tag.id}),
+ (0, 0, {'debit': 4500.0, 'credit': 0.0, 'account_id': self.account_financing.id}),
+ (0, 0, {'debit': 0.0, 'credit': 3000.0, 'account_id': self.account_receivable_2.id}),
+ ],
+ })
+ misc_move.action_post()
+
+ self._reconcile_on((misc_move + liquidity_move_1).line_ids, self.account_receivable_1)
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', -300.0],
+ ['Cash flows from operating activities', 2650.0],
+ ['Advance Payments received from customers', 2650.0],
+ ['Cash received from operating activities', 0.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', -3600.0],
+ ['Cash in', 0.0],
+ ['Cash out', -3600.0],
+ ['Cash flows from unclassified activities', 650.0],
+ ['Cash in', 650.0],
+ ['Cash out', 0.0],
+ ['Cash and cash equivalents, closing balance', -300.0],
+ ], options)
+
+ # Second liquidity move.
+
+ liquidity_move_2 = self.env['account.move'].create({
+ 'date': '2016-03-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 3200.0, 'credit': 0.0, 'account_id': self.account_receivable_2.id}),
+ (0, 0, {'debit': 200.0, 'credit': 0.0, 'account_id': self.account_receivable_3.id}),
+ (0, 0, {'debit': 0.0, 'credit': 400.0, 'account_id': self.account_financing.id}),
+ (0, 0, {'debit': 0.0, 'credit': 3000.0, 'account_id': self.account_bank.id}),
+ ],
+ })
+ liquidity_move_2.action_post()
+
+ self._reconcile_on((misc_move + liquidity_move_2).line_ids, self.account_receivable_2)
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', -3300.0],
+ ['Cash flows from operating activities', -150.0],
+ ['Advance Payments received from customers', -150.0],
+ ['Cash received from operating activities', 0.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', -3875.0],
+ ['Cash in', 400.0],
+ ['Cash out', -4275.0],
+ ['Cash flows from unclassified activities', 725.0],
+ ['Cash in', 725.0],
+ ['Cash out', 0.0],
+ ['Cash and cash equivalents, closing balance', -3300.0],
+ ], options)
+
+ # This should not change the report.
+ self._reconcile_on((liquidity_move_1 + liquidity_move_2).line_ids, self.account_receivable_3)
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', -3300.0],
+ ['Cash flows from operating activities', -150.0],
+ ['Advance Payments received from customers', -150.0],
+ ['Cash received from operating activities', 0.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', -3875.0],
+ ['Cash in', 400.0],
+ ['Cash out', -4275.0],
+ ['Cash flows from unclassified activities', 725.0],
+ ['Cash in', 725.0],
+ ['Cash out', 0.0],
+ ['Cash and cash equivalents, closing balance', -3300.0],
+ ], options)
+
+ def test_cash_flow_tricky_case_3(self):
+ ''' Test how the cash flow report is involved:
+ - when reconciling entries on a not-receivable/payable account.
+ - when dealing with weird liquidity moves.
+ '''
+ move_1 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 500.0, 'account_id': self.account_no_tag.id}),
+ (0, 0, {'debit': 500.0, 'credit': 0.0, 'account_id': self.account_financing.id}),
+ ],
+ })
+
+ move_2 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ (0, 0, {'debit': 0.0, 'credit': 500.0, 'account_id': self.account_financing.id}),
+ (0, 0, {'debit': 0.0, 'credit': 500.0, 'account_id': self.account_financing.id}),
+ ],
+ })
+
+ move_3 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-02-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 500.0, 'account_id': self.account_bank.id}),
+ (0, 0, {'debit': 500.0, 'credit': 0.0, 'account_id': self.account_financing.id}),
+ ],
+ })
+ (move_1 + move_2 + move_3).action_post()
+
+ # Reconcile everything on account_financing.
+ self._reconcile_on((move_1 + move_2 + move_3).line_ids, self.account_financing)
+
+ options = self._generate_options(self.report, fields.Date.from_string('2016-01-01'), fields.Date.from_string('2016-01-01'))
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', 1000.0],
+ ['Cash flows from operating activities', 0.0],
+ ['Advance Payments received from customers', 0.0],
+ ['Cash received from operating activities', 0.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', 500.0],
+ ['Cash in', 500.0],
+ ['Cash out', 0.0],
+ ['Cash flows from unclassified activities', 500.0],
+ ['Cash in', 500.0],
+ ['Cash out', 0.0],
+ ['Cash and cash equivalents, closing balance', 1000.0],
+ ], options)
+
+ options = self._generate_options(self.report, fields.Date.from_string('2016-01-01'), fields.Date.from_string('2016-02-01'))
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', 500.0],
+ ['Cash flows from operating activities', 0.0],
+ ['Advance Payments received from customers', 0.0],
+ ['Cash received from operating activities', 0.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from unclassified activities', 500.0],
+ ['Cash in', 500.0],
+ ['Cash out', 0.0],
+ ['Cash and cash equivalents, closing balance', 500.0],
+ ], options)
+
+ def test_cash_flow_tricky_case_4(self):
+ ''' The difficulty of this case is the liquidity move will pay the misc move at 1000 / 3000 = 1/3.
+ However, you must take care of the sign because the 3000 in credit must become 1000 in debit.
+ '''
+ options = self._generate_options(self.report, fields.Date.from_string('2016-01-01'), fields.Date.from_string('2016-01-01'))
+
+ move_1 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 3000.0, 'account_id': self.account_no_tag.id}),
+ (0, 0, {'debit': 5000.0, 'credit': 0.0, 'account_id': self.account_financing.id}),
+ (0, 0, {'debit': 0.0, 'credit': 1000.0, 'account_id': self.account_financing.id}),
+ (0, 0, {'debit': 0.0, 'credit': 1000.0, 'account_id': self.account_financing.id}),
+ ],
+ })
+
+ move_2 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 1000.0, 'account_id': self.account_bank.id}),
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'account_id': self.account_financing.id}),
+ ],
+ })
+
+ (move_1 + move_2).action_post()
+
+ self._reconcile_on(move_1.line_ids.filtered('credit') + move_2.line_ids, self.account_financing)
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', -1000.0],
+ ['Cash flows from operating activities', 0.0],
+ ['Advance Payments received from customers', 0.0],
+ ['Cash received from operating activities', 0.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from unclassified activities', -1000.0],
+ ['Cash in', 0.0],
+ ['Cash out', -1000.0],
+ ['Cash and cash equivalents, closing balance', -1000.0],
+ ], options)
+
+ def test_cash_flow_tricky_case_5(self):
+ ''' Same as test_cash_flow_tricky_case_4 in credit.'''
+ options = self._generate_options(self.report, fields.Date.from_string('2016-01-01'), fields.Date.from_string('2016-01-01'))
+
+ move_1 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 3000.0, 'credit': 0.0, 'account_id': self.account_no_tag.id}),
+ (0, 0, {'debit': 0.0, 'credit': 5000.0, 'account_id': self.account_financing.id}),
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'account_id': self.account_financing.id}),
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'account_id': self.account_financing.id}),
+ ],
+ })
+
+ move_2 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ (0, 0, {'debit': 0.0, 'credit': 1000.0, 'account_id': self.account_financing.id}),
+ ],
+ })
+
+ (move_1 + move_2).action_post()
+
+ self._reconcile_on(move_1.line_ids.filtered('debit') + move_2.line_ids, self.account_financing)
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', 1000.0],
+ ['Cash flows from operating activities', 0.0],
+ ['Advance Payments received from customers', 0.0],
+ ['Cash received from operating activities', 0.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from unclassified activities', 1000.0],
+ ['Cash in', 1000.0],
+ ['Cash out', 0.0],
+ ['Cash and cash equivalents, closing balance', 1000.0],
+ ], options)
+
+ def test_cash_flow_tricky_case_6(self):
+ ''' Test the additional lines on liquidity moves (e.g. bank fees) are well reported. '''
+ options = self._generate_options(self.report, fields.Date.from_string('2016-01-01'), fields.Date.from_string('2016-01-01'))
+
+ moves = self.env['account.move'].create([
+ {
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 3000.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ (0, 0, {'debit': 0.0, 'credit': 1000.0, 'account_id': self.account_investing.id}),
+ (0, 0, {'debit': 0.0, 'credit': 2000.0, 'account_id': self.account_receivable_2.id}),
+ ],
+ },
+ {
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 3000.0, 'account_id': self.account_bank.id}),
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'account_id': self.account_no_tag.id}),
+ (0, 0, {'debit': 2000.0, 'credit': 0.0, 'account_id': self.account_receivable_1.id}),
+ ],
+ },
+ {
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'account_id': self.account_no_tag.id}),
+ (0, 0, {'debit': 0.0, 'credit': 2000.0, 'account_id': self.account_receivable_2.id}),
+ ],
+ },
+ {
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 1000.0, 'account_id': self.account_bank.id}),
+ (0, 0, {'debit': 0.0, 'credit': 1000.0, 'account_id': self.account_investing.id}),
+ (0, 0, {'debit': 2000.0, 'credit': 0.0, 'account_id': self.account_receivable_1.id}),
+ ],
+ },
+ {
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 4000.0, 'account_id': self.account_receivable_1.id}),
+ (0, 0, {'debit': 4000.0, 'credit': 0.0, 'account_id': self.account_receivable_2.id}),
+ ],
+ },
+ ])
+
+ moves.action_post()
+
+ self._reconcile_on(moves.line_ids, self.account_receivable_1)
+ self._reconcile_on(moves.line_ids, self.account_receivable_2)
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', 0.0],
+ ['Cash flows from operating activities', 0.0],
+ ['Advance Payments received from customers', 0.0],
+ ['Cash received from operating activities', 0.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 2000.0],
+ ['Cash in', 2000.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from unclassified activities', -2000.0],
+ ['Cash in', 0.0],
+ ['Cash out', -2000.0],
+ ['Cash and cash equivalents, closing balance', 0.0],
+ ], options)
+
+ def test_cash_flow_tricky_case_7(self):
+ ''' Test cross reconciliation between liquidity moves with additional lines when the liquidity account
+ is reconcile.
+ '''
+ options = self._generate_options(self.report, fields.Date.from_string('2016-01-01'), fields.Date.from_string('2016-01-01'))
+
+ move_1 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 3000.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ (0, 0, {'debit': 0.0, 'credit': 1000.0, 'account_id': self.account_financing.id}),
+ (0, 0, {'debit': 0.0, 'credit': 2000.0, 'account_id': self.account_receivable_2.id}),
+ ],
+ })
+
+ move_2 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 1500.0, 'account_id': self.account_bank.id}),
+ (0, 0, {'debit': 500.0, 'credit': 0.0, 'account_id': self.account_no_tag.id}),
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'account_id': self.account_receivable_1.id}),
+ ],
+ })
+ (move_1 + move_2).action_post()
+
+ self.account_bank.reconcile = True
+
+ self._reconcile_on((move_1 + move_2).line_ids, self.account_bank)
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', 1500.0],
+ ['Cash flows from operating activities', 1000.0],
+ ['Advance Payments received from customers', 1000.0],
+ ['Cash received from operating activities', 0.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', 1000.0],
+ ['Cash in', 1000.0],
+ ['Cash out', 0.0],
+ ['Cash flows from unclassified activities', -500.0],
+ ['Cash in', 0.0],
+ ['Cash out', -500.0],
+ ['Cash and cash equivalents, closing balance', 1500.0],
+ ], options)
+
+ def test_cash_flow_tricky_case_8(self):
+ ''' Difficulties on this test are:
+ - The liquidity moves are reconciled to move having a total amount of 0.0.
+ - Double reconciliation between the liquidity and the misc moves.
+ - The reconciliations are partials.
+ - There are additional lines on the misc moves.
+ '''
+ options = self._generate_options(self.report, fields.Date.from_string('2016-01-01'), fields.Date.from_string('2016-01-01'))
+
+ move_1 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 100.0, 'account_id': self.account_bank.id}),
+ (0, 0, {'debit': 900.0, 'credit': 0.0, 'account_id': self.account_receivable_2.id}),
+ (0, 0, {'debit': 0.0, 'credit': 400.0, 'account_id': self.account_no_tag.id}),
+ (0, 0, {'debit': 0.0, 'credit': 400.0, 'account_id': self.account_financing.id}),
+ ],
+ })
+
+ move_2 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 500.0, 'credit': 0.0, 'account_id': self.account_no_tag.id}),
+ (0, 0, {'debit': 0.0, 'credit': 500.0, 'account_id': self.account_no_tag.id}),
+ (0, 0, {'debit': 500.0, 'credit': 0.0, 'account_id': self.account_financing.id}),
+ (0, 0, {'debit': 0.0, 'credit': 500.0, 'account_id': self.account_financing.id}),
+ ],
+ })
+ (move_1 + move_2).action_post()
+
+ self._reconcile_on(move_1.line_ids + move_2.line_ids.filtered('debit'), self.account_no_tag)
+ self._reconcile_on(move_1.line_ids + move_2.line_ids.filtered('debit'), self.account_financing)
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', -100.0],
+ ['Cash flows from operating activities', -900.0],
+ ['Advance Payments received from customers', -900.0],
+ ['Cash received from operating activities', 0.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', 400.0],
+ ['Cash in', 400.0],
+ ['Cash out', 0.0],
+ ['Cash flows from unclassified activities', 400.0],
+ ['Cash in', 400.0],
+ ['Cash out', 0.0],
+ ['Cash and cash equivalents, closing balance', -100.0],
+ ], options)
+
+ def test_cash_flow_tricky_case_9(self):
+ ''' Same as test_cash_flow_tricky_case_8 with reversed debit/credit.'''
+ options = self._generate_options(self.report, fields.Date.from_string('2016-01-01'), fields.Date.from_string('2016-01-01'))
+
+ move_1 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ (0, 0, {'debit': 0.0, 'credit': 900.0, 'account_id': self.account_receivable_2.id}),
+ (0, 0, {'debit': 400.0, 'credit': 0.0, 'account_id': self.account_no_tag.id}),
+ (0, 0, {'debit': 400.0, 'credit': 0.0, 'account_id': self.account_financing.id}),
+ ],
+ })
+
+ move_2 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 500.0, 'account_id': self.account_no_tag.id}),
+ (0, 0, {'debit': 500.0, 'credit': 0.0, 'account_id': self.account_no_tag.id}),
+ (0, 0, {'debit': 0.0, 'credit': 500.0, 'account_id': self.account_financing.id}),
+ (0, 0, {'debit': 500.0, 'credit': 0.0, 'account_id': self.account_financing.id}),
+ ],
+ })
+ (move_1 + move_2).action_post()
+
+ self._reconcile_on(move_1.line_ids + move_2.line_ids.filtered('credit'), self.account_no_tag)
+ self._reconcile_on(move_1.line_ids + move_2.line_ids.filtered('credit'), self.account_financing)
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', 100.0],
+ ['Cash flows from operating activities', 900.0],
+ ['Advance Payments received from customers', 900.0],
+ ['Cash received from operating activities', 0.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', -400.0],
+ ['Cash in', 0.0],
+ ['Cash out', -400.0],
+ ['Cash flows from unclassified activities', -400.0],
+ ['Cash in', 0.0],
+ ['Cash out', -400.0],
+ ['Cash and cash equivalents, closing balance', 100.0],
+ ], options)
+
+ def test_cash_flow_handle_multiple_tags(self):
+ ''' Ensure that the balances are correct in the following situations:
+ - when several non-cash-flow-report account tags are set
+ - when a mix of several non-cash-flow-report tags and one cash-flow report tag are set.
+ '''
+
+ options = self._generate_options(self.report, fields.Date.from_string('2016-01-01'), fields.Date.from_string('2016-01-01'))
+
+ unrelated_tag_1 = self.env['account.account.tag'].create({
+ 'name': 'Unrelated Tag 1',
+ 'applicability': 'accounts',
+ })
+ unrelated_tag_2 = self.env['account.account.tag'].create({
+ 'name': 'Unrelated Tag 2',
+ 'applicability': 'accounts',
+ })
+
+ # account_no_tag will now have 2 tags unrelated to the Cash Flow Report.
+ # account_financing will now have the `account.account_tag_financing` tag, plus the two unrelated tags.
+ (self.account_no_tag + self.account_financing).write({
+ 'tag_ids': [Command.link(unrelated_tag_1.id), Command.link(unrelated_tag_2.id)],
+ })
+
+ move = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': self.bank_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 800.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ (0, 0, {'debit': 0.0, 'credit': 300.0, 'account_id': self.account_no_tag.id}),
+ (0, 0, {'debit': 0.0, 'credit': 500.0, 'account_id': self.account_financing.id}),
+ ],
+ })
+
+ move.action_post()
+
+ self.assertLinesValues(self.report._get_lines(options), [0, 1], [
+ ['Cash and cash equivalents, beginning of period', 0.0],
+ ['Net increase in cash and cash equivalents', 800.0],
+ ['Cash flows from operating activities', 0.0],
+ ['Advance Payments received from customers', 0.0],
+ ['Cash received from operating activities', 0.0],
+ ['Advance payments made to suppliers', 0.0],
+ ['Cash paid for operating activities', 0.0],
+ ['Cash flows from investing & extraordinary activities', 0.0],
+ ['Cash in', 0.0],
+ ['Cash out', 0.0],
+ ['Cash flows from financing activities', 500.0],
+ ['Cash in', 500.0],
+ ['Cash out', 0.0],
+ ['Cash flows from unclassified activities', 300.0],
+ ['Cash in', 300.0],
+ ['Cash out', 0.0],
+ ['Cash and cash equivalents, closing balance', 800.0],
+ ], options)
+
+ def test_cash_flow_hierarchy(self):
+ """ Test the 'hierarchy' option. I.e. we want to ensure that each section of the report (e.g. "Cash and cash equivalents, beginning of period" and "Cash and cash equivalents, closing balance") has its own dedicated hierarchy and they are not mixed up together.
+ """
+ options = self._generate_options(self.report, '2016-01-01', '2016-12-31')
+ self.env.company.totals_below_sections = True
+
+ # Create the account groups for the bank and cash accounts
+ self.env['account.group'].create([
+ {'name': 'Group Bank & Cash', 'code_prefix_start': '10', 'code_prefix_end': '10'},
+ {'name': 'Group Bank', 'code_prefix_start': '1014', 'code_prefix_end': '1014'},
+ {'name': 'Group Cash', 'code_prefix_start': '1015', 'code_prefix_end': '1015'},
+ ])
+ self.account_bank.code = "10140499"
+ self.account_cash.code = "10150199"
+
+ # Create opening balance
+ self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2015-01-01',
+ 'journal_id': self.misc_journal.id,
+ 'line_ids': [
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'account_id': self.account_bank.id}),
+ (0, 0, {'debit': 50.0, 'credit': 0.0, 'account_id': self.account_cash.id}),
+ (0, 0, {'debit': 0.0, 'credit': 150.0, 'account_id': self.account_no_tag.id}),
+ ],
+ }).action_post()
+
+ # Test the report with the hierarchy option disabled
+ # To make sure that the lines are in the right place we also check their 'level'
+ options['hierarchy'] = False
+ lines_wo_hierarchy = [
+ {
+ 'name': line['name'],
+ 'level': line['level'],
+ 'book_value': line['columns'][-1]['name']
+ }
+ for line in self.report._get_lines(options)
+ ]
+ expected_values_wo_hierarchy = [
+ {'name': "Cash and cash equivalents, beginning of period", 'level': 0, 'book_value': '$\xa0150.00'},
+ {'name': "10140499 Bank", 'level': 1, 'book_value': '$\xa0100.00'},
+ {'name': "10150199 Cash", 'level': 1, 'book_value': '$\xa050.00'},
+ {'name': "Total Cash and cash equivalents, beginning of period", 'level': 1, 'book_value': '$\xa0150.00'},
+ {'name': "Net increase in cash and cash equivalents", 'level': 0, 'book_value': '$\xa00.00'},
+ {'name': "Cash flows from operating activities", 'level': 2, 'book_value': '$\xa00.00'},
+ {'name': "Advance Payments received from customers", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Cash received from operating activities", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Advance payments made to suppliers", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Cash paid for operating activities", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Cash flows from investing & extraordinary activities", 'level': 2, 'book_value': '$\xa00.00'},
+ {'name': "Cash in", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Cash out", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Cash flows from financing activities", 'level': 2, 'book_value': '$\xa00.00'},
+ {'name': "Cash in", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Cash out", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Cash flows from unclassified activities", 'level': 2, 'book_value': '$\xa00.00'},
+ {'name': "Cash in", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Cash out", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Cash and cash equivalents, closing balance", 'level': 0, 'book_value': '$\xa0150.00'},
+ {'name': "10140499 Bank", 'level': 1, 'book_value': '$\xa0100.00'},
+ {'name': "10150199 Cash", 'level': 1, 'book_value': '$\xa050.00'},
+ {'name': "Total Cash and cash equivalents, closing balance", 'level': 1, 'book_value': '$\xa0150.00'}
+ ]
+ # assertEqual is used and not assertLinesValues because we want to check the 'level'
+ self.assertEqual(len(lines_wo_hierarchy), len(expected_values_wo_hierarchy))
+ self.assertEqual(lines_wo_hierarchy, expected_values_wo_hierarchy)
+
+ # Test the report with the hierarchy option enabled
+ # To make sure that the lines are in the right place we also check their 'level'
+ options['hierarchy'] = True
+ lines = [
+ {
+ 'name': line['name'],
+ 'level': line['level'],
+ 'book_value': line['columns'][-1]['name']
+ }
+ for line in self.report._get_lines(options)
+ ]
+ expected_values = [
+ {'name': "Cash and cash equivalents, beginning of period", 'level': 0, 'book_value': '$\xa0150.00'},
+ {'name': "10 Group Bank & Cash", 'level': 1, 'book_value': '$\xa0150.00'},
+ {'name': "1014 Group Bank", 'level': 2, 'book_value': '$\xa0100.00'},
+ {'name': "10140499 Bank", 'level': 3, 'book_value': '$\xa0100.00'},
+ {'name': "Total 1014 Group Bank", 'level': 2, 'book_value': '$\xa0100.00'},
+ {'name': "1015 Group Cash", 'level': 2, 'book_value': '$\xa050.00'},
+ {'name': "10150199 Cash", 'level': 3, 'book_value': '$\xa050.00'},
+ {'name': "Total 1015 Group Cash", 'level': 2, 'book_value': '$\xa050.00'},
+ {'name': "Total 10 Group Bank & Cash", 'level': 1, 'book_value': '$\xa0150.00'},
+ {'name': "Total Cash and cash equivalents, beginning of period", 'level': 1, 'book_value': '$\xa0150.00'},
+ {'name': "Net increase in cash and cash equivalents", 'level': 0, 'book_value': '$\xa00.00'},
+ {'name': "Cash flows from operating activities", 'level': 2, 'book_value': '$\xa00.00'},
+ {'name': "Advance Payments received from customers", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Cash received from operating activities", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Advance payments made to suppliers", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Cash paid for operating activities", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Cash flows from investing & extraordinary activities", 'level': 2, 'book_value': '$\xa00.00'},
+ {'name': "Cash in", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Cash out", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Cash flows from financing activities", 'level': 2, 'book_value': '$\xa00.00'},
+ {'name': "Cash in", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Cash out", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Cash flows from unclassified activities", 'level': 2, 'book_value': '$\xa00.00'},
+ {'name': "Cash in", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Cash out", 'level': 4, 'book_value': '$\xa00.00'},
+ {'name': "Cash and cash equivalents, closing balance", 'level': 0, 'book_value': '$\xa0150.00'},
+ {'name': "10 Group Bank & Cash", 'level': 1, 'book_value': '$\xa0150.00'},
+ {'name': "1014 Group Bank", 'level': 2, 'book_value': '$\xa0100.00'},
+ {'name': "10140499 Bank", 'level': 3, 'book_value': '$\xa0100.00'},
+ {'name': "Total 1014 Group Bank", 'level': 2, 'book_value': '$\xa0100.00'},
+ {'name': "1015 Group Cash", 'level': 2, 'book_value': '$\xa050.00'},
+ {'name': "10150199 Cash", 'level': 3, 'book_value': '$\xa050.00'},
+ {'name': "Total 1015 Group Cash", 'level': 2, 'book_value': '$\xa050.00'},
+ {'name': "Total 10 Group Bank & Cash", 'level': 1, 'book_value': '$\xa0150.00'},
+ {'name': "Total Cash and cash equivalents, closing balance", 'level': 1, 'book_value': '$\xa0150.00'}
+ ]
+ # assertEqual is used and not assertLinesValues because we want to check the 'level'
+ self.assertEqual(len(lines), len(expected_values))
+ self.assertEqual(lines, expected_values)
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_currency_table.py b/dev_odex30_accounting/odex30_account_reports/tests/test_currency_table.py
new file mode 100644
index 0000000..361765a
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_currency_table.py
@@ -0,0 +1,455 @@
+from odoo import Command
+from odoo.tests import tagged
+
+from .common import TestAccountReportsCommon
+
+
+@tagged('post_install', '-at_install')
+class TestCurrencyTable(TestAccountReportsCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.company_data['company'].write({'name': "USD Company 1", 'sequence': 1, 'totals_below_sections': False})
+ cls.company_usd_data = cls.company_data
+ # Create additional companies (also adding them to env.companies)
+ cls.company_usd_data_2 = cls.setup_other_company(name="USD Company 2", sequence=2, currency_id=cls.env.ref('base.USD').id)
+ cls.company_eur_data = cls.setup_other_company(name="EUR Company 1", sequence=3, currency_id=cls.env.ref('base.EUR').id)
+ cls.company_eur_data_2 = cls.setup_other_company(name="EUR Company 2", sequence=4, currency_id=cls.env.ref('base.EUR').id)
+ cls.company_chf_data = cls.setup_other_company(name="CHF Company", sequence=5, currency_id=cls.env.ref('base.CHF').id)
+ cls.company_mxn_data = cls.setup_other_company(name="MXN Company", sequence=5, currency_id=cls.env.ref('base.MXN').id)
+
+ # Add equity account to the company data
+ for company_data in (cls.company_usd_data, cls.company_usd_data_2, cls.company_eur_data, cls.company_eur_data_2, cls.company_chf_data, cls.company_mxn_data):
+ company_data['equity_account'] = cls.env['account.account'].search([
+ ('company_ids', '=', company_data['company'].id),
+ ('account_type', '=', 'equity'),
+ ], limit=1)
+
+ cls.report = cls.env['account.report'].create({
+ 'name': "Currency Table Test",
+ 'filter_multi_company': 'selector',
+ 'currency_translation': 'cta',
+ 'column_ids': [Command.create({'name': "Balance", 'expression_label': 'balance'})],
+ 'line_ids': [
+ Command.create({
+ 'name': "Asset",
+ 'groupby': 'company_id',
+ 'expression_ids': [
+ Command.create({
+ 'label': 'balance',
+ 'engine': 'domain',
+ 'formula': "[('account_id.internal_group', '=', 'asset')]",
+ 'subformula': "sum",
+ }),
+ ],
+ }),
+ Command.create({
+ 'name': "Income",
+ 'groupby': 'company_id',
+ 'expression_ids': [
+ Command.create({
+ 'label': 'balance',
+ 'engine': 'domain',
+ 'formula': "[('account_id.internal_group', '=', 'income')]",
+ 'subformula': "sum",
+ }),
+ ],
+ }),
+ Command.create({
+ 'name': "Equity",
+ 'groupby': 'company_id',
+ 'expression_ids': [
+ Command.create({
+ 'label': 'balance',
+ 'engine': 'domain',
+ 'formula': "[('account_id.internal_group', '=', 'equity')]",
+ 'subformula': "sum",
+ }),
+ ],
+ }),
+ ],
+ })
+
+ def _generate_equity_move(self, company_data, date, amount):
+ rslt = self.env['account.move'].create({
+ 'company_id': company_data['company'].id,
+ 'date': date,
+ 'line_ids': [
+ Command.create({
+ 'debit': 0,
+ 'credit': amount,
+ 'account_id': company_data['default_account_expense'].id,
+ }),
+ Command.create({
+ 'debit': amount,
+ 'credit': 0,
+ 'account_id': company_data['equity_account'].id,
+ }),
+ ],
+ })
+ rslt.action_post()
+ return rslt
+
+ def test_currency_table_multicurrency(self):
+ # USD OPERATIONS (domestic currency)
+ self.init_invoice('out_invoice', company=self.company_usd_data['company'], invoice_date='2020-05-12', amounts=[10], post=True)
+ self.init_invoice('out_invoice', company=self.company_usd_data_2['company'], invoice_date='2020-08-23', amounts=[25], post=True)
+ self._generate_equity_move(self.company_usd_data, '2020-11-11', 42)
+ self._generate_equity_move(self.company_usd_data, '2020-12-01', 88)
+ self._generate_equity_move(self.company_usd_data_2, '2020-02-01', 11)
+
+ # EUR OPERATIONS
+ self.setup_other_currency('EUR', rates=[('2018-10-10', 7), ('2019-12-22', 11), ('2020-01-01', 2), ('2020-03-12', 5), ('2020-03-30', 20), ('2020-04-01', 4)])
+
+ self.init_invoice('out_invoice', company=self.company_eur_data['company'], invoice_date='2020-01-15', amounts=[23], post=True)
+ self.init_invoice('out_invoice', company=self.company_eur_data['company'], invoice_date='2020-02-20', amounts=[64], post=True)
+ self.init_invoice('out_invoice', company=self.company_eur_data['company'], invoice_date='2020-03-30', amounts=[100], post=True)
+ self._generate_equity_move(self.company_eur_data, '2020-03-15', 20)
+ self._generate_equity_move(self.company_eur_data, '2020-03-31', 5)
+
+ self.init_invoice('out_invoice', company=self.company_eur_data_2['company'], invoice_date='2020-03-14', amounts=[54], post=True)
+ self.init_invoice('out_invoice', company=self.company_eur_data_2['company'], invoice_date='2020-04-10', amounts=[77], post=True)
+ self._generate_equity_move(self.company_eur_data_2, '2020-05-21', 40)
+
+ # CHF OPERATIONS
+ self.setup_other_currency('CHF', rates=[('2018-03-01', 10), ('2019-01-01', 3), ('2020-03-30', 7), ('2020-05-10', 8), ('2020-12-25', 2)])
+
+ self.init_invoice('out_invoice', company=self.company_chf_data['company'], invoice_date='2020-01-16', amounts=[58], post=True)
+ self.init_invoice('out_invoice', company=self.company_chf_data['company'], invoice_date='2020-05-01', amounts=[99], post=True)
+ self.init_invoice('out_invoice', company=self.company_chf_data['company'], invoice_date='2020-12-31', amounts=[22], post=True)
+ self._generate_equity_move(self.company_chf_data, '2020-03-15', 20)
+
+ # MXN OPERATIONS
+ self.setup_other_currency('MXN', rates=[('2020-05-10', 2)])
+ self.init_invoice('out_invoice', company=self.company_mxn_data['company'], invoice_date='2020-01-01', amounts=[10], post=True)
+ self.init_invoice('out_invoice', company=self.company_mxn_data['company'], invoice_date='2020-07-01', amounts=[21], post=True)
+ self._generate_equity_move(self.company_mxn_data, '2020-03-15', 2)
+
+ # Test conversion : date range cta
+ self.report.currency_translation = 'cta'
+ cta_options_range = self._generate_options(self.report, '2019-12-22', '2020-12-31')
+ self.assertLinesValues(
+ self.report._get_lines(cta_options_range),
+ [ 0, 1],
+ [
+ ("Asset", 219.50),
+ ("USD Company 1", 10.00),
+ ("USD Company 2", 25.00),
+ # EUR current rate = 1/4
+ ("EUR Company 1", 46.75), # (23 + 64 + 100) / 4
+ ("EUR Company 2", 32.75), # (54 + 77) / 4
+ # CHF current rate = 1/2
+ ("CHF Company", 89.50), # (58 + 99 + 22) / 2
+ ("MXN Company", 15.50), # (10 + 21) / 2
+ ("Income", -182.13),
+ ("USD Company 1", -10.00),
+ ("USD Company 2", -25.00),
+ # EUR average rate = (1/11 * 10 + 1/2 * 71 + 1/5 * 18 + 1/20 * 2 + 1/4 * 275) / 376 = 0.289518859
+ ("EUR Company 1", -54.14), # (-23 - 64 - 100) * 0.289518859
+ ("EUR Company 2", -37.93), # (-54 - 77) * 0.289518859
+ # CHF average rate = (1/3 * 99 + 1/7 * 41 + 1/8 * 229 + 1/2 * 7) / 376 = 0.188782295
+ ("CHF Company", -33.79), # (-58 - 99 -22) * 0.188782295
+ # MXN average rate = (1 * 140 + 1/2 * 236) / 376 = 0.686170213
+ ("MXN Company", -21.27), # (-10 - 21) * 0.686170213
+ ("Equity", 163.92),
+ ("USD Company 1", 130.00),
+ ("USD Company 2", 11.00),
+ ("EUR Company 1", 4.25), # 20 / 5 + 5 / 20
+ ("EUR Company 2", 10.00), # 40 / 4
+ ("CHF Company", 6.67), # 20 / 3
+ ("MXN Company", 2.00), # 2 / 1
+ ],
+ cta_options_range,
+ )
+
+ # Test conversion : single cta
+ self.report.currency_translation = 'cta'
+ self.report.filter_date_range = False
+ cta_options_single = self._generate_options(self.report, '2020-12-31', '2020-12-31')
+
+ self.assertLinesValues(
+ self.report._get_lines(cta_options_single),
+ [ 0, 1],
+ [
+ ("Asset", 219.50),
+ ("USD Company 1", 10.00),
+ ("USD Company 2", 25.00),
+ # EUR current rate = 1/4
+ ("EUR Company 1", 46.75), # (23 + 64 + 100) / 4
+ ("EUR Company 2", 32.75), # (54 + 77) / 4
+ # CHF current rate = 1/2
+ ("CHF Company", 89.50), # (58 + 99 + 22) / 2
+ # MXN current rate = 1/2
+ ("MXN Company", 15.50), # (10 + 21) / 2
+ ("Income", -182.88),
+ ("USD Company 1", -10.00),
+ ("USD Company 2", -25.00),
+ # EUR average rate = (1/2 * 71 + 1/5 * 18 + 1/20 * 2 + 1/4 * 275) / 366 = 0.294945355
+ ("EUR Company 1", -55.15), # (-23 - 64 - 100) * 0.294945355
+ ("EUR Company 2", -38.64), # (-54 - 77) * 0.294945355
+ # CHF average rate = (1/3 * 89 + 1/7 * 41 + 1/8 * 229 + 1/2 * 7) / 366 = 0.184832813
+ ("CHF Company", -33.09), # (-58 - 99 -22) * 0.184832813
+ # MXN average rate = (1 * 130 + 1/2 * 236) / 366 = 0.677595628
+ ("MXN Company", -21.01), # (-10 - 21) * 0.677595628
+ ("Equity", 163.92),
+ ("USD Company 1", 130.00),
+ ("USD Company 2", 11.00),
+ ("EUR Company 1", 4.25), # 20 / 5 + 5 / 20
+ ("EUR Company 2", 10.00), # 40 / 4
+ ("CHF Company", 6.67), # 20 / 3
+ ("MXN Company", 2.00), # 2 / 1
+ ],
+ cta_options_single,
+ )
+
+ # Test conversion : current : date range and single
+ current_expected_lines = [
+ # EUR current rate = 1/4
+ # CHF current rate = 1/2
+ ("Asset", 219.50),
+ ("USD Company 1", 10.00),
+ ("USD Company 2", 25.00),
+ ("EUR Company 1", 46.75), # (23 + 64 + 100) / 4
+ ("EUR Company 2", 32.75), # (54 + 77) / 4
+ ("CHF Company", 89.50), # (58 + 99 + 22) / 2
+ ("MXN Company", 15.50), # (10 + 21) / 2
+ ("Income", -219.50),
+ ("USD Company 1", -10.00),
+ ("USD Company 2", -25.00),
+ ("EUR Company 1", -46.75), # (-23 - 64 - 100) / 4
+ ("EUR Company 2", -32.75), # (-54 - 77) / 4
+ ("CHF Company", -89.50), # (-58 - 99 -22) / 2
+ ("MXN Company", -15.50), # (-10 - 21) / 2
+ ("Equity", 168.25),
+ ("USD Company 1", 130.00),
+ ("USD Company 2", 11.00),
+ ("EUR Company 1", 6.25), # (20 + 5) / 4
+ ("EUR Company 2", 10.00), # 40 / 4
+ ("CHF Company", 10.00), # 20 / 2
+ ("MXN Company", 1.00), # 2 / 2
+ ]
+
+ self.report.currency_translation = 'current'
+
+ current_options_range = self._generate_options(self.report, '2020-01-01', '2020-12-31')
+ self.assertLinesValues(
+ self.report._get_lines(current_options_range),
+ [0, 1],
+ current_expected_lines,
+ current_options_range,
+ )
+
+ self.report.filter_date_range = False
+ current_options_single = self._generate_options(self.report, '2020-12-31', '2020-12-31')
+ self.assertLinesValues(
+ self.report._get_lines(current_options_single),
+ [0, 1],
+ current_expected_lines,
+ current_options_single,
+ )
+
+ def test_currency_table_monocurrency(self):
+ self.init_invoice('out_invoice', company=self.company_usd_data['company'], invoice_date='2020-01-01', amounts=[63], post=True)
+ self.init_invoice('out_invoice', company=self.company_usd_data_2['company'], invoice_date='2020-01-01', amounts=[42], post=True)
+ self._generate_equity_move(self.company_usd_data, '2020-11-11', 88)
+ self._generate_equity_move(self.company_usd_data_2, '2020-11-11', 92)
+
+ self.env.companies = self.company_usd_data['company'] + self.company_usd_data_2['company']
+ options = self._generate_options(self.report, '2020-01-01', '2020-12-31')
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ [ 0, 1],
+ [
+ ("Asset", 105.00),
+ ("USD Company 1", 63.00),
+ ("USD Company 2", 42.00),
+ ("Income", -105.00),
+ ("USD Company 1", -63.00),
+ ("USD Company 2", -42.00),
+ ("Equity", 180.00),
+ ("USD Company 1", 88.00),
+ ("USD Company 2", 92.00),
+ ],
+ options,
+ )
+
+ def test_currency_table_comparison(self):
+ # EUR operations
+ self.setup_other_currency('EUR', rates=[('2018-01-01', 7), ('2019-01-01', 2), ('2020-03-12', 5), ('2021-03-30', 20), ('2022-04-01', 4)])
+
+ self.init_invoice('out_invoice', company=self.company_eur_data['company'], invoice_date='2019-01-01', amounts=[14], post=True)
+ self.init_invoice('out_invoice', company=self.company_eur_data['company'], invoice_date='2020-01-01', amounts=[24], post=True)
+ self._generate_equity_move(self.company_eur_data, '2019-11-11', 55)
+ self._generate_equity_move(self.company_eur_data, '2020-11-11', 99)
+
+ # CHF operations
+ self.setup_other_currency('CHF', rates=[('2018-01-01', 10), ('2019-01-01', 4), ('2019-03-12', 5), ('2021-03-30', 20), ('2022-04-01', 4)])
+
+ self.init_invoice('out_invoice', company=self.company_chf_data['company'], invoice_date='2019-01-01', amounts=[20], post=True)
+ self.init_invoice('out_invoice', company=self.company_chf_data['company'], invoice_date='2020-01-01', amounts=[66], post=True)
+ self._generate_equity_move(self.company_chf_data, '2019-11-11', 12)
+ self._generate_equity_move(self.company_chf_data, '2020-11-11', 84)
+
+ options = self._generate_options(self.report, '2020-01-01', '2020-12-31')
+ options = self._update_comparison_filter(options, self.report, 'previous_period', 1)
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # 2020 2019
+ [ 0, 1, 2],
+ [
+ ("Asset", 18.00, 11.00),
+ # EUR current rate: 2020 = 1/5 ; 2019 = 1/2
+ ("EUR Company 1", 4.80, 7.00),
+ # CHF current rate: 2020 = 1/5 ; 2019 = 1/5
+ ("CHF Company", 13.20, 4.00),
+ ("Income", -19.40, -11.19),
+ # EUR average rate in 2020: (1/2 * 71 + 1/5 * 295) / 366 = 0.258196721
+ # EUR average rate in 2019: 1/2
+ ("EUR Company 1", -6.20, -7.00),
+ # CHF average rate in 2020: 1/5
+ # CHF average rate in 2019: (1/4 * 70 + 1/5 * 295) / 365 = 0.209589041
+ ("CHF Company", -13.20, -4.19),
+ ("Equity", 36.60, 29.90),
+ ("EUR Company 1", 19.80, 27.50),
+ ("CHF Company", 16.80, 2.40),
+ ],
+ options,
+ )
+
+ def test_currency_table_branches(self):
+ usd_branch_data = self.setup_other_company(
+ name='USD Branch',
+ parent_id=self.company_usd_data['company'].id,
+ sequence=self.company_usd_data['company'].sequence,
+ )
+
+ eur_branch_data = self.setup_other_company(
+ name='EUR Branch',
+ parent_id=self.company_eur_data['company'].id,
+ sequence=self.company_eur_data['company'].sequence,
+ )
+
+ usd_branch_data['company'].totals_below_sections = False
+
+ # Add equity accounts to branch data
+ usd_branch_data['equity_account'] = self.company_usd_data['equity_account']
+ eur_branch_data['equity_account'] = self.company_eur_data['equity_account']
+
+ # Create rates on the root USD company
+ self.setup_other_currency('EUR', rates=[('2020-01-01', 4), ('2020-03-01', 2), ('2020-12-01', 5)])
+
+ # Create some entries
+ self.init_invoice('out_invoice', company=self.company_usd_data['company'], invoice_date='2020-11-01', amounts=[10], post=True)
+ self.init_invoice('out_invoice', company=usd_branch_data['company'], invoice_date='2020-09-01', amounts=[20], post=True)
+ self.init_invoice('out_invoice', company=self.company_eur_data['company'], invoice_date='2020-06-25', amounts=[30], post=True)
+ self.init_invoice('out_invoice', company=eur_branch_data['company'], invoice_date='2020-02-23', amounts=[40], post=True)
+
+ self._generate_equity_move(self.company_usd_data, '2020-11-12', 50)
+ self._generate_equity_move(usd_branch_data, '2020-01-21', 60)
+ self._generate_equity_move(self.company_eur_data, '2020-05-12', 70)
+ self._generate_equity_move(eur_branch_data, '2020-09-09', 80)
+
+ # env.company becomes the USD branch
+ self.env.companies = usd_branch_data['company'] + eur_branch_data['company'] + self.company_usd_data['company'] + self.company_eur_data['company']
+ self.env.company = usd_branch_data['company']
+
+ options = self._generate_options(self.report, '2020-01-01', '2020-12-31')
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ [ 0, 1],
+ [
+ ("Asset", 44.00),
+ ("USD Branch", 20.00),
+ ("USD Company 1", 10.00),
+ # EUR current rate = 1/5
+ ("EUR Branch", 8.00),
+ ("EUR Company 1", 6.00),
+ ("Income", -60.35),
+ ("USD Branch", -20.00),
+ ("USD Company 1", -10.00),
+ # EUR average rate = (1/4 * 60 + 1/2 * 275 + 1/5 * 31) / 366 = 0.433606557
+ ("EUR Branch", -17.34),
+ ("EUR Company 1", -13.01),
+ ("Equity", 185.00),
+ ("USD Branch", 60.00),
+ ("USD Company 1", 50.00),
+ ("EUR Branch", 40.00),
+ ("EUR Company 1", 35.00),
+
+ ],
+ options,
+ )
+
+ def test_currency_table_no_rate(self):
+ self.init_invoice('out_invoice', company=self.company_usd_data['company'], invoice_date='2020-11-01', amounts=[10], post=True)
+ self._generate_equity_move(self.company_usd_data, '2020-11-12', 20)
+ self.init_invoice('out_invoice', company=self.company_eur_data['company'], invoice_date='2020-02-23', amounts=[30], post=True)
+ self._generate_equity_move(self.company_eur_data, '2020-05-12', 40)
+
+ options = self._generate_options(self.report, '2020-01-01', '2020-12-31')
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ [ 0, 1],
+ [
+ ("Asset", 40.00),
+ ("USD Company 1", 10.00),
+ ("EUR Company 1", 30.00),
+ ("Income", -40.00),
+ ("USD Company 1", -10.00),
+ ("EUR Company 1", -30.00),
+ ("Equity", 60.00),
+ ("USD Company 1", 20.00),
+ ("EUR Company 1", 40.00),
+ ],
+ options,
+ )
+
+ def test_currency_table_non_1_domestic_rate(self):
+ self.setup_other_currency('USD', rates=[('2020-01-01', 0.5)])
+ self.setup_other_currency('EUR', rates=[('2020-01-01', 4), ('2020-03-01', 2), ('2020-12-01', 5)])
+
+ self.init_invoice('out_invoice', company=self.company_usd_data['company'], invoice_date='2020-11-01', amounts=[10], post=True)
+ self._generate_equity_move(self.company_usd_data, '2020-11-12', 20)
+
+ self.init_invoice('out_invoice', company=self.company_eur_data['company'], invoice_date='2020-02-23', amounts=[30], post=True)
+ self._generate_equity_move(self.company_eur_data, '2020-05-12', 40)
+
+ options = self._generate_options(self.report, '2020-01-01', '2020-12-31')
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ [ 0, 1],
+ [
+ ("Asset", 13.00),
+ ("USD Company 1", 10.00),
+ # EUR current rate = 0.5/5
+ ("EUR Company 1", 3.00),
+ ("Income", -16.50),
+ ("USD Company 1", -10.00),
+ # EUR average rate = (0.5/4 * 60 + 0.5/2 * 275 + 0.5/5 * 31) / 366 = 0.216803279
+ ("EUR Company 1", -6.50),
+ ("Equity", 30.00),
+ ("USD Company 1", 20.00),
+ ("EUR Company 1", 10.00), # rate = 0.5/2
+ ],
+ options,
+ )
+
+ def test_currency_manual_line_expansion(self):
+ self.setup_other_currency('EUR', rates=[('2020-01-01', 2)])
+ self.init_invoice('out_invoice', company=self.company_eur_data['company'], invoice_date='2020-12-22', amounts=[42], post=True)
+
+ self.report.line_ids.foldable = True
+
+ options = self._generate_options(self.report, '2020-01-01', '2020-12-31')
+ line_to_expand_id = self.report._get_generic_line_id('account.report.line', self.report.line_ids[0].id)
+ self.assertLinesValues(
+ self.report.get_expanded_lines(options, line_to_expand_id, 'company_id', '_report_expand_unfoldable_line_with_groupby', 0, 0, None),
+ [ 0, 1],
+ [
+ ("EUR Company 1", 21.00),
+ ],
+ options,
+ )
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_deferred_reports.py b/dev_odex30_accounting/odex30_account_reports/tests/test_deferred_reports.py
new file mode 100644
index 0000000..3d7a25c
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_deferred_reports.py
@@ -0,0 +1,2040 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=C0326
+from freezegun import freeze_time
+
+from odoo.addons.odex30_account_reports.tests.common import TestAccountReportsCommon
+from odoo import fields, Command
+from odoo.exceptions import UserError
+from odoo.tests import tagged, HttpCase, Form
+
+
+@tagged('post_install', '-at_install')
+class TestDeferredReports(TestAccountReportsCommon, HttpCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.deferred_expense_report = cls.env.ref('odex30_account_reports.deferred_expense_report')
+ cls.deferred_revenue_report = cls.env.ref('odex30_account_reports.deferred_revenue_report')
+ cls.handler = cls.env['account.deferred.expense.report.handler']
+
+ cls.expense_accounts = [cls.env['account.account'].create({
+ 'name': f'Expense {i}',
+ 'code': f'EXP{i}',
+ 'account_type': 'expense',
+ }) for i in range(10)]
+ cls.revenue_accounts = [cls.env['account.account'].create({
+ 'name': f'Revenue {i}',
+ 'code': f'REV{i}',
+ 'account_type': 'income',
+ }) for i in range(3)]
+
+ cls.company = cls.company_data['company']
+ cls.deferral_account = cls.company_data['default_account_deferred_expense']
+ cls.company.deferred_expense_journal_id = cls.company_data['default_journal_misc'].id
+ cls.company.deferred_revenue_journal_id = cls.company_data['default_journal_misc'].id
+ cls.company.deferred_expense_account_id = cls.company_data['default_account_deferred_expense'].id
+ cls.company.deferred_revenue_account_id = cls.company_data['default_account_deferred_revenue'].id
+
+ cls.expense_lines = [
+ [cls.expense_accounts[0], 1000, '2023-01-01', '2023-04-30'], # 4 full months (=250/month)
+ [cls.expense_accounts[0], 1050, '2023-01-16', '2023-04-30'], # 3 full months + 15 days (=300/month)
+ [cls.expense_accounts[1], 1225, '2023-01-01', '2023-04-15'], # 3 full months + 15 days (=350/month)
+ [cls.expense_accounts[2], 1680, '2023-01-21', '2023-04-14'], # 2 full months + 10 days + 14 days (=600/month)
+ [cls.expense_accounts[2], 225, '2023-04-01', '2023-04-15'], # 15 days (=450/month)
+ ]
+ cls.revenue_lines = [
+ [cls.revenue_accounts[0], 1000, '2023-01-01', '2023-04-30'], # 4 full months (=250/month)
+ [cls.revenue_accounts[0], 1050, '2023-01-16', '2023-04-30'], # 3 full months + 15 days (=300/month)
+ [cls.revenue_accounts[1], 1225, '2023-01-01', '2023-04-15'], # 3 full months + 15 days (=350/month)
+ [cls.revenue_accounts[2], 1680, '2023-01-21', '2023-04-14'], # 2 full months + 10 days + 14 days (=600/month)
+ [cls.revenue_accounts[2], 225, '2023-04-01', '2023-04-15'], # 15 days (=450/month)
+ ]
+
+ def create_invoice(self, invoice_lines, move_type='in_invoice', invoice_date='2023-01-01', post=True):
+ journal = self.company_data['default_journal_sale']
+ if move_type.startswith('in_'):
+ journal = self.company_data['default_journal_purchase']
+ move = self.env['account.move'].create({
+ 'move_type': move_type,
+ 'partner_id': self.partner_a.id,
+ 'date': invoice_date,
+ 'invoice_date': invoice_date,
+ 'journal_id': journal.id,
+ 'invoice_line_ids': [
+ Command.create({
+ 'product_id': self.product_a.id,
+ 'quantity': 1,
+ 'account_id': invoice_line[0].id,
+ 'price_unit': invoice_line[1],
+ 'deferred_start_date': invoice_line[2],
+ 'deferred_end_date': invoice_line[3],
+ }) for invoice_line in invoice_lines
+ ]
+ })
+ if post:
+ move.action_post()
+ return move
+
+ def get_options(self, from_date, to_date, report=None):
+ report = report or self.deferred_expense_report
+ return self._generate_options(report, from_date, to_date)
+
+ def get_lines(self, options, report=None):
+ report = report if report is not None else self.deferred_expense_report
+ # Clear the cache to avoid any interference with the previous test
+ # This is automatically done when clicking on a report,
+ # but it needs to be manually done here since we call the backend function directly
+ self.env.cr.cache.pop('report_deferred_lines', None)
+ return report._get_lines(options)
+
+ def generate_deferral_entries(self, options, report_handler=None):
+ report_handler = report_handler if report_handler is not None else self.handler
+ self.env.cr.cache.pop('report_deferred_lines', None) # same as above
+ return report_handler._generate_deferral_entry(options)
+
+ def test_deferred_expense_report_months(self):
+ """
+ Test the deferred expense report with the 'month' method.
+ We use multiple report months/quarters/years to check that the computation is correct.
+ """
+ self.company.deferred_expense_amount_computation_method = 'month'
+ self.create_invoice(self.expense_lines)
+
+ # December 2022
+ options = self.get_options('2022-12-01', '2022-12-31')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [],
+ options,
+ )
+
+ # January 2023
+ options = self.get_options('2023-01-01', '2023-01-31')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1000 + 1050, 0, 0, 250 + 150, 750 + 900 ),
+ ('EXP1 Expense 1', 1225, 0, 0, 350, 875 ),
+ ('EXP2 Expense 2', 1680 + 225, 225, 0, 600 * (10/30) + 0, 600 * (2 + 14/30) + 225 ),
+ ('Total', 5180, 225, 0, 950, 4230 ),
+ ],
+ options,
+ )
+
+ # February 2023
+ options = self.get_options('2023-02-01', '2023-02-28')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1000 + 1050, 0, 250 + 150, 250 + 300, 500 + 600 ),
+ ('EXP1 Expense 1', 1225, 0, 350, 350, 525 ),
+ ('EXP2 Expense 2', 1680 + 225, 225, 600 * (10/30) + 0, 600 + 0, 600 * (1 + 14/30) + 225 ),
+ ('Total', 5180, 225, 950, 1500, 2730 ),
+ ],
+ options,
+ )
+
+ # April 2023
+ options = self.get_options('2023-04-01', '2023-04-30')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1000 + 1050, 0, 750 + 750, 250 + 300, 0 ),
+ ('EXP1 Expense 1', 1225, 0, 1050, 175, 0 ),
+ ('EXP2 Expense 2', 1680 + 225, 0, 600 * (2 + 10/30), 600 * (14/30)+ 225, 0 ),
+ ('Total', 5180, 0, 3950, 1230, 0 ),
+ ],
+ options,
+ )
+
+ # May 2023
+ options = self.get_options('2023-05-01', '2023-05-31')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [],
+ options,
+ )
+
+ # Q1 2023
+ options = self.get_options('2023-01-01', '2023-03-31')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1000 + 1050, 0, 0, 750 + 750, 550 ),
+ ('EXP1 Expense 1', 1225, 0, 0, 1050, 175 ),
+ ('EXP2 Expense 2', 1680 + 225, 225, 0, 600 * (2 + 10/30) + 0, 280 + 225 ),
+ ('Total', 5180, 225, 0, 3950, 1230 ),
+ ],
+ options,
+ )
+
+ # Q2 2023
+ options = self.get_options('2023-04-01', '2023-06-30')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1000 + 1050, 0, 750 + 750, 250 + 300, 0 ),
+ ('EXP1 Expense 1', 1225, 0, 1050, 175, 0 ),
+ ('EXP2 Expense 2', 1680 + 225, 0, 600 * (2 + 10/30), 600 * (14/30)+ 225, 0 ),
+ ('Total', 5180, 0, 3950, 1230, 0 ),
+ ],
+ options,
+ )
+
+ # 2022
+ options = self.get_options('2022-01-01', '2022-12-31')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [],
+ options,
+ )
+
+ # 2023, nothing to show as all the moves have been deferred by the end of the year 2023
+ options = self.get_options('2023-01-01', '2023-12-31')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [],
+ options,
+ )
+
+ def test_deferred_revenue_report(self):
+ """
+ Test the deferred revenue report with the 'month' method.
+ We use multiple report months/quarters/years to check that the computation is correct.
+ """
+ self.company.deferred_revenue_amount_computation_method = 'month'
+ self.create_invoice(self.revenue_lines, 'out_invoice')
+
+ # December 2022
+ options = self.get_options('2022-12-01', '2022-12-31', self.deferred_revenue_report)
+ lines = self.get_lines(options, self.deferred_revenue_report)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [],
+ options,
+ )
+
+ # January 2023
+ options = self.get_options('2023-01-01', '2023-01-31', self.deferred_revenue_report)
+ lines = self.get_lines(options, self.deferred_revenue_report)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('REV0 Revenue 0', 1000 + 1050, 0, 0, 250 + 150, 750 + 900 ),
+ ('REV1 Revenue 1', 1225, 0, 0, 350, 875 ),
+ ('REV2 Revenue 2', 1680 + 225, 225, 0, 600 * (10/30) + 0, 600 * (2 + 14/30) + 225 ),
+ ('Total', 5180, 225, 0, 950, 4230 ),
+ ],
+ options,
+ )
+
+ # February 2023
+ options = self.get_options('2023-02-01', '2023-02-28', self.deferred_revenue_report)
+ lines = self.get_lines(options, self.deferred_revenue_report)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('REV0 Revenue 0', 1000 + 1050, 0, 250 + 150, 250 + 300, 500 + 600 ),
+ ('REV1 Revenue 1', 1225, 0, 350, 350, 525 ),
+ ('REV2 Revenue 2', 1680 + 225, 225, 600 * (10/30) + 0, 600 + 0, 600 * (1 + 14/30) + 225 ),
+ ('Total', 5180, 225, 950, 1500, 2730 ),
+ ],
+ options,
+ )
+
+ # April 2023
+ options = self.get_options('2023-04-01', '2023-04-30', self.deferred_revenue_report)
+ lines = self.get_lines(options, self.deferred_revenue_report)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('REV0 Revenue 0', 1000 + 1050, 0, 750 + 750, 250 + 300, 0 ),
+ ('REV1 Revenue 1', 1225, 0, 1050, 175, 0 ),
+ ('REV2 Revenue 2', 1680 + 225, 0, 600 * (2 + 10/30), 600 * (14/30)+ 225, 0 ),
+ ('Total', 5180, 0, 3950, 1230, 0 ),
+ ],
+ options,
+ )
+
+ # May 2023
+ options = self.get_options('2023-05-01', '2023-05-31', self.deferred_revenue_report)
+ lines = self.get_lines(options, self.deferred_revenue_report)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [],
+ options,
+ )
+
+ # Q1 2023
+ options = self.get_options('2023-01-01', '2023-03-31', self.deferred_revenue_report)
+ lines = self.get_lines(options, self.deferred_revenue_report)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('REV0 Revenue 0', 1000 + 1050, 0, 0, 750 + 750, 550 ),
+ ('REV1 Revenue 1', 1225, 0, 0, 1050, 175 ),
+ ('REV2 Revenue 2', 1680 + 225, 225, 0, 600 * (2 + 10/30) + 0, 280 + 225 ),
+ ('Total', 5180, 225, 0, 3950, 1230 ),
+ ],
+ options,
+ )
+
+ # Q2 2023
+ options = self.get_options('2023-04-01', '2023-06-30', self.deferred_revenue_report)
+ lines = self.get_lines(options, self.deferred_revenue_report)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('REV0 Revenue 0', 1000 + 1050, 0, 750 + 750, 250 + 300, 0 ),
+ ('REV1 Revenue 1', 1225, 0, 1050, 175, 0 ),
+ ('REV2 Revenue 2', 1680 + 225, 0, 600 * (2 + 10/30), 600 * (14/30)+ 225, 0 ),
+ ('Total', 5180, 0, 3950, 1230, 0 ),
+ ],
+ options,
+ )
+
+ # 2022
+ options = self.get_options('2022-01-01', '2022-12-31', self.deferred_revenue_report)
+ lines = self.get_lines(options, self.deferred_revenue_report)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [],
+ options,
+ )
+
+ # 2023: nothing to show since all the invoices are deferred
+ options = self.get_options('2023-01-01', '2023-12-31', self.deferred_revenue_report)
+ lines = self.get_lines(options, self.deferred_revenue_report)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [],
+ options,
+ )
+
+ def test_deferred_expense_report_days(self):
+ """
+ Test the deferred expense report with the 'day' method.
+ We use multiple report months/quarters/years to check that the computation is correct.
+ """
+ self.company.deferred_expense_amount_computation_method = 'day'
+ self.create_invoice(self.expense_lines)
+
+ # December 2022
+ options = self.get_options('2022-12-01', '2022-12-31')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [],
+ options,
+ )
+
+ # January 2023
+ options = self.get_options('2023-01-01', '2023-01-31')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1000 + 1050, 0, 0, 418.33, 1631.67 ),
+ ('EXP1 Expense 1', 1225, 0, 0, 361.67, 863.33 ),
+ ('EXP2 Expense 2', 1680 + 225, 225, 0, 220, 1460 + 225 ),
+ ('Total', 5180, 225, 0, 1000, 4180 ),
+ ],
+ options,
+ )
+
+ # February 2023
+ options = self.get_options('2023-02-01', '2023-02-28')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1000 + 1050, 0, 418.33, 513.33, 1118.33 ),
+ ('EXP1 Expense 1', 1225, 0, 361.67, 326.67, 536.67 ),
+ ('EXP2 Expense 2', 1680 + 225, 225, 220, 560, 900 + 225 ),
+ ('Total', 5180, 225, 1000, 1400, 2780 ),
+ ],
+ options,
+ )
+
+ # April 2023
+ options = self.get_options('2023-04-01', '2023-04-30')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+
+ # ruff: noqa: E202
+
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1000 + 1050, 0, 1500, 550, 0 ),
+ ('EXP1 Expense 1', 1225, 0, 1050, 175, 0 ),
+ ('EXP2 Expense 2', 1680 + 225, 0, 1400, 505, 0 ),
+ ('Total', 5180, 0, 3950, 1230, 0 ),
+ ],
+ options,
+ )
+
+ # Q1 2023
+ options = self.get_options('2023-01-01', '2023-03-31')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1000 + 1050, 0, 0, 1500, 550 ),
+ ('EXP1 Expense 1', 1225, 0, 0, 1050, 175 ),
+ ('EXP2 Expense 2', 1680 + 225, 225, 0, 1400, 280 + 225 ),
+ ('Total', 5180, 225, 0, 3950, 1230 ),
+ ],
+ options,
+ )
+
+ def test_deferred_expense_report_filter_all_entries(self):
+ """
+ Test the 'All entries' option on the deferred expense report.
+ """
+ self.company.deferred_expense_amount_computation_method = 'day'
+ self.create_invoice(self.expense_lines, post=True)
+ self.create_invoice(self.expense_lines, post=False)
+
+ # Only posted entries
+ options = self.get_options('2023-02-01', '2023-02-28')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1000 + 1050, 0, 418.33, 513.33, 1118.33 ),
+ ('EXP1 Expense 1', 1225, 0, 361.67, 326.67, 536.67 ),
+ ('EXP2 Expense 2', 1680 + 225, 225, 220, 560, 900 + 225 ),
+ ('Total', 5180, 225, 1000, 1400, 2780 ),
+ ],
+ options,
+ )
+
+ # All non-cancelled entries
+ options = self._generate_options(self.deferred_expense_report, fields.Date.from_string('2023-02-01'), fields.Date.from_string('2023-02-28'), {
+ 'all_entries': True,
+ })
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 2000 + 2100, 0, 836.67, 1026.67, 2236.67 ),
+ ('EXP1 Expense 1', 2450, 0, 723.33, 653.33, 1073.33 ),
+ ('EXP2 Expense 2', 3360 + 450, 450, 440, 1120, 1800 + 450 ),
+ ('Total', 10360, 450, 2000, 2800, 5560 ),
+ ],
+ options,
+ )
+
+ def test_deferred_expense_report_comparison(self):
+ """
+ Test the the comparison tool on the deferred expense report.
+ For instance, we select April 2023 and compare it with the last 4 months
+ """
+ self.create_invoice(self.expense_lines)
+
+ # April 2023 + period comparison of last 4 months
+ options = self.get_options('2023-04-01', '2023-04-30')
+ options = self._update_comparison_filter(options, self.deferred_expense_report, 'previous_period', 4)
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Dec 2022 Jan 2023 Feb 2023 Mar 2023 Apr 2023 (Current) Later
+ [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ],
+ [
+ ('EXP0 Expense 0', 2050, 0, 0, 0, 400, 550, 550, 550, 0 ),
+ ('EXP1 Expense 1', 1225, 0, 0, 0, 350, 350, 350, 175, 0 ),
+ ('EXP2 Expense 2', 1905, 0, 0, 0, 200, 600, 600, 505, 0 ),
+ ('Total', 5180, 0, 0, 0, 950, 1500, 1500, 1230, 0 ),
+ ],
+ options,
+ )
+
+ def test_deferred_expense_report_partially_deductible_tax(self):
+ """
+ Test the deferred expense report with partially deductible tax.
+ If we have 50% deductible tax, half of the invoice line amount should also be deferred.
+ Here, for an invoice line of 1000, and a tax of 40% partially deductible (50%) on 3 months, we will have:
+ - 1400 for the total amount, tax included
+ - 1200 for the total amount to be deferred (1000 + 400/2)
+ - 400 for the deferred amount for each of the 3 months
+ """
+ partially_deductible_tax = self.env['account.tax'].create({
+ 'name': 'Partially deductible Tax',
+ 'amount': 40,
+ 'amount_type': 'percent',
+ 'type_tax_use': 'purchase',
+ 'invoice_repartition_line_ids': [
+ Command.create({'repartition_type': 'base'}),
+ Command.create({
+ 'factor_percent': 50,
+ 'repartition_type': 'tax',
+ 'use_in_tax_closing': False
+ }),
+ Command.create({
+ 'factor_percent': 50,
+ 'repartition_type': 'tax',
+ 'account_id': self.company_data['default_account_tax_purchase'].id,
+ 'use_in_tax_closing': True
+ }),
+ ],
+ 'refund_repartition_line_ids': [
+ Command.create({'repartition_type': 'base'}),
+ Command.create({
+ 'factor_percent': 50,
+ 'repartition_type': 'tax',
+ 'use_in_tax_closing': False
+ }),
+ Command.create({
+ 'factor_percent': 50,
+ 'repartition_type': 'tax',
+ 'account_id': self.company_data['default_account_tax_purchase'].id,
+ 'use_in_tax_closing': True
+ }),
+ ],
+ })
+
+ # Create invoice with 3 cars to be depreciated on different periods
+ # to make sure the deferred dates are correctly copied on the right lines
+ move_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice'))
+ move_form.partner_id = self.partner_a
+ move_form.invoice_date = fields.Date.from_string('2022-01-01')
+ for year in (2022, 2023, 2023):
+ with move_form.invoice_line_ids.new() as line_form:
+ line_form.name = f"Car {year}"
+ line_form.quantity = 1
+ line_form.price_unit = 1000
+ line_form.deferred_start_date = f'{year}-01-01'
+ line_form.deferred_end_date = f'{year}-03-31'
+ line_form.tax_ids.add(partially_deductible_tax)
+ line_form.account_id = self.expense_accounts[0]
+ move = move_form.save()
+ move.action_post()
+
+ # Taxes for 2023 should be aggregated into one because we have the same deferred dates.
+ # Taxes for 2022 should not be aggregated with 2023 because we have different deferred dates.
+ # Therefore we have 4 lines:
+ # - 3 for the cars
+ # - 1 for the tax of 2022
+ # - 1 for the aggregated taxes of 2023
+ self.assertEqual(len(move.line_ids.filtered(lambda l: l.account_id == self.expense_accounts[0])), 5)
+
+ # 2 cars (not 3) should appear with this date range
+ options = self.get_options('2023-02-01', '2023-02-28')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 2400, 0, 800, 800, 800 ),
+ ('Total', 2400, 0, 800, 800, 800 ),
+ ],
+ options,
+ )
+
+ def test_deferred_expense_report_credit_notes(self):
+ """
+ Test the credit notes on the deferred expense report.
+ """
+ self.company.deferred_expense_amount_computation_method = 'day'
+ self.create_invoice(self.expense_lines, move_type='in_refund')
+
+ options = self.get_options('2023-02-01', '2023-02-28')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', -1000 - 1050, 0, -418.33, -513.33, -1118.33 ),
+ ('EXP1 Expense 1', -1225, 0, -361.67, -326.67, -536.67 ),
+ ('EXP2 Expense 2', -1680 - 225, -225, -220, -560, -900 - 225 ),
+ ('Total', -5180, -225, -1000, -1400, -2780 ),
+ ],
+ options,
+ )
+
+ def test_deferred_expense_report_compute_method_full_months(self):
+ """
+ Test the full_months method on the deferred expense report.
+ """
+ self.company.deferred_expense_amount_computation_method = 'full_months'
+ self.create_invoice([[self.expense_accounts[0], 1200, '2023-01-31', '2024-01-30']])
+ self.create_invoice([[self.expense_accounts[1], 1200, '2023-02-01', '2023-03-31']])
+ self.create_invoice([[self.expense_accounts[2], 1200, '2023-02-01', '2023-03-16']])
+ self.create_invoice([[self.expense_accounts[3], 1200, '2023-02-05', '2023-03-16']])
+ self.create_invoice([[self.expense_accounts[4], 1200, '2023-02-05', '2023-03-31']])
+ self.create_invoice([[self.expense_accounts[5], 1200, '2023-02-13', '2023-04-30']])
+ self.create_invoice([[self.expense_accounts[6], 1200, '2023-03-01', '2023-06-18']])
+ self.create_invoice([[self.expense_accounts[7], 1200, '2023-03-05', '2023-06-30']])
+ self.create_invoice([[self.expense_accounts[8], 1200, '2023-03-13', '2024-03-12']])
+ self.create_invoice([[self.expense_accounts[9], 1200, '2023-03-14', '2023-03-18']])
+
+ options = self.get_options('2023-03-01', '2023-03-31')
+ lines = self.get_lines(options)
+
+ for line in lines:
+ total = line['columns'][0]['no_format']
+ before = line['columns'][2]['no_format'] # 1 is "Not Started"
+ current = line['columns'][3]['no_format']
+ later = line['columns'][4]['no_format']
+ self.assertAlmostEqual(total, before + current + later, 3)
+
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [0, 1, 2, 3, 4, 5],
+ [
+ ('EXP0 Expense 0', 1200, 0, 200, 100, 900),
+ ('EXP1 Expense 1', 1200, 0, 600, 600, 0),
+ ('EXP2 Expense 2', 1200, 0, 1200, 0, 0),
+ ('EXP3 Expense 3', 1200, 0, 1200, 0, 0),
+ ('EXP4 Expense 4', 1200, 0, 600, 600, 0),
+ ('EXP5 Expense 5', 1200, 0, 400, 400, 400),
+ ('EXP6 Expense 6', 1200, 0, 0, 400, 800),
+ ('EXP7 Expense 7', 1200, 0, 0, 300, 900),
+ ('EXP8 Expense 8', 1200, 0, 0, 100, 1100),
+ ('EXP9 Expense 9', 1200, 0, 0, 1200, 0),
+ ('Total', 12000, 0, 4200, 3700, 4100),
+ ],
+ options,
+ )
+
+ def assert_invoice_lines(self, deferred_move, expected_values):
+ for line, expected_value in zip(deferred_move.line_ids, expected_values):
+ expected_account, expected_debit, expected_credit = expected_value
+ self.assertRecordValues(line, [{
+ 'account_id': expected_account.id,
+ 'debit': expected_debit,
+ 'credit': expected_credit,
+ }])
+
+ def test_deferred_expense_report_accounting_date(self):
+ """
+ Test that the accounting date is taken into account for the deferred expense report.
+ """
+ self.company.generate_deferred_expense_entries_method = 'manual'
+ self.create_invoice([self.expense_lines[0]], invoice_date='2023-02-15')
+
+ # In january, the move is not accounted yet (accounting date is in 15 Feb), so nothing should be displayed.
+ options = self.get_options('2023-01-01', '2023-01-31')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [],
+ options,
+ )
+ # Nothing should be generated either.
+ with self.assertRaisesRegex(UserError, 'No entry to generate.'):
+ self.generate_deferral_entries(options)
+
+ # In Feb, the move is accounted, so it should be displayed.
+ options = self.get_options('2023-02-01', '2023-02-28')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1000, 0, 250, 250, 500 ),
+ ('Total', 1000, 0, 250, 250, 500 ),
+ ],
+ options,
+ )
+
+ # Same in March.
+ options = self.get_options('2023-03-01', '2023-03-31')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1000, 0, 500, 250, 250 ),
+ ('Total', 1000, 0, 500, 250, 250 ),
+ ],
+ options,
+ )
+
+ # Same in April.
+ options = self.get_options('2023-04-01', '2023-04-30')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1000, 0, 750, 250, 0 ),
+ ('Total', 1000, 0, 750, 250, 0 ),
+ ],
+ options,
+ )
+
+ # In May, the move is accounted and fully deferred, so it should not be displayed.
+ options = self.get_options('2023-05-01', '2023-05-31')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [],
+ options,
+ )
+
+ def test_deferred_expense_generate_grouped_entries_method(self):
+ """
+ Test the Generate entries button on the deferred expense report.
+ """
+ self.company.generate_deferred_expense_entries_method = 'manual'
+
+ options = self.get_options('2023-01-01', '2023-01-31')
+
+ with self.assertRaisesRegex(UserError, 'No entry to generate.'):
+ self.generate_deferral_entries(options)
+
+ # Create 3 different invoices (instead of one with 3 lines)
+ for expense_line in self.expense_lines[:3]:
+ self.create_invoice([expense_line])
+
+ # Check that no deferred move has been created
+ self.assertEqual(self.env['account.move.line'].search_count([('account_id', '=', self.deferral_account.id)]), 0)
+
+ # Generate the grouped deferred entries
+ generated_entries_january = self.generate_deferral_entries(options)
+
+ deferred_move_january = generated_entries_january[0]
+ self.assertRecordValues(deferred_move_january, [{
+ 'state': 'posted',
+ 'move_type': 'entry',
+ 'date': fields.Date.to_date('2023-01-31'),
+ }])
+ expected_values_january = [
+ # Account Debit Credit
+ [self.expense_accounts[0], 0, 1000 + 1050],
+ [self.expense_accounts[0], 250 + 150, 0],
+ [self.expense_accounts[1], 0, 1225],
+ [self.expense_accounts[1], 350, 0],
+ [self.deferral_account, 1000 + 1050 + 1225 - 250 - 150 - 350, 0]
+ ]
+ self.assert_invoice_lines(deferred_move_january, expected_values_january)
+
+ deferred_inverse_january = generated_entries_january[1]
+ self.assertEqual(deferred_inverse_january.move_type, 'entry')
+ self.assertEqual(deferred_inverse_january.state, 'posted') # Posted because the date is before today
+ self.assertEqual(deferred_inverse_january.date, fields.Date.from_string('2023-02-01'))
+
+ # Don't re-generate entries for the same period if they already exist for all move lines
+ with self.assertRaisesRegex(UserError, 'No entry to generate.'):
+ self.generate_deferral_entries(options)
+
+ # Generate the grouped deferred entries for the next period
+ generated_entries_february = self.generate_deferral_entries(self.get_options('2023-02-01', '2023-02-28'))
+ deferred_move_february = generated_entries_february[0]
+ expected_values_february = [
+ # Account Debit Credit
+ [self.expense_accounts[0], 0, 1000 + 1050],
+ [self.expense_accounts[0], 500 + 450, 0],
+ [self.expense_accounts[1], 0, 1225],
+ [self.expense_accounts[1], 700, 0],
+ [self.deferral_account, 1000 + 1050 + 1225 - 500 - 450 - 700, 0]
+ ]
+ self.assert_invoice_lines(deferred_move_february, expected_values_february)
+
+ def _test_deferred_expense_partially_generate_grouped_entries_method_common(self):
+ """
+ Test that if we generate the deferrals for a period, we don't re-generate them if they already exist.
+ and we regenerate them if necessary (e.g. a new invoice has been created for the same period).
+ """
+ self.company.generate_deferred_expense_entries_method = 'manual'
+
+ options = self.get_options('2023-01-01', '2023-01-31')
+
+ # Create 2 different invoices (instead of one with 2 lines)
+ self.create_invoice([self.expense_lines[0]])
+ self.create_invoice([self.expense_lines[1]])
+
+ # Generate the grouped deferred entries
+ generated_entries_january = self.generate_deferral_entries(options)
+
+ deferred_move_january, deferred_inverse_january = generated_entries_january
+ self.assertRecordValues(deferred_move_january, [{
+ 'move_type': 'entry',
+ 'date': fields.Date.to_date('2023-01-31'),
+ }])
+ expected_values_january = [
+ # Account Debit Credit
+ [self.expense_accounts[0], 0, 1000 + 1050],
+ [self.expense_accounts[0], 250 + 150, 0],
+ [self.deferral_account, 1000 + 1050 - 250 - 150, 0]
+ ]
+ self.assert_invoice_lines(deferred_move_january, expected_values_january)
+
+ self.assertRecordValues(deferred_inverse_january, [{
+ 'move_type': 'entry',
+ 'date': fields.Date.to_date('2023-02-01'),
+ }])
+
+ # Don't re-generate entries for the same period if they already exist for all move lines
+ with self.assertRaisesRegex(UserError, 'No entry to generate.'):
+ self.generate_deferral_entries(options)
+
+ # Let's create a new invoice that should be shown in the January period
+ self.create_invoice([self.expense_lines[2]])
+
+ # Now, even though we have already generated the grouped deferral for January,
+ # we must re-generate it because we have a new invoice that should be included in the January period.
+ # We only generate the missing part
+ generated_entries_january2 = self.generate_deferral_entries(options)
+ deferred_move_january2 = generated_entries_january2[0]
+ self.assertRecordValues(deferred_move_january2, [{
+ 'move_type': 'entry',
+ 'date': fields.Date.to_date('2023-01-31'),
+ }])
+ expected_values_january2 = [
+ # Account Debit Credit
+ [self.expense_accounts[1], 0, 1225],
+ [self.expense_accounts[1], 350, 0],
+ [self.deferral_account, 1225 - 350, 0]
+ ]
+ self.assert_invoice_lines(deferred_move_january2, expected_values_january2)
+ return deferred_move_january, deferred_inverse_january
+
+ def test_deferred_expense_partially_generate_grouped_entries_method_past_period(self):
+ """
+ Test that if we generate the deferrals for a period, we don't re-generate them if they already exist.
+ and we regenerate them if necessary (e.g. a new invoice has been created for the same period).
+ """
+ deferred_move_january, deferred_inverse_january = self._test_deferred_expense_partially_generate_grouped_entries_method_common()
+ self.assertEqual(deferred_move_january.state, 'posted') # January is in the past so the move is posted
+ self.assertEqual(deferred_inverse_january.state, 'posted')
+
+ @freeze_time('2023-01-01')
+ def test_deferred_expense_partially_generate_grouped_entries_method_current_period(self):
+ """
+ Same as previous test but for an ongoing month. This test has been added after discovering
+ that if you generate the deferrals for the current month, you could generate them again
+ and again. This is because the generated deferral is only posted at then end of the month
+ (date_to of the report period). So the `filter_generated_entries` wasn't working as expected
+ The test is exactly the same as the previous one, we with a freeze_time simulating Jan 2023
+ """
+ deferred_move_january, deferred_inverse_january = self._test_deferred_expense_partially_generate_grouped_entries_method_common()
+ self.assertEqual(deferred_move_january.state, 'draft') # January is still current so the move is not posted yet
+ self.assertEqual(deferred_inverse_january.state, 'draft')
+
+ @freeze_time('2023-01-01')
+ def test_deferred_expense_partially_generate_grouped_entries_method_later_period(self):
+ """
+ Same as previous test but for a later month (February 2023 while we simulate we are in Jan 2023)
+ """
+ self.company.generate_deferred_expense_entries_method = 'manual'
+
+ options = self.get_options('2023-02-01', '2023-02-28')
+
+ # Create 2 different invoices (instead of one with 2 lines)
+ self.create_invoice([self.expense_lines[0]])
+ self.create_invoice([self.expense_lines[1]])
+
+ # Generate the grouped deferred entries
+ generated_entries_february = self.generate_deferral_entries(options)
+
+ deferred_move_february, deferred_inverse_february = generated_entries_february
+ self.assertRecordValues(deferred_move_february, [{
+ 'state': 'draft', # Not posted yet since it's in the future
+ 'move_type': 'entry',
+ 'date': fields.Date.to_date('2023-02-28'),
+ }])
+ expected_values_february = [
+ # Account Debit Credit
+ [self.expense_accounts[0], 0, 1000 + 1050],
+ [self.expense_accounts[0], 500 + 450, 0],
+ [self.deferral_account, 1000 + 1050 - 500 - 450, 0]
+ ]
+ self.assert_invoice_lines(deferred_move_february, expected_values_february)
+
+ self.assertRecordValues(deferred_inverse_february, [{
+ 'state': 'draft', # Not posted yet since it's in the future
+ 'move_type': 'entry',
+ 'date': fields.Date.to_date('2023-03-01'),
+ }])
+
+ # Don't re-generate entries for the same period if they already exist for all move lines
+ with self.assertRaisesRegex(UserError, 'No entry to generate.'):
+ self.generate_deferral_entries(options)
+
+ # Let's create a new invoice that should be shown in the february period
+ self.create_invoice([self.expense_lines[2]])
+
+ # Now, even though we have already generated the grouped deferral for february,
+ # we must re-generate it because we have a new invoice that should be included in the february period.
+ # We only generate the missing part
+ generated_entries_february2 = self.generate_deferral_entries(options)
+ deferred_move_february2 = generated_entries_february2[0]
+ self.assertRecordValues(deferred_move_february2, [{
+ 'state': 'draft', # Not posted yet since it's in the future
+ 'move_type': 'entry',
+ 'date': fields.Date.to_date('2023-02-28'),
+ }])
+ expected_values_february2 = [
+ # Account Debit Credit
+ [self.expense_accounts[1], 0, 1225],
+ [self.expense_accounts[1], 700, 0],
+ [self.deferral_account, 1225 - 700, 0]
+ ]
+ self.assert_invoice_lines(deferred_move_february2, expected_values_february2)
+
+ def test_deferred_expense_generate_future_deferrals_grouped(self):
+ """
+ Test the Generate entries button when we have a deferral starting after the invoice period.
+ """
+ self.company.deferred_expense_amount_computation_method = 'month'
+ self.company.generate_deferred_expense_entries_method = 'manual'
+ self.create_invoice([[self.expense_accounts[0], 750, '2023-03-01', '2023-04-15']])
+
+ # JANUARY
+ generated_entries_january = self.generate_deferral_entries(self.get_options('2023-01-01', '2023-01-31'))
+
+ # January Deferral
+ deferred_move_january = generated_entries_january[0]
+ self.assertRecordValues(deferred_move_january, [{
+ 'move_type': 'entry',
+ 'date': fields.Date.to_date('2023-01-31'),
+ }])
+ expected_values_january = [
+ # Account Debit Credit
+ [self.expense_accounts[0], 0, 750],
+ [self.deferral_account, 750, 0],
+ ]
+ self.assert_invoice_lines(deferred_move_january, expected_values_january)
+
+ # January Reversal
+ deferred_inverse_january = generated_entries_january[1]
+ self.assertRecordValues(deferred_inverse_january, [{
+ 'move_type': 'entry',
+ 'date': fields.Date.to_date('2023-02-01'),
+ }])
+ expected_values_inverse_january = [
+ # Account Debit Credit
+ [self.expense_accounts[0], 750, 0],
+ [self.deferral_account, 0, 750],
+ ]
+ self.assert_invoice_lines(deferred_inverse_january, expected_values_inverse_january)
+
+ # FEBRUARY
+ generated_entries_february = self.generate_deferral_entries(self.get_options('2023-02-01', '2023-02-28'))
+
+ # February Deferral
+ deferred_move_february = generated_entries_february[0]
+ self.assertRecordValues(deferred_move_february, [{
+ 'move_type': 'entry',
+ 'date': fields.Date.to_date('2023-02-28'),
+ }])
+ expected_values_february = [
+ # Account Debit Credit
+ [self.expense_accounts[0], 0, 750],
+ [self.deferral_account, 750, 0],
+ ]
+ self.assert_invoice_lines(deferred_move_february, expected_values_february)
+
+ # February Reversal
+ deferred_inverse_february = generated_entries_february[1]
+ self.assertRecordValues(deferred_inverse_february, [{
+ 'move_type': 'entry',
+ 'date': fields.Date.to_date('2023-03-01'),
+ }])
+ expected_values_inverse_february = [
+ # Account Debit Credit
+ [self.expense_accounts[0], 750, 0],
+ [self.deferral_account, 0, 750],
+ ]
+ self.assert_invoice_lines(deferred_inverse_february, expected_values_inverse_february)
+
+ # MARCH
+ generated_entries_march = self.generate_deferral_entries(self.get_options('2023-03-01', '2023-03-31'))
+
+ # March Deferral
+ deferred_move_march = generated_entries_march[0]
+ self.assertRecordValues(deferred_move_march, [{
+ 'move_type': 'entry',
+ 'date': fields.Date.to_date('2023-03-31'),
+ }])
+ expected_values_march = [
+ # Account Debit Credit
+ [self.expense_accounts[0], 0, 750],
+ [self.expense_accounts[0], 500, 0],
+ [self.deferral_account, 250, 0],
+ ]
+ self.assert_invoice_lines(deferred_move_march, expected_values_march)
+
+ # March Reversal
+ deferred_inverse_march = generated_entries_march[1]
+ self.assertRecordValues(deferred_inverse_march, [{
+ 'move_type': 'entry',
+ 'date': fields.Date.to_date('2023-04-01'),
+ }])
+ expected_values_inverse_march = [
+ # Account Debit Credit
+ [self.expense_accounts[0], 750, 0],
+ [self.expense_accounts[0], 0, 500],
+ [self.deferral_account, 0, 250],
+ ]
+ self.assert_invoice_lines(deferred_inverse_march, expected_values_inverse_march)
+
+ # APRIL
+ with self.assertRaisesRegex(UserError, 'No entry to generate.'):
+ # No entry should be generated, since everything has been deferred.
+ self.generate_deferral_entries(self.get_options('2023-04-01', '2023-04-30'))
+
+ def test_deferred_revenue_generate_grouped_without_taxes(self):
+ """
+ Test the default taxes on accounts are ignored when generating a grouped deferral entry.
+ """
+ self.company.generate_deferred_revenue_entries_method = 'manual'
+ deferral_account = self.company_data['default_account_deferred_revenue']
+ revenue_account_with_taxes = self.env['account.account'].create({
+ 'name': 'Revenue with Taxes',
+ 'code': 'REVWTAXES',
+ 'account_type': 'income',
+ 'tax_ids': [Command.set(self.tax_sale_a.ids)]
+ })
+ options = self.get_options('2023-01-01', '2023-01-31', self.deferred_revenue_report)
+ revenue_handler = self.env['account.deferred.revenue.report.handler']
+
+ self.create_invoice([[revenue_account_with_taxes, 1000, '2023-01-01', '2023-04-30']], move_type='out_invoice')
+
+ # Check that no deferred move has been created
+ self.assertEqual(self.env['account.move.line'].search_count([('account_id', '=', deferral_account.id)]), 0)
+
+ # Generate the grouped deferred entries
+ generated_entries_january = self.generate_deferral_entries(options, revenue_handler)
+
+ deferred_move_january = generated_entries_january[0]
+ self.assertRecordValues(deferred_move_january, [{
+ 'state': 'posted',
+ 'move_type': 'entry',
+ 'date': fields.Date.to_date('2023-01-31'),
+ }])
+ expected_values_january = [
+ # Account Debit Credit
+ [revenue_account_with_taxes, 1000, 0],
+ [revenue_account_with_taxes, 0, 250],
+ [deferral_account, 0, 750],
+ ]
+ self.assert_invoice_lines(deferred_move_january, expected_values_january)
+ # There are no extra (tax) lines besides the three lines we checked before
+ self.assertFalse(deferred_move_january.line_ids.tax_line_id)
+
+ def test_deferred_values_rounding(self):
+ """
+ When using the manually & grouped method, we might have some rounding issues
+ when aggregating multiple deferred entries. This test ensures that the rounding
+ is done correctly.
+ """
+ self.company.generate_deferred_expense_entries_method = 'manual'
+ self.company.deferred_expense_amount_computation_method = 'day'
+ self.create_invoice([[self.expense_accounts[0], 600, '2023-04-04', '2023-05-25']])
+ self.create_invoice([[self.expense_accounts[1], 600, '2023-04-05', '2023-05-16']])
+ self.create_invoice([[self.expense_accounts[0], 600, '2023-04-04', '2023-05-08']])
+
+ # This shouldn't raise an error like this 'The total of debits equals $1,800.01 and the total of credits equals $1,800.00.'
+ self.generate_deferral_entries(self.get_options('2023-04-01', '2023-04-30'))
+
+ def test_deferred_single_rounding(self):
+ """
+ When using the manually & grouped method, we might have some rounding issues
+ due to rounding in different places. This test ensures that the a balance line is created
+ automatically for the difference.
+ """
+ self.company.generate_deferred_expense_entries_method = 'manual'
+ self.company.deferred_expense_amount_computation_method = 'month'
+ self.create_invoice([[self.expense_accounts[0], 4.95, '2023-01-01', '2023-10-31']])
+
+ # This shouldn't raise an error like this 'The total of debits equals $4.96 and the total of credits equals $4.95.'
+ generated_entries = self.generate_deferral_entries(self.get_options('2023-01-01', '2023-01-31'))
+
+ deferral_move = generated_entries[0]
+ expected_values = [
+ # Account Debit Credit
+ [self.expense_accounts[0], 0, 4.95],
+ [self.expense_accounts[0], 0.50, 0],
+ [self.deferral_account, 4.46, 0],
+ [self.deferral_account, 0, 0.01],
+ ]
+ # The balance line was added for the rounding error
+ self.assert_invoice_lines(deferral_move, expected_values)
+
+ def test_deferred_fully_inside_report_period(self):
+ """
+ If the invoice is fully inside the report period, nothing should be generated.
+ """
+ self.company.generate_deferred_expense_entries_method = 'manual'
+ self.company.deferred_expense_amount_computation_method = 'month'
+
+ # The report should be empty because the invoice date, and the deferred dates are all in inside the report period
+ # Nothing should be reversed, displayed or generated because the invoice is already in the correct period
+ move1 = self.create_invoice([[self.expense_accounts[0], 600, '2023-01-15', '2023-01-30']], invoice_date='2023-01-15')
+ options_january = self.get_options('2023-01-01', '2023-01-31')
+ lines = self.get_lines(options_january)
+ self.assertLinesValues(
+ lines,
+ # Name Total Before Current Later
+ [ 0, 1, 2, 3, 4 ],
+ [],
+ options_january,
+ )
+ with self.assertRaisesRegex(UserError, 'No entry to generate.'):
+ self.generate_deferral_entries(options_january)
+
+ move1.button_cancel()
+
+ # The report should be NOT empty because the invoice date is prior the report date (even if
+ # the deferred dates are all in inside the report period) because we need to be able to
+ # reverse the invoice in january and account for it in the correct period (in february here).
+ self.create_invoice([[self.expense_accounts[0], 1000, '2023-02-10', '2023-02-28']], invoice_date='2023-01-01')
+
+ # In january, the invoice exists, and the 'To defer' (Later) column should not be empty
+ lines = self.get_lines(options_january)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1000, 1000, 0, 0, 1000 ),
+ ('Total', 1000, 1000, 0, 0, 1000 ),
+ ],
+ options_january,
+ )
+
+ # The invoice should be reversed in january so that it can be accounted for in february
+ generated_entries = self.generate_deferral_entries(options_january)
+
+ deferral_move_january = generated_entries[0]
+ self.assertEqual(deferral_move_january.date, fields.Date.to_date('2023-01-31'))
+ expected_values_january = [
+ # Account Debit Credit
+ [self.expense_accounts[0], 0, 1000],
+ [self.deferral_account, 1000, 0],
+ ]
+ self.assert_invoice_lines(deferral_move_january, expected_values_january)
+
+ # In february, the invoice exists, and is being accounted for (Current column)
+ options_february = self.get_options('2023-02-01', '2023-02-28')
+ lines = self.get_lines(options_february)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1000, 0, 0, 1000, 0 ),
+ ('Total', 1000, 0, 0, 1000, 0 ),
+ ],
+ options_february,
+ )
+
+ # The invoice is now accounted for in february, so nothing should be generated
+ with self.assertRaisesRegex(UserError, 'No entry to generate.'):
+ self.generate_deferral_entries(options_february)
+
+ def test_deferred_same_date(self):
+ """
+ A bug has been found where having an invoice with a start and end date on the last day
+ of the month would cause a division by zero error. This test ensures that this bug is fixed.
+ Here, the deferred dates are not inside the report period.
+ """
+ self.company.generate_deferred_expense_entries_method = 'manual'
+ self.company.deferred_expense_amount_computation_method = 'month'
+ self.create_invoice([[self.expense_accounts[0], 1000, '2023-10-30', '2023-10-30']])
+
+ options_sept = self.get_options('2023-09-01', '2023-09-30')
+ lines = self.get_lines(options_sept)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1000, 1000, 0, 0, 1000 ),
+ ('Total', 1000, 1000, 0, 0, 1000 ),
+ ],
+ options_sept,
+ )
+
+ generated_entries = self.generate_deferral_entries(options_sept)
+
+ deferral_move = generated_entries[0]
+ self.assertEqual(deferral_move.date, fields.Date.to_date('2023-09-30'))
+ expected_values = [
+ # Account Debit Credit
+ [self.expense_accounts[0], 0, 1000],
+ [self.deferral_account, 1000, 0],
+ ]
+ self.assert_invoice_lines(deferral_move, expected_values)
+
+ def test_deferred_expense_change_grouped_entries_method(self):
+ """
+ Test the change of the deferred expense method from on_validation to manual
+ """
+ self.company.generate_deferred_expense_entries_method = 'on_validation'
+
+ self.create_invoice([self.expense_lines[0]])
+ self.assertEqual(self.env['account.move.line'].search_count([('account_id', '=', self.deferral_account.id)]), 5) # 4 months + 1 for the initial deferred invoice
+
+ # When changing the method to manual, the deferred entries should not be re-generated
+ self.company.generate_deferred_expense_entries_method = 'manual'
+ with self.assertRaisesRegex(UserError, 'No entry to generate.'):
+ self.generate_deferral_entries(self.get_options('2023-02-01', '2023-02-28'))
+
+ def test_deferred_expense_manual_generation_totally_deferred(self):
+ """
+ In manual mode generation, if the lines are totally deferred,
+ then no entry should be generated.
+ """
+ self.company.generate_deferred_expense_entries_method = 'manual'
+
+ self.create_invoice([[self.expense_accounts[0], 1000, '2023-01-01', '2023-04-30']])
+ self.generate_deferral_entries(self.get_options('2023-03-01', '2023-03-31'))
+ self.assertEqual(self.env['account.move.line'].search_count([('account_id', '=', self.deferral_account.id)]), 2)
+
+ with self.assertRaisesRegex(UserError, 'No entry to generate.'):
+ self.generate_deferral_entries(self.get_options('2023-04-01', '2023-04-30'))
+
+ move2 = self.create_invoice([[self.expense_accounts[1], 1000, '2023-01-01', '2023-05-31']])
+ generated_entry = self.generate_deferral_entries(self.get_options('2023-04-01', '2023-04-30'))[0]
+ self.assertEqual(len(generated_entry.line_ids), 3) # 3 lines, not 6 because move1 is totally deferred
+ self.assertEqual(generated_entry.deferred_original_move_ids, move2) # not move1
+
+ def test_deferred_expense_manual_generation_only_posted(self):
+ """
+ In manual mode generation, even if the filter shows draft moves,
+ then no entry should be generated for draft moves.
+ """
+ self.company.generate_deferred_expense_entries_method = 'manual'
+
+ self.create_invoice([self.expense_lines[0]], post=False)
+ options = self.get_options('2023-01-01', '2023-01-31')
+ options['all_entries'] = True
+ with self.assertRaisesRegex(UserError, 'No entry to generate.'):
+ self.generate_deferral_entries(options)
+
+ def test_deferred_expense_manual_generation_after_on_validation(self):
+ """
+ In manual mode, you should still be able to generate an deferral entry for a period
+ when there already exists a deferral entry from a former on_validation mode on the same date,
+ and that entry should not defer the already deferred amount from the automatic entry in that
+ same period.
+ """
+ # First post an invoice with the on_validation method, creating deferrals
+ move = self.create_invoice([self.expense_lines[0]])
+ # Check that the deferral moves have been created (1 + 4)
+ self.assertEqual(len(move.deferred_move_ids), 5)
+
+ # Switch to manual mode
+ self.company.generate_deferred_expense_entries_method = 'manual'
+ self.create_invoice([self.expense_lines[0]])
+
+ options = self.get_options('2023-01-01', '2023-01-31')
+ generated_entries_jan = self.generate_deferral_entries(options)
+
+ deferred_move_jan = generated_entries_jan[0]
+ self.assertRecordValues(deferred_move_jan, [{
+ 'move_type': 'entry',
+ 'date': fields.Date.to_date('2023-01-31'),
+ }])
+ expected_values_jan = [
+ # Account Debit Credit
+ [self.expense_accounts[0], 0, 1000],
+ [self.expense_accounts[0], 250, 0],
+ [self.deferral_account, 750, 0],
+ ]
+ self.assert_invoice_lines(deferred_move_jan, expected_values_jan)
+
+ # Reversal
+ deferred_inverse_jan = generated_entries_jan[1]
+ self.assertRecordValues(deferred_inverse_jan, [{
+ 'move_type': 'entry',
+ 'date': fields.Date.to_date('2023-02-01'),
+ }])
+ expected_values_inverse_jan = [
+ # Account Debit Credit
+ [self.expense_accounts[0], 1000, 0],
+ [self.expense_accounts[0], 0, 250],
+ [self.deferral_account, 0, 750],
+ ]
+ self.assert_invoice_lines(deferred_inverse_jan, expected_values_inverse_jan)
+
+ def test_deferred_expense_manual_generation_old_moves(self):
+ """Test that old moves are not taken into account when generating deferred entries."""
+ self.company.generate_deferred_expense_entries_method = 'manual'
+ self.company.deferred_expense_amount_computation_method = 'month'
+
+ self.create_invoice([(self.expense_accounts[0], 1200, '2022-01-01', '2022-12-31')])
+ self.create_invoice([(self.expense_accounts[0], 1200, '2023-01-01', '2023-12-31')])
+
+ options = self.get_options('2023-03-01', '2023-03-31')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1200, 0, 200, 100, 900 ),
+ ('Total', 1200, 0, 200, 100, 900 ),
+ ],
+ options,
+ )
+
+ expected_values = (
+ # Account Debit Credit
+ (self.expense_accounts[0], 0, 1200),
+ (self.expense_accounts[0], 300, 0),
+ (self.deferral_account, 900, 0),
+ )
+ deferral = self.generate_deferral_entries(options)[0]
+ self.assert_invoice_lines(deferral, expected_values)
+
+ def test_deferred_expense_manual_generation_deprecated_account(self):
+ """Test that deferred on deprecated accounts are still visible in the report, but cannot be generated."""
+ self.company.generate_deferred_expense_entries_method = 'manual'
+ self.company.deferred_expense_amount_computation_method = 'month'
+
+ self.create_invoice([self.expense_lines[0]])
+ self.expense_accounts[0].deprecated = True
+
+ options = self.get_options('2023-03-01', '2023-03-31')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1000, 0, 500, 250, 250 ),
+ ('Total', 1000, 0, 500, 250, 250 ),
+ ],
+ options,
+ )
+
+ # Shouldn't raise
+ # 'The account ... is deprecated.'
+ # or 'A line of this move is using a deprecated account, you cannot post it.'
+ entries = self.generate_deferral_entries(options)
+ expected_values = [
+ # Account Debit Credit
+ [self.expense_accounts[0], 0, 1000],
+ [self.expense_accounts[0], 750, 0],
+ [self.deferral_account, 250, 0],
+ ]
+ self.assert_invoice_lines(entries[0], expected_values)
+
+ def test_deferred_expense_manual_generation_go_backwards_in_time(self):
+ """
+ In manual mode generation, if we generate the deferral entries for
+ a given month, we should still be able to generate the entries for
+ the months prior to this one.
+ """
+ self.company.deferred_expense_amount_computation_method = 'month'
+ self.company.generate_deferred_expense_entries_method = 'manual'
+
+ # No entries yet for August
+ options_august = self.get_options('2023-08-01', '2023-08-31')
+ with self.assertRaisesRegex(UserError, 'No entry to generate.'):
+ self.generate_deferral_entries(options_august)
+
+ self.create_invoice([[self.expense_accounts[0], 600, '2023-07-01', '2023-09-30']])
+
+ # Check that no deferred move has been created yet
+ self.assertEqual(self.env['account.move.line'].search_count([('account_id', '=', self.deferral_account.id)]), 0)
+
+ # Generate the grouped deferred entries for August (before doing it for July)
+ generated_entries_august = self.generate_deferral_entries(options_august)
+ deferred_move_august = generated_entries_august[0]
+ expected_values_august = (
+ # Account Debit Credit
+ (self.expense_accounts[0], 0, 600),
+ (self.expense_accounts[0], 400, 0),
+ (self.deferral_account, 200, 0),
+ )
+ self.assert_invoice_lines(deferred_move_august, expected_values_august)
+
+ # Don't re-generate entries for the same period if they already exist for all move lines
+ with self.assertRaisesRegex(UserError, 'No entry to generate.'):
+ self.generate_deferral_entries(options_august)
+
+ # Generate the grouped deferred entries for July
+ options_july = self.get_options('2023-07-01', '2023-07-31')
+ generated_entries_july = self.generate_deferral_entries(options_july)
+ self.assertRecordValues(generated_entries_july, [{
+ 'state': 'posted',
+ 'move_type': 'entry',
+ 'date': fields.Date.to_date('2023-07-31'),
+ }, {
+ 'state': 'posted',
+ 'move_type': 'entry',
+ 'date': fields.Date.to_date('2023-08-01'),
+ }])
+ deferred_move_july, reversed_deferred_move_july = generated_entries_july
+ expected_values_july = (
+ # Account Debit Credit
+ (self.expense_accounts[0], 0, 600),
+ (self.expense_accounts[0], 200, 0),
+ (self.deferral_account, 400, 0),
+ )
+ expected_values_july_reversed = (
+ # Account Debit Credit
+ (self.expense_accounts[0], 600, 0),
+ (self.expense_accounts[0], 0, 200),
+ (self.deferral_account, 0, 400),
+ )
+ self.assert_invoice_lines(deferred_move_july, expected_values_july)
+ self.assert_invoice_lines(reversed_deferred_move_july, expected_values_july_reversed)
+
+ # Don't re-generate entries for the same period if they already exist for all move lines
+ with self.assertRaisesRegex(UserError, 'No entry to generate.'):
+ self.generate_deferral_entries(options_july)
+
+ def test_deferred_expense_manual_generation_single_period(self):
+ """
+ If we have an invoice covering only one period, we should only avoid creating deferral entries when the invoice
+ date is the same as the period for the deferral. Otherwise we should still generate a deferral entry.
+ """
+ self.company.deferred_expense_amount_computation_method = 'month'
+ self.company.generate_deferred_expense_entries_method = 'manual'
+
+ self.create_invoice([[self.expense_accounts[0], 1000, '2023-02-01', '2023-02-28']])
+
+ self.generate_deferral_entries(self.get_options('2023-01-01', '2023-01-31'))
+ self.assertEqual(self.env['account.move.line'].search_count([('account_id', '=', self.deferral_account.id)]), 2)
+
+ def test_deferred_expense_generation_lock_date(self):
+ """
+ Test that we cannot generate entries for a period that is locked.
+ """
+ self.company.deferred_expense_amount_computation_method = 'month'
+ self.company.generate_deferred_expense_entries_method = 'manual'
+
+ move = self.create_invoice([[self.expense_accounts[0], 1000, '2023-01-01', '2023-04-30']])
+
+ move.company_id.fiscalyear_lock_date = fields.Date.to_date('2023-02-28')
+
+ with self.assertRaisesRegex(UserError, 'You cannot generate entries for a period that is locked.'):
+ self.generate_deferral_entries(self.get_options('2023-01-01', '2023-01-31'))
+
+ def test_deferred_expense_manual_generation_reset_to_draft(self):
+ """Test that the deferred entries cannot be deleted in the manual mode"""
+
+ # On validation, we can reset to draft
+ self.company.deferred_expense_amount_computation_method = 'month'
+ move = self.create_invoice([(self.expense_accounts[0], 1680, '2023-01-21', '2023-04-14')])
+ self.assertEqual(len(move.deferred_move_ids), 5)
+ move.button_draft()
+ self.assertFalse(move.deferred_move_ids)
+ move.action_post() # Repost
+
+ # Let's switch to manual mode
+ self.company.generate_deferred_expense_entries_method = 'manual'
+
+ # We should still be able to reset to draft a move that was created with the on_validation mode
+ move.button_draft()
+ self.assertFalse(move.deferred_move_ids)
+
+ # If the grouped deferral entry is the aggregation of only one invoice, we can reset the invoice to draft
+ move3 = self.create_invoice([(self.expense_accounts[0], 1680, '2023-03-21', '2023-06-14')])
+ self.generate_deferral_entries(self.get_options('2023-03-01', '2023-03-31'))
+ move3.button_draft()
+ self.assertFalse(move.deferred_move_ids)
+
+ # If the grouped deferral entry is the aggregation of more than one invoice, we cannot reset to draft any of those to draft
+ move4 = self.create_invoice([(self.expense_accounts[0], 1680, '2023-03-21', '2023-06-14')])
+ move5 = self.create_invoice([(self.expense_accounts[0], 1680, '2023-03-21', '2023-06-14')])
+ self.generate_deferral_entries(self.get_options('2023-03-01', '2023-03-31'))
+ with self.assertRaisesRegex(UserError, 'You cannot reset to draft an invoice that is grouped in deferral entry. You can create a credit note instead.'):
+ move4.button_draft()
+ with self.assertRaisesRegex(UserError, 'You cannot reset to draft an invoice that is grouped in deferral entry. You can create a credit note instead.'):
+ move5.button_draft()
+
+ def test_deferred_expense_on_validation_generation_analytic_distribution(self):
+ """Test that the analytic distribution of the invoice is transferred the deferred entries generated on validation."""
+
+ analytic_plan_a = self.env['account.analytic.plan'].create({
+ 'name': 'Plan A',
+ })
+ aa_a1 = self.env['account.analytic.account'].create({
+ 'name': 'Account A1',
+ 'plan_id': analytic_plan_a.id
+ })
+ aa_a2 = self.env['account.analytic.account'].create({
+ 'name': 'Account A2',
+ 'plan_id': analytic_plan_a.id
+ })
+ move = self.create_invoice([self.expense_lines[0]], post=False)
+ move.write({'invoice_line_ids': [
+ Command.update(move.invoice_line_ids.id, {'analytic_distribution': {str(aa_a1.id): 60.0, str(aa_a2.id): 40.0}}),
+ ]})
+ self.env['account.move.line'].flush_model()
+ move.action_post()
+ expected_analytic_distribution = {str(aa_a1.id): 60.0, str(aa_a2.id): 40.0}
+ for line in move.deferred_move_ids.line_ids:
+ self.assertEqual(line.analytic_distribution, expected_analytic_distribution)
+
+ def test_deferred_expense_manual_generation_analytic_distribution(self):
+ """
+ When using the manually & grouped method, the analytic distribution of the deferred entries
+ should be computed according to the proportion between the deferred amount of each account
+ and the total deferred amount.
+ """
+ self.company.generate_deferred_expense_entries_method = 'manual'
+
+ analytic_plan_a = self.env['account.analytic.plan'].create({
+ 'name': 'Plan A',
+ })
+ analytic_plan_b = self.env['account.analytic.plan'].create({
+ 'name': 'Plan B',
+ })
+ aa_a1 = self.env['account.analytic.account'].create({
+ 'name': 'Account A1',
+ 'plan_id': analytic_plan_a.id
+ })
+ aa_a2 = self.env['account.analytic.account'].create({
+ 'name': 'Account A2',
+ 'plan_id': analytic_plan_a.id
+ })
+ aa_a3 = self.env['account.analytic.account'].create({
+ 'name': 'Account A3',
+ 'plan_id': analytic_plan_a.id
+ })
+ aa_b1 = self.env['account.analytic.account'].create({
+ 'name': 'Account B1',
+ 'plan_id': analytic_plan_b.id
+ })
+ aa_b2 = self.env['account.analytic.account'].create({
+ 'name': 'Account B2',
+ 'plan_id': analytic_plan_b.id
+ })
+ # Test the account move lines with the following analytic distribution:
+ #
+ # # | Expense Account | Amount | Analytic Distribution
+ # ------------------------------------------------------------------------
+ # 1 | Expense 0 | 1200 | {A1: 60%, A2: 40%, B1: 50%, B2: 50%}
+ # 2 | Expense 0 | 2400 | {A1: 100%}
+ # 3 | Expense 1 | 1200 | {A1: 50%, A3: 50%}
+ # 4 | Expense 1 | 1200 | NULL
+
+ invoice_lines = [
+ [self.expense_accounts[0], 1200, '2023-01-01', '2023-12-31'],
+ [self.expense_accounts[0], 2400, '2023-01-01', '2023-12-31'],
+ [self.expense_accounts[1], 1200, '2023-01-01', '2023-12-31'],
+ [self.expense_accounts[1], 1200, '2023-01-01', '2023-12-31'],
+ ]
+ move = self.create_invoice(invoice_lines, post=False)
+ move.write({'invoice_line_ids': [
+ Command.update(move.invoice_line_ids[0].id,
+ {'analytic_distribution': {str(aa_a1.id): 60.0, str(aa_a2.id): 40.0, str(aa_b1.id): 50.0, str(aa_b2.id): 50.0}}
+ ),
+ Command.update(move.invoice_line_ids[1].id,{'analytic_distribution': {str(aa_a1.id): 100.0}}),
+ Command.update(move.invoice_line_ids[2].id, {'analytic_distribution': {str(aa_a1.id): 50.0, str(aa_a3.id): 50.0}}),
+ ]})
+ self.env['account.move.line'].flush_model()
+ move.action_post()
+
+ # Generate the grouped deferred entries
+ generated_entries = self.generate_deferral_entries(self.get_options('2023-01-01', '2023-08-31'))
+
+ # Details of the computation:
+ # Total Amount (Deferral Account): 1200 + 2400 + 1200 + 1200 = 6000
+ # Amount for Expense 0: 1200 + 2400 = 3600
+ # Amount for Expense 1: 1200 + 1200 = 2400
+ # ___________________________________________________________________________________________________________________
+ # | | | | Distribution by expense (*) | Deferral distribution (**)
+ # # | Amount | Expense ratio | Total ratio | (distribution / expense ratio) | (distribution / total ratio)
+ # -------------------------------------------------------------------------------------------------------------------
+ # 1 | 1200 | 1200 / 3600 = 0.33 | 1200 / 6000 = 0.2 | A1 = 60% * 0.33 = 20% | A1 = 60% * 0.2 = 12%
+ # | | (0.33333333333...) | | A2 = 40% * 0.33 = 13.33% | A2 = 40% * 0.2 = 8%
+ # | | | | B1, B2 = 50% * 0.33 = 16.67% | B1, B2 = 50% * 0.2 = 10%
+ # -------------------------------------------------------------------------------------------------------------------
+ # 2 | 1200 | 2400 / 3600 = 0.67 | 2400 / 6000 = 0.4 | A1 = 100% * 0.67 = 66.67% | A1 = 100% * 0.4 = 40%
+ # -------------------------------------------------------------------------------------------------------------------
+ # 3 | 1200 | 1200 / 2400 = 0.5 | 1200 / 6000 = 0.2 | A1, A3 = 50% * 0.5 = 25% | A1, A3 = 50% * 0.2 = 10%
+ # -------------------------------------------------------------------------------------------------------------------
+ # 4 | 1200 | 1200 / 2400 = 0.5 | 1200 / 6000 = 0.2 | NULL | NULL
+ #
+ # The analytic distribution of the deferred entries should be:
+ # - Expense 0: {A1: 86.67%, A2: 13.33%, B1: 16.67%, B2: 16.67%} [Sum of column (*) of line #1 and #2]
+ # - Expense 1: {A1: 25%, A3: 25%} [Sum of column (*) of line #3 and #4]
+ # - Deferral Account: {A1: 62%, A2: 8%, B1: 10%, B2: 10%, A3: 10%} [Sum of column (**) of all 4 lines]
+ #
+ # The 2 generated entries should be the "Grouped Deferral Entry of Aug 2023" and its reversal.
+ #
+ # "Grouped Deferral Entry of Aug 2023":
+ #
+ # Account | Analytic Distribution | Debit | Credit
+ # -------------------------------------------------------------------------------------
+ # Expense 0 | {A1: 86.67%, A2: 13.33%, B1: 16.67%, B2: 16.67%} | 3600 | 0
+ # Expense 0 | {A1: 86.67%, A2: 13.33%, B1: 16.67%, B2: 16.67%} | 0 | 2400
+ # Expense 1 | {A1: 25%, A3: 25%} | 2400 | 0
+ # Expense 1 | {A1: 25%, A3: 25%} | 0 | 1600
+ # Deferral Account | {A1: 62%, A2: 8%, B1: 10%, B2: 10%, A3: 10%} | 0 | 2000
+ # -------------------------------------------------------------------------------------
+ # | 6000 | 6000
+ expected_analytic_distribution = {
+ self.expense_accounts[0].id: {str(aa_a1.id): 86.67, str(aa_a2.id): 13.33, str(aa_b1.id): 16.67, str(aa_b2.id): 16.67},
+ self.expense_accounts[1].id: {str(aa_a1.id): 25.0, str(aa_a3.id): 25.0},
+ self.deferral_account.id: {str(aa_a1.id): 62.0, str(aa_a2.id): 8.0, str(aa_b1.id): 10.0, str(aa_b2.id): 10.0, str(aa_a3.id): 10.0},
+ }
+ expected_analytic_amount = [{
+ aa_a1.id: (3120.12, 0.00), # 3600 * 86.67%
+ aa_a2.id: ( 479.88, 0.00), # 3600 * 13.33%
+ aa_b1.id: ( 0.00, 600.12), # 3600 * 16.67%
+ aa_b2.id: ( 0.00, 600.12), # 3600 * 16.67%
+ }, {
+ aa_a1.id: (-2080.08, 0.00), # -2400 * 86.67%
+ aa_a2.id: ( -319.92, 0.00), # -2400 * 13.33%
+ aa_b1.id: ( 0.00, -400.08), # -2400 * 16.67%
+ aa_b2.id: ( 0.00, -400.08), # -2400 * 16.67%
+ },{
+ aa_a1.id: ( 600.00, 0.00), # 2400 * 25%
+ aa_a3.id: ( 600.00, 0.00), # 2400 * 25%
+ },{
+ aa_a1.id: ( -400.00, 0.00), # -1600 * 25%
+ aa_a3.id: ( -400.00, 0.00), # -1600 * 25%
+ }, {
+ aa_a1.id: (-1240.00, 0.00), # -2000 * 62%
+ aa_a2.id: ( -160.00, 0.00), # -2000 * 8%
+ aa_a3.id: ( -200.00, 0.00), # -2000 * 10%
+ aa_b1.id: ( 0.00, -200.00), # -2000 * 10%
+ aa_b2.id: ( 0.00, -200.00), # -2000 * 10%
+ }]
+ # testing the amount of the analytic lines for the "Grouped Deferral Entry of Aug 2023"
+ for index, line in enumerate(generated_entries[0].line_ids):
+ self.assertEqual(line.analytic_distribution, expected_analytic_distribution[line.account_id.id])
+ for al in line.analytic_line_ids:
+ fname_a = analytic_plan_a._column_name()
+ fname_b = analytic_plan_b._column_name()
+ fname, idx = (fname_a, 0) if al[fname_a] else (fname_b, 1)
+ self.assertAlmostEqual(al.amount, expected_analytic_amount[index][al[fname].id][idx])
+ # testing the amount of the analytic lines for the "Reversal of Grouped Deferral Entry of Aug 2023"
+ # the values should be the opposite of the "Grouped Deferral Entry of Aug 2023"
+ for index, line in enumerate(generated_entries[1].line_ids):
+ self.assertEqual(line.analytic_distribution, expected_analytic_distribution[line.account_id.id])
+ for al in line.analytic_line_ids:
+ fname_a = analytic_plan_a._column_name()
+ fname_b = analytic_plan_b._column_name()
+ fname, idx = (fname_a, 0) if al[fname_a] else (fname_b, 1)
+ self.assertAlmostEqual(al.amount, -expected_analytic_amount[index][al[fname].id][idx])
+
+ def test_deferred_revenue_manual_generation_analytic_distribution(self):
+ """
+ Test if deferred revenues have the right analytic distribution when manually generated.
+ """
+ self.company.generate_deferred_expense_entries_method = 'manual'
+ move = self.create_invoice(self.revenue_lines)
+ analytic_plan = self.env['account.analytic.plan'].create({'name': 'Plan'})
+ analytic_account = self.env['account.analytic.account'].create({
+ 'name': 'Account',
+ 'plan_id': analytic_plan.id,
+ })
+ move.invoice_line_ids[0]['analytic_distribution'] = {analytic_account.id: 100}
+ options = self.get_options('2023-02-01', '2023-02-28', report=self.deferred_revenue_report)
+ options['analytic_accounts'] = [analytic_account.id]
+ revenue_handler = self.env['account.deferred.revenue.report.handler']
+ deferral_move = self.generate_deferral_entries(options, report_handler=revenue_handler)[0]
+ analytic_distributions = deferral_move.line_ids.mapped('analytic_distribution')
+ expected_distributions = [{str(analytic_account.id): 100}] * 3
+ self.assertEqual(analytic_distributions, expected_distributions)
+
+ def test_deferred_expense_report_invalid_period(self):
+ """
+ Only periods that start on the first day of a month and end on the last day of a month are allowed.
+ """
+ self.company.generate_deferred_expense_entries_method = 'manual'
+ self.create_invoice([self.expense_lines[0]])
+
+ options = self.get_options('2023-03-01', '2023-03-15')
+ lines = self.get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Total Not Started Before Current Later
+ [ 0, 1, 2, 3, 4, 5 ],
+ [
+ ('EXP0 Expense 0', 1000, 0, 500, 125, 375 ),
+ ('Total', 1000, 0, 500, 125, 375 ),
+ ],
+ options,
+ )
+
+ with self.assertRaisesRegex(UserError, 'You cannot generate entries for a period that does not end at the end of the month.'):
+ self.generate_deferral_entries(options)
+
+ def audit_cell(self, options, line, column_name):
+ audit_cell_res = self.handler.action_audit_cell(options, {
+ 'report_line_id': line['id'],
+ 'calling_line_dict_id': line['id'],
+ 'expression_label': column_name,
+ 'column_group_key': next(iter(options['column_groups'])),
+ })
+ __, account_id = self.deferred_expense_report._get_model_info_from_id(line['id'])
+ return self.env['account.move.line']\
+ .search(audit_cell_res['domain'])\
+ .filtered(lambda l: l.account_id.id == account_id)\
+ .sorted(lambda l: (l.date, not l.deferred_start_date, l.id)) # Order by date then Original lines first (False comes before True, so we negate it)
+
+ def get_audited_line_to_assert(self, date, balance):
+ return {'date': fields.Date.to_date(date), 'balance': balance}
+
+ def test_deferred_expense_report_audit_cell_on_validation_mode(self):
+ """
+ Test that the audit correctly works (when clicking on a cell) in the on_validation mode
+ Amounts should always correspond between the report and the list view of the audited cell
+ """
+ self.company.generate_deferred_expense_entries_method = 'on_validation'
+
+ inv1 = self.create_invoice([[self.expense_accounts[0], 1000, '2023-01-01', '2023-04-30']]) # Basic example, 4 full months, in period
+ inv2 = self.create_invoice([[self.expense_accounts[0], 100, '2023-04-01', '2023-05-31']]) # 2 full months, Not Started yet
+ inv3 = self.create_invoice([
+ [self.expense_accounts[0], 10, '2023-01-01', '2023-02-28'], # 2 full months, fully deferred so shouldn't appear at all
+ [self.expense_accounts[0], 1, '2023-01-01', '2023-05-31'], # 5 full months
+ ])
+ inv4 = self.create_invoice([
+ [self.expense_accounts[0], 10000, '2023-03-01', '2023-04-30'], # 2 full months
+ [self.expense_accounts[0], 1998, '2023-03-01', '2023-03-31'], # fully inside period -> nothing to defer/show/audit
+ ], invoice_date='2023-03-01')
+ options = self.get_options('2023-03-01', '2023-03-31')
+ lines = self.get_lines(options)
+
+ # Total column
+ audit_lines = self.audit_cell(options, lines[0], 'total')
+ self.assertEqual(audit_lines, inv1.invoice_line_ids + inv2.invoice_line_ids + inv3.invoice_line_ids.sorted('id')[1] + inv4.invoice_line_ids.sorted('id')[0])
+
+ # Not Started column
+ audit_lines = self.audit_cell(options, lines[0], 'not_started')
+ self.assertEqual(audit_lines, inv2.invoice_line_ids)
+
+ # Before March 2023
+ audit_lines = self.audit_cell(options, lines[0], 'before').sorted('id')
+ self.assertRecordValues(audit_lines, [
+ # INV 1
+ self.get_audited_line_to_assert('2023-01-01', 1000), # Original line
+ self.get_audited_line_to_assert('2023-01-01', -1000), # Cancel out original line
+ self.get_audited_line_to_assert('2023-01-31', 250), # Jan 23
+ self.get_audited_line_to_assert('2023-02-28', 250), # Feb 23
+
+ # INV 2: although not started, the original and cancel out should still appear in the "before" column
+ self.get_audited_line_to_assert('2023-01-01', 100), # Original line
+ self.get_audited_line_to_assert('2023-01-01', -100), # Cancel out original line
+
+ # INV 3, lines are ordred by id, so first the original lines, then the generated lines
+ # self.get_audited_line_to_assert('2023-01-01', 10), # Original line 1: shouldn't appear because the original line is fully in the past in March
+ self.get_audited_line_to_assert('2023-01-01', 1), # Original line 2
+ # However the reversal of line 1 should appear so that it can cancel out with the deferrals created for Jan & Feb 23
+ self.get_audited_line_to_assert('2023-01-01', -10), # Reversal Original line 1
+ self.get_audited_line_to_assert('2023-01-01', -1), # Cancel out original line 2
+ self.get_audited_line_to_assert('2023-01-31', 5), # Jan 23 for line 1
+ self.get_audited_line_to_assert('2023-02-28', 5), # Feb 23 for line 1
+ self.get_audited_line_to_assert('2023-01-31', 0.2), # Jan 23 for line 2
+ self.get_audited_line_to_assert('2023-02-28', 0.2), # Feb 23 for line 2
+ ])
+ # Total = 1000 - 1000 + 250 + 250 + 100 - 100 + 1 - 10 + 5 + 5 - 1 + 0.2 + 0.2 = 500.4 = (2 * 250) + (2 * 0.2) for Jan+Feb 23
+
+ # March 2023
+ audit_lines = self.audit_cell(options, lines[0], 'current')
+ self.assertRecordValues(audit_lines, [
+ self.get_audited_line_to_assert('2023-03-01', 10000), # inv4 line 1 original
+ self.get_audited_line_to_assert('2023-03-01', -10000), # inv4 line 1 reversal
+ self.get_audited_line_to_assert('2023-03-31', 250), # Amount for inv1
+ self.get_audited_line_to_assert('2023-03-31', 0.2), # Amount for inv3 line 2
+ self.get_audited_line_to_assert('2023-03-31', 5000), # Amount for inv4 line 1
+ ])
+ # Total = 10000 - 10000 + 5000 + 250 + 0.2 = 5250.2 for March 23
+
+ # Later than March 2023
+ audit_lines = self.audit_cell(options, lines[0], 'later')
+ self.assertRecordValues(audit_lines, [
+ self.get_audited_line_to_assert('2023-04-30', 250), # Amount for April 23 of Inv 1
+ self.get_audited_line_to_assert('2023-04-30', 50), # Amount for April 23 of Inv 2
+ self.get_audited_line_to_assert('2023-04-30', 0.2), # Amount for April 23 of Inv 3 line 2
+ self.get_audited_line_to_assert('2023-04-30', 5000), # Amount for April 23 of Inv 4 line 1
+ self.get_audited_line_to_assert('2023-05-31', 50), # Amount for May 23 of Inv 2
+ self.get_audited_line_to_assert('2023-05-31', 0.2), # Amount for May 23 of Inv 3 line 2
+ ])
+ # Total = 250 + 50 + 50 + 0.2 + 0.2 + 5000 = 5350.4 = (1 * 250) + (2 * 50) + (2 * 0.2) + (1 * 5000) for April+May 23
+
+ def test_deferred_expense_report_audit_cell_grouped_mode(self):
+ """
+ Test that the audit correctly works (when clicking on a cell) in the grouped mode
+ If the deferrals haven't been generated yet, the report shows the expected amounts
+ which will probably differ from the list view of the audited cell which shows the
+ "candidates" lines that are going to be different upon Generating the Entries.
+ """
+
+ self.company.generate_deferred_expense_entries_method = 'manual'
+
+ inv1 = self.create_invoice([[self.expense_accounts[0], 1000, '2023-01-01', '2023-04-30']]) # Basic example, 4 full months, in period
+ inv2 = self.create_invoice([[self.expense_accounts[0], 100, '2023-04-01', '2023-05-31']]) # 2 full months, Not Started yet
+ inv3 = self.create_invoice([
+ [self.expense_accounts[0], 10, '2023-01-01', '2023-02-28'], # 2 full months, fully deferred so shouldn't appear at all
+ [self.expense_accounts[0], 1, '2023-01-01', '2023-05-31'], # 5 full months
+ ])
+ inv4 = self.create_invoice([
+ [self.expense_accounts[0], 10000, '2023-03-01', '2023-04-30'], # 2 full months
+ [self.expense_accounts[0], 1998, '2023-03-01', '2023-03-31'], # fully inside period -> nothing to defer/show/audit
+ ], invoice_date='2023-03-01')
+ options = self.get_options('2023-03-01', '2023-03-31')
+ lines = self.get_lines(options)
+
+ # At this point, we haven't generated the entries yet, so we show the candidates lines
+ # that will be deferred upon generating the entries (i.e. amounts shown on the reports
+ # will be different from the total amounts shown in the list view of the audited cell)
+
+ # Total column
+ audit_lines = self.audit_cell(options, lines[0], 'total')
+ self.assertEqual(audit_lines, inv1.invoice_line_ids + inv2.invoice_line_ids + inv3.invoice_line_ids.sorted('id')[1] + inv4.invoice_line_ids.sorted('id')[0])
+
+ # Not Started column
+ audit_lines = self.audit_cell(options, lines[0], 'not_started')
+ self.assertEqual(audit_lines, inv2.invoice_line_ids)
+
+ # Before March 2023 (Jan + Feb)
+ audit_lines = self.audit_cell(options, lines[0], 'before')
+ self.assertRecordValues(audit_lines, [
+ {'id': inv1.invoice_line_ids[0].id},
+ {'id': inv3.invoice_line_ids[1].id},
+ ])
+
+ # March 2023
+ audit_lines = self.audit_cell(options, lines[0], 'current')
+ self.assertRecordValues(audit_lines, [
+ {'id': inv1.invoice_line_ids[0].id},
+ {'id': inv3.invoice_line_ids[1].id},
+ {'id': inv4.invoice_line_ids[0].id},
+ ])
+
+ # Later than March 2023 (April + May)
+ audit_lines = self.audit_cell(options, lines[0], 'later')
+ self.assertRecordValues(audit_lines, [
+ {'id': inv1.invoice_line_ids.id},
+ {'id': inv2.invoice_line_ids.id},
+ {'id': inv3.invoice_line_ids.sorted('id')[1].id},
+ {'id': inv4.invoice_line_ids.sorted('id')[0].id},
+ ])
+
+ # Now, let's generate the entries so that the amounts shown in the report match the
+ # totals of the list view of the audited cell. We should generate the entries for the
+ # previous periods too to have the correct amounts in the "Before" column.
+ for report_date_from, report_date_to in (('2023-01-01', '2023-01-31'), ('2023-02-01', '2023-02-28'), ('2023-03-01', '2023-03-31')):
+ self.generate_deferral_entries(self.get_options(report_date_from, report_date_to))
+ lines = self.get_lines(options)
+
+ # Total column
+ audit_lines = self.audit_cell(options, lines[0], 'total')
+ self.assertEqual(audit_lines, inv1.invoice_line_ids + inv2.invoice_line_ids + inv3.invoice_line_ids.sorted('id')[1] + inv4.invoice_line_ids.sorted('id')[0])
+
+ # Not Started column
+ audit_lines = self.audit_cell(options, lines[0], 'not_started')
+ self.assertEqual(audit_lines, inv2.invoice_line_ids)
+
+ # Before March 2023 (Jan + Feb)
+ audit_lines = self.audit_cell(options, lines[0], 'before')
+ self.assertRecordValues(audit_lines[:3], [ # Original lines
+ {'id': inv1.invoice_line_ids.id},
+ {'id': inv2.invoice_line_ids.id},
+ {'id': inv3.invoice_line_ids[1].id},
+ ])
+ self.assertRecordValues(audit_lines[3:], [ # Generated grouping lines
+ # Grouped of Jan 23
+ self.get_audited_line_to_assert('2023-01-31', -1111), # = 1000 + 100 + 10 + 1 for Jan 23 (deferral of 10 is used to compute the grouped deferral of Jan 23)
+ self.get_audited_line_to_assert('2023-01-31', 255.2), # = (1 * 250) + (1 * 5) + (1 * 0.2) for Jan 23
+ # Reversal of Jan 23 (on the next day)
+ self.get_audited_line_to_assert('2023-02-01', 1111),
+ self.get_audited_line_to_assert('2023-02-01', - 255.2),
+ # Grouped of Feb 23
+ self.get_audited_line_to_assert('2023-02-28', -1111), # same as above
+ self.get_audited_line_to_assert('2023-02-28', 510.4), # = (2 * 250) + (2 * 5) + (2 * 0.2) for Jan + Feb 23
+ # Reversal of Feb 23 does not exist in the before period as it is only created on the 1st March
+ ])
+ # Total = 1000 + 100 + 1 - 1111 + 255.2 + 1111 - 255.2 - 1111 + 510.4 = 500.4 for Jan + Feb 23 = (2 * 250) + (2 * 0.2)
+
+ # March 2023
+ audit_lines = self.audit_cell(options, lines[0], 'current')
+ self.assertRecordValues(audit_lines, [
+ self.get_audited_line_to_assert('2023-03-01', 10000), # Inv4 line 1 original
+ # Reversal of grouped of February
+ self.get_audited_line_to_assert('2023-03-01', 1111), # = 1000 + 100 + 10 + 1 for Feb 23 (deferral of 10 is used to compute the grouped deferral of Feb 23)
+ self.get_audited_line_to_assert('2023-03-01', - 510.4), # = - (2 * 250) - (2 * 5) - (2 * 0.2) for Jan + Feb 23
+ # Grouped of current month (March 23)
+ self.get_audited_line_to_assert('2023-03-31', -11101), # Deferral of 10 not included anymore in March
+ self.get_audited_line_to_assert('2023-03-31', 5750.6), # = (3 * 250) + (3 * 0.2)+ (1 * 5000) for Jan + Feb + March 23
+ ])
+ # Total = 10000 + 1111 - 510.4 - 11101 + 5750.6 = 5250.2 for March 23 = 250 + 0.2 + 5000 OK
+
+ # Later than March 2023 (April + May)
+ audit_lines = self.audit_cell(options, lines[0], 'later')
+ self.assertRecordValues(audit_lines, [
+ # Only the Reversal of previous period exist as we've only generated entries up to March and not after
+ self.get_audited_line_to_assert('2023-04-01', 11101),
+ self.get_audited_line_to_assert('2023-04-01', - 5750.6),
+ ])
+ # Total = 11101 - 5750.6 = 5350.2 for April/May = (1 * 250) + (2 * 50) + (2 * 0.2) + (1 * 5000) OK
+
+ # Let's generate for April too (not May as everything is totally deferred in May)
+ # Now we'll have to include inv2 too
+ self.generate_deferral_entries(self.get_options('2023-04-01', '2023-04-30'))
+ audit_lines = self.audit_cell(options, lines[0], 'later')
+ self.assertRecordValues(audit_lines, [
+ # Reversal of grouped of March
+ self.get_audited_line_to_assert('2023-04-01', 11101),
+ self.get_audited_line_to_assert('2023-04-01', - 5750.6),
+ # Grouped of April
+ self.get_audited_line_to_assert('2023-04-30', -11101),
+ self.get_audited_line_to_assert('2023-04-30', 11050.8), # = (4 * 250) + (4 * 0.2) + (1 * 50) + (2 * 5000) for Jan -> April 23
+ # Reversal of April
+ self.get_audited_line_to_assert('2023-05-01', 11101),
+ self.get_audited_line_to_assert('2023-05-01', -11050.8),
+ ])
+ # Total = Just reversal of grouped of March, so same as previous
+
+ # Now, let's create a new bill such that we have a mix of generated deferrals and candidates
+ inv5 = self.create_invoice([[self.expense_accounts[0], 100000, '2023-01-01', '2023-05-31']])
+
+ # Generate deferrals for previous months, but not March 23 yet.
+ for report_date_from, report_date_to in (('2023-01-01', '2023-01-31'), ('2023-02-01', '2023-02-28')):
+ self.generate_deferral_entries(self.get_options(report_date_from, report_date_to))
+
+ options = self.get_options('2023-03-01', '2023-03-31')
+ lines = self.get_lines(options)
+
+ # Total column
+ audit_lines = self.audit_cell(options, lines[0], 'total')
+ self.assertEqual(audit_lines, inv1.invoice_line_ids + inv2.invoice_line_ids
+ + inv3.invoice_line_ids.sorted('id')[1] + inv4.invoice_line_ids.sorted('id')[0]
+ + inv5.invoice_line_ids)
+
+ # Not Started column
+ audit_lines = self.audit_cell(options, lines[0], 'not_started')
+ self.assertEqual(audit_lines, inv2.invoice_line_ids)
+
+ # Before March 2023 (Jan + Feb)
+ audit_lines = self.audit_cell(options, lines[0], 'before')
+ self.assertRecordValues(audit_lines[:4], [ # Original lines
+ {'id': inv1.invoice_line_ids.id},
+ {'id': inv2.invoice_line_ids.id},
+ {'id': inv3.invoice_line_ids[1].id},
+ {'id': inv5.invoice_line_ids.id}, # new
+ ])
+ self.assertRecordValues(audit_lines[4:], [ # Generated grouping lines
+ # Grouped of Jan 23
+ self.get_audited_line_to_assert('2023-01-31', - 1111), # inv1,2,3,4 = 1000 + 100 + 10 + 1 for Jan 23 (deferral of 10 is used to compute the grouped deferral of Jan 23)
+ self.get_audited_line_to_assert('2023-01-31', 255.2), # = (1 * 250) + (1 * 5) + (1 * 0.2) for Jan 23
+ self.get_audited_line_to_assert('2023-01-31', - 100000), # just inv5
+ self.get_audited_line_to_assert('2023-01-31', 20000),
+ # Reversal of Jan 23 (on the next day)
+ self.get_audited_line_to_assert('2023-02-01', 1111),
+ self.get_audited_line_to_assert('2023-02-01', - 255.2),
+ self.get_audited_line_to_assert('2023-02-01', 100000),
+ self.get_audited_line_to_assert('2023-02-01', - 20000),
+ # Grouped of Feb 23
+ self.get_audited_line_to_assert('2023-02-28', - 1111), # same as above
+ self.get_audited_line_to_assert('2023-02-28', 510.4), # = (2 * 250) + (2 * 5) + (2 * 0.2) for Jan + Feb 23
+ self.get_audited_line_to_assert('2023-02-28', - 100000),
+ self.get_audited_line_to_assert('2023-02-28', 40000), # inv5: 2 * 20000 for Jan + Feb 23
+ # Reversal of Feb 23 does not exist in the before period as it is only created on the 1st March
+ ])
+ # Subtotal inv1,2,3 = 1000 + 100 + 1 (originals) - 1111 + 255.2 + 1111 - 255.2 - 1111 + 510.4 = 500.4 for Jan + Feb 23 = (2 * 250) + (2 * 0.2)
+ # Subtotal inv4 = 0 as it does not exist yet
+ # Subtotal inv5 = 10000 (original) -10000 + 2000 + 10000 - 2000 - 10000 + 4000 = 4000
+ # Total = 4000 + 500.4 = 4500.4
+
+ # March 2023
+ audit_lines = self.audit_cell(options, lines[0], 'current')
+ self.assertRecordValues(audit_lines, [
+ self.get_audited_line_to_assert('2023-03-01', 10000), # Inv4 line 1 original
+ # Reversal of grouped of February
+ self.get_audited_line_to_assert('2023-03-01', 1111), # inv1,2,3
+ self.get_audited_line_to_assert('2023-03-01', - 510.4),
+ self.get_audited_line_to_assert('2023-03-01', 100000), # inv5
+ self.get_audited_line_to_assert('2023-03-01', - 40000),
+ # Grouped of current month (March 23)
+ self.get_audited_line_to_assert('2023-03-31', - 11101), # inv1,2,3 + 4
+ self.get_audited_line_to_assert('2023-03-31', 5750.6),
+ # Nothing for inv5 as we've not yet generated for March since its creation
+ ])
+ # Total = 10000 + 1111 - 510.4 + 100000 - 40000 - 11101 + 5750.6 = 65 250.2
+ # The total of the list view is different from the cell (25 250.2 = inv1 + inv3.1 + inv4 + inv5) so we directly see that
+ # there is a problem and the deferrals are not completely generated.
+ # The difference is 40 000, so we know that inv5 is not yet generated
+
+ # Generate for March 23
+ self.generate_deferral_entries(self.get_options('2023-03-01', '2023-03-31'))
+
+ audit_lines = self.audit_cell(options, lines[0], 'current')
+ self.assertRecordValues(audit_lines, [
+ self.get_audited_line_to_assert('2023-03-01', 10000), # Inv4 line 1 original
+ # Reversal of grouped of February
+ self.get_audited_line_to_assert('2023-03-01', 1111), # inv1,2,3
+ self.get_audited_line_to_assert('2023-03-01', - 510.4),
+ self.get_audited_line_to_assert('2023-03-01', 100000), # inv5
+ self.get_audited_line_to_assert('2023-03-01', - 40000),
+ # Grouped of current month (March 23)
+ self.get_audited_line_to_assert('2023-03-31', - 11101), # inv1,2,3 + 4
+ self.get_audited_line_to_assert('2023-03-31', 5750.6),
+ self.get_audited_line_to_assert('2023-03-31', - 100000), # inv5
+ self.get_audited_line_to_assert('2023-03-31', 60000),
+ ])
+ # Total = 10000 + 1111 - 510.4 + 100000 - 40000 - 11101 + 5750.6 - 100000 + 60000 = 25250.2
+ # The total of the list view is now the same as from the cell (25 250.2 = inv1 + inv3.1 + inv4 + inv5)
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_financial_report.py b/dev_odex30_accounting/odex30_account_reports/tests/test_financial_report.py
new file mode 100644
index 0000000..e48e32c
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_financial_report.py
@@ -0,0 +1,919 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=C0326
+
+from .common import TestAccountReportsCommon
+
+from odoo import fields, Command
+from odoo.tests import tagged
+
+from freezegun import freeze_time
+
+
+@tagged('post_install', '-at_install')
+class TestFinancialReport(TestAccountReportsCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ # ==== Partners ====
+ cls.partner_c = cls._create_partner(name='partner_c')
+
+ # ==== Accounts ====
+
+ # Cleanup existing "Current year earnings" accounts since we can only have one by company.
+ cls.env['account.account'].search([
+ ('company_ids', 'in', (cls.company_data['company'] + cls.company_data_2['company']).ids),
+ ('account_type', '=', 'equity_unaffected'),
+ ]).unlink()
+
+ account_type_data = [
+ ('asset_receivable', {'reconcile': True}),
+ ('liability_payable', {'reconcile': True}),
+ ('asset_cash', {}),
+ ('asset_current', {}),
+ ('asset_prepayments', {}),
+ ('asset_fixed', {}),
+ ('asset_non_current', {}),
+ ('equity', {}),
+ ('equity_unaffected', {}),
+ ('income', {}),
+ ]
+
+ accounts = cls.env['account.account'].create([{
+ **data[1],
+ 'name': 'account%s' % i,
+ 'code': 'code%s' % i,
+ 'account_type': data[0],
+ } for i, data in enumerate(account_type_data)])
+
+ accounts_2 = cls.env['account.account'].create([{
+ **data[1],
+ 'name': 'account%s' % (i + 100),
+ 'code': 'code%s' % (i + 100),
+ 'account_type': data[0],
+ 'company_ids': [Command.link(cls.company_data_2['company'].id)]
+ } for i, data in enumerate(account_type_data)])
+
+ for account in accounts_2:
+ account.code = account.with_company(cls.company_data_2['company']).code
+
+ # ==== Custom filters ====
+
+ cls.horizontal_group = cls.env['account.report.horizontal.group'].create({
+ 'name': 'Horizontal Group',
+ 'rule_ids': [
+ Command.create({
+ 'field_name': 'partner_id',
+ 'domain': f"[('id', 'in', {(cls.partner_a + cls.partner_b).ids})]",
+ }),
+ Command.create({
+ 'field_name': 'account_id',
+ 'domain': f"[('id', 'in', {accounts[:2].ids})]",
+ }),
+ ],
+ })
+
+ # ==== Journal entries ====
+
+ cls.move_2019 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2019-01-01'),
+ 'line_ids': [
+ (0, 0, {'debit': 25.0, 'credit': 0.0, 'account_id': accounts[0].id, 'partner_id': cls.partner_a.id}),
+ (0, 0, {'debit': 25.0, 'credit': 0.0, 'account_id': accounts[0].id, 'partner_id': cls.partner_b.id}),
+ (0, 0, {'debit': 25.0, 'credit': 0.0, 'account_id': accounts[0].id, 'partner_id': cls.partner_c.id}),
+ (0, 0, {'debit': 25.0, 'credit': 0.0, 'account_id': accounts[0].id, 'partner_id': cls.partner_a.id}),
+ (0, 0, {'debit': 200.0, 'credit': 0.0, 'account_id': accounts[1].id, 'partner_id': cls.partner_b.id}),
+ (0, 0, {'debit': 0.0, 'credit': 300.0, 'account_id': accounts[2].id, 'partner_id': cls.partner_c.id}),
+ (0, 0, {'debit': 400.0, 'credit': 0.0, 'account_id': accounts[3].id, 'partner_id': cls.partner_a.id}),
+ (0, 0, {'debit': 0.0, 'credit': 1100.0, 'account_id': accounts[4].id, 'partner_id': cls.partner_b.id}),
+ (0, 0, {'debit': 700.0, 'credit': 0.0, 'account_id': accounts[6].id, 'partner_id': cls.partner_a.id}),
+ (0, 0, {'debit': 0.0, 'credit': 800.0, 'account_id': accounts[7].id, 'partner_id': cls.partner_b.id}),
+ (0, 0, {'debit': 800.0, 'credit': 0.0, 'account_id': accounts[8].id, 'partner_id': cls.partner_c.id}),
+ ],
+ })
+ cls.move_2019.action_post()
+
+ cls.move_2018 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2018-01-01'),
+ 'line_ids': [
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'account_id': accounts[0].id, 'partner_id': cls.partner_a.id}),
+ (0, 0, {'debit': 0.0, 'credit': 1000.0, 'account_id': accounts[2].id, 'partner_id': cls.partner_b.id}),
+ (0, 0, {'debit': 250.0, 'credit': 0.0, 'account_id': accounts[0].id, 'partner_id': cls.partner_a.id}),
+ (0, 0, {'debit': 0.0, 'credit': 250.0, 'account_id': accounts[9].id, 'partner_id': cls.partner_a.id}),
+ ],
+ })
+ cls.move_2018.action_post()
+
+ cls.move_2017 = cls.env['account.move'].with_company(cls.company_data_2['company']).create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2017-01-01'),
+ 'line_ids': [
+ (0, 0, {'debit': 2000.0, 'credit': 0.0, 'account_id': accounts_2[0].id, 'partner_id': cls.partner_a.id}),
+ (0, 0, {'debit': 0.0, 'credit': 4000.0, 'account_id': accounts_2[2].id, 'partner_id': cls.partner_b.id}),
+ (0, 0, {'debit': 0.0, 'credit': 5000.0, 'account_id': accounts_2[4].id, 'partner_id': cls.partner_c.id}),
+ (0, 0, {'debit': 7000.0, 'credit': 0.0, 'account_id': accounts_2[6].id, 'partner_id': cls.partner_a.id}),
+ ],
+ })
+ cls.move_2017.action_post()
+
+ cls.report = cls.env.ref('odex30_account_reports.balance_sheet')
+
+ cls.report_no_parent_id = cls.env["account.report"].create({
+ 'name': "Test report",
+
+ 'column_ids': [
+ Command.create({
+ 'name': 'Balance',
+ 'expression_label': 'balance',
+ 'sequence': 1
+ })
+ ],
+
+ 'line_ids': [
+ Command.create({
+ 'name': "Invisible Partner A line",
+ 'code': "INVA",
+ 'sequence': 1,
+ 'hierarchy_level': 0,
+ 'groupby': "account_id",
+ 'foldable': True,
+ 'expression_ids': [Command.clear(), Command.create({
+ 'label': 'balance',
+ 'engine': 'domain',
+ 'formula': [("partner_id", "=", cls.partner_a.id)],
+ 'subformula': 'sum',
+ })],
+ }),
+ Command.create({
+ 'name': "Invisible Partner B line",
+ 'code': "INVB",
+ 'sequence': 2,
+ 'hierarchy_level': 0,
+ 'groupby': "account_id",
+ 'foldable': True,
+ 'expression_ids': [Command.clear(), Command.create({
+ 'label': 'balance',
+ 'engine': 'domain',
+ 'formula': [("partner_id", "=", cls.partner_b.id)],
+ 'subformula': 'sum',
+ })],
+ }),
+ Command.create({
+ 'name': "Total of Invisible lines",
+ 'code': "INVT",
+ 'sequence': 3,
+ 'hierarchy_level': 0,
+ 'expression_ids': [Command.clear(), Command.create({
+ 'label': 'balance',
+ 'engine': 'aggregation',
+ 'formula': 'INVA.balance + INVB.balance',
+ })],
+ }),
+ ],
+ })
+
+ def _build_generic_id_from_financial_line(self, financial_rep_ln_xmlid):
+ report_line = self.env.ref(financial_rep_ln_xmlid)
+ return '-account.financial.html.report.line-%s' % report_line.id
+
+ def _get_line_id_from_generic_id(self, generic_id):
+ return int(generic_id.split('-')[-1])
+
+ def test_financial_report_strict_range_on_report_lines_with_no_parent_id(self):
+ """ Tests that lines with no parent can be correctly filtered by date range """
+ self.report_no_parent_id.filter_multi_company = 'disabled'
+ options = self._generate_options(self.report_no_parent_id, fields.Date.from_string('2019-01-01'), fields.Date.from_string('2019-12-31'))
+
+ lines = self.report_no_parent_id._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Invisible Partner A line', 1150.0),
+ ('Invisible Partner B line', -1675.0),
+ ('Total of Invisible lines', -525.0),
+
+ ],
+ options,
+ )
+
+ def test_financial_report_strict_empty_range_on_report_lines_with_no_parent_id(self):
+ """ Tests that lines with no parent can be correctly filtered by date range with no invoices"""
+ self.report_no_parent_id.filter_multi_company = 'disabled'
+ options = self._generate_options(self.report_no_parent_id, fields.Date.from_string('2019-03-01'), fields.Date.from_string('2019-03-31'))
+
+ lines = self.report_no_parent_id._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Invisible Partner A line', 0.0),
+ ('Invisible Partner B line', 0.0),
+ ('Total of Invisible lines', 0.0),
+ ],
+ options,
+ )
+
+ @freeze_time("2016-06-06")
+ def test_balance_sheet_today_current_year_earnings(self):
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'date': '2016-02-02',
+ 'invoice_line_ids': [Command.create({
+ 'product_id': self.product_a.id,
+ 'price_unit': 110,
+ 'tax_ids': [],
+ })]
+ })
+ invoice.action_post()
+
+ self.report.filter_multi_company = 'disabled'
+ options = self._generate_options(self.report, fields.Date.from_string('2016-06-01'), fields.Date.from_string('2016-06-06'))
+ options['date']['filter'] = 'today'
+
+ lines = self.report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Balance
+ [ 0, 1],
+ [
+ ('ASSETS', 110.0),
+ ('Current Assets', 110.0),
+ ('Bank and Cash Accounts', 0.0),
+ ('Receivables', 110.0),
+ ('Current Assets', 0.0),
+ ('Prepayments', 0.0),
+ ('Total Current Assets', 110.0),
+ ('Plus Fixed Assets', 0.0),
+ ('Plus Non-current Assets', 0.0),
+ ('Total ASSETS', 110.0),
+
+ ('LIABILITIES', 0.0),
+ ('Current Liabilities', 0.0),
+ ('Current Liabilities', 0.0),
+ ('Payables', 0.0),
+ ('Total Current Liabilities', 0.0),
+ ('Plus Non-current Liabilities', 0.0),
+ ('Total LIABILITIES', 0.0),
+
+ ('EQUITY', 110.0),
+ ('Unallocated Earnings', 110.0),
+ ('Current Year Unallocated Earnings', 110.0),
+ ('Previous Years Unallocated Earnings', 0.0),
+ ('Total Unallocated Earnings', 110.0),
+ ('Retained Earnings', 0.0),
+ ('Current Year Retained Earnings', 0.0),
+ ('Previous Years Retained Earnings', 0.0),
+ ('Total Retained Earnings', 0.0),
+ ('Total EQUITY', 110.0),
+
+ ('LIABILITIES + EQUITY', 110.0),
+ ],
+ options,
+ )
+
+ @freeze_time("2016-05-05")
+ def test_balance_sheet_last_month_vs_custom_current_year_earnings(self):
+ """
+ Checks the balance sheet calls the right period of the P&L when using last_month date filter, or an equivalent custom filter
+ (this used to fail due to options regeneration made by the P&L's get_options())"
+ """
+ to_invoice = [('15', '11'), ('15', '12'), ('16', '01'), ('16', '02'), ('16', '03'), ('16', '04')]
+ for year, month in to_invoice:
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': f'20{year}-{month}-01',
+ 'invoice_line_ids': [Command.create({
+ 'product_id': self.product_a.id,
+ 'price_unit': 1000,
+ 'tax_ids': [],
+ })]
+ })
+ invoice.action_post()
+ expected_result =[
+ ('ASSETS', 6000.0),
+ ('Current Assets', 6000.0),
+ ('Bank and Cash Accounts', 0.0),
+ ('Receivables', 6000.0),
+ ('Current Assets', 0.0),
+ ('Prepayments', 0.0),
+ ('Total Current Assets', 6000.0),
+ ('Plus Fixed Assets', 0.0),
+ ('Plus Non-current Assets', 0.0),
+ ('Total ASSETS', 6000.0),
+
+ ('LIABILITIES', 0.0),
+ ('Current Liabilities', 0.0),
+ ('Current Liabilities', 0.0),
+ ('Payables', 0.0),
+ ('Total Current Liabilities', 0.0),
+ ('Plus Non-current Liabilities', 0.0),
+ ('Total LIABILITIES', 0.0),
+
+ ('EQUITY', 6000.0),
+ ('Unallocated Earnings', 6000.0),
+ ('Current Year Unallocated Earnings', 4000.0),
+ ('Previous Years Unallocated Earnings', 2000.0),
+ ('Total Unallocated Earnings', 6000.0),
+ ('Retained Earnings', 0.0),
+ ('Current Year Retained Earnings', 0.0),
+ ('Previous Years Retained Earnings', 0.0),
+ ('Total Retained Earnings', 0.0),
+ ('Total EQUITY', 6000.0),
+ ('LIABILITIES + EQUITY', 6000.0),
+
+ ]
+ self.report.filter_multi_company = 'disabled'
+ options = self._generate_options(self.report, fields.Date.from_string('2016-05-05'), fields.Date.from_string('2016-05-05'))
+
+ # End of Last Month
+ options['date']['filter'] = 'last_month'
+ lines = self.report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Balance
+ [ 0, 1],
+ expected_result,
+ options,
+ )
+ # Custom
+ options['date']['filter'] = 'custom'
+ lines = self.report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Balance
+ [ 0, 1],
+ expected_result,
+ options,
+ )
+
+ def test_financial_report_single_company(self):
+ line_id = self._get_basic_line_dict_id_from_report_line_ref('odex30_account_reports.account_financial_report_bank_view0')
+ self.report.filter_multi_company = 'disabled'
+ options = self._generate_options(self.report, fields.Date.from_string('2019-01-01'), fields.Date.from_string('2019-12-31'))
+ options['unfolded_lines'] = [line_id]
+
+ lines = self.report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Balance
+ [ 0, 1],
+ [
+ ('ASSETS', 50.0),
+ ('Current Assets', -650.0),
+ ('Bank and Cash Accounts', -1300.0),
+ ('code2 account2', -1300.0),
+ ('Total Bank and Cash Accounts', -1300.0),
+ ('Receivables', 1350.0),
+ ('Current Assets', 400.0),
+ ('Prepayments', -1100.0),
+ ('Total Current Assets', -650.0),
+ ('Plus Fixed Assets', 0.0),
+ ('Plus Non-current Assets', 700.0),
+ ('Total ASSETS', 50.0),
+
+ ('LIABILITIES', -200.0),
+ ('Current Liabilities', -200.0),
+ ('Current Liabilities', 0.0),
+ ('Payables', -200.0),
+ ('Total Current Liabilities', -200.0),
+ ('Plus Non-current Liabilities', 0.0),
+ ('Total LIABILITIES', -200.0),
+
+ ('EQUITY', 250.0),
+ ('Unallocated Earnings', -550.0),
+ ('Current Year Unallocated Earnings', -800.0),
+ ('Previous Years Unallocated Earnings', 250.0),
+ ('Total Unallocated Earnings', -550.0),
+ ('Retained Earnings', 800.0),
+ ('Current Year Retained Earnings', 800.0),
+ ('Previous Years Retained Earnings', 0.0),
+ ('Total Retained Earnings', 800.0),
+ ('Total EQUITY', 250.0),
+
+ ('LIABILITIES + EQUITY', 50.0),
+ ],
+ options,
+ )
+
+ unfolded_lines = self.report._get_unfolded_lines(lines, line_id)
+ self.assertLinesValues(
+ unfolded_lines,
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Bank and Cash Accounts', -1300.0),
+ ('code2 account2', -1300.0),
+ ('Total Bank and Cash Accounts', -1300.0),
+ ],
+ options,
+ )
+
+ def test_financial_report_multi_company_currency(self):
+ line_id = self._get_basic_line_dict_id_from_report_line_ref('odex30_account_reports.account_financial_report_bank_view0')
+ options = self._generate_options(self.report, fields.Date.from_string('2019-01-01'), fields.Date.from_string('2019-12-31'))
+ options['unfolded_lines'] = [line_id]
+
+ lines = self.report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Balance
+ [ 0, 1],
+ [
+ ('ASSETS', 50.0),
+ ('Current Assets', -4150.0),
+ ('Bank and Cash Accounts', -3300.0),
+ ('code102 account102', -2000.0),
+ ('code2 account2', -1300.0),
+ ('Total Bank and Cash Accounts', -3300.0),
+ ('Receivables', 2350.0),
+ ('Current Assets', 400.0),
+ ('Prepayments', -3600.0),
+ ('Total Current Assets', -4150.0),
+ ('Plus Fixed Assets', 0.0),
+ ('Plus Non-current Assets', 4200.0),
+ ('Total ASSETS', 50.0),
+
+ ('LIABILITIES', -200.0),
+ ('Current Liabilities', -200.0),
+ ('Current Liabilities', 0.0),
+ ('Payables', -200.0),
+ ('Total Current Liabilities', -200.0),
+ ('Plus Non-current Liabilities', 0.0),
+ ('Total LIABILITIES', -200.0),
+
+ ('EQUITY', 250.0),
+ ('Unallocated Earnings', -550.0),
+ ('Current Year Unallocated Earnings', -800.0),
+ ('Previous Years Unallocated Earnings', 250.0),
+ ('Total Unallocated Earnings', -550.0),
+ ('Retained Earnings', 800.0),
+ ('Current Year Retained Earnings', 800.0),
+ ('Previous Years Retained Earnings', 0.0),
+ ('Total Retained Earnings', 800.0),
+ ('Total EQUITY', 250.0),
+
+ ('LIABILITIES + EQUITY', 50.0),
+ ],
+ options,
+ )
+
+ unfolded_lines = self.report._get_unfolded_lines(lines, line_id)
+ self.assertLinesValues(
+ unfolded_lines,
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Bank and Cash Accounts', -3300.0),
+ ('code102 account102', -2000.0),
+ ('code2 account2', -1300.0),
+ ('Total Bank and Cash Accounts', -3300.0),
+ ],
+ options,
+ )
+
+ def test_financial_report_comparison(self):
+ line_id = self._get_basic_line_dict_id_from_report_line_ref('odex30_account_reports.account_financial_report_bank_view0')
+ options = self._generate_options(self.report, fields.Date.from_string('2019-01-01'), fields.Date.from_string('2019-12-31'))
+ options = self._update_comparison_filter(options, self.report, 'custom', 1, date_to=fields.Date.from_string('2018-12-31'))
+ options['unfolded_lines'] = [line_id]
+
+ lines = self.report._get_lines(options)
+
+ self.assertColumnPercentComparisonValues(
+ lines,
+ [
+ ('ASSETS', '-80.0%', 'red'),
+ ('Current Assets', '27.7%', 'red'),
+ ('Bank and Cash Accounts', '10.0%', 'red'),
+ ('code102 account102', '0.0%', 'muted'),
+ ('code2 account2', '30.0%', 'red'),
+ ('Total Bank and Cash Accounts', '10.0%', 'red'),
+ ('Receivables', '4.4%', 'green'),
+ ('Current Assets', 'n/a', 'muted'),
+ ('Prepayments', '44.0%', 'red'),
+ ('Total Current Assets', '27.7%', 'red'),
+ ('Plus Fixed Assets', 'n/a', 'muted'),
+ ('Plus Non-current Assets', '20.0%', 'green'),
+ ('Total ASSETS', '-80.0%', 'red'),
+
+ ('LIABILITIES', 'n/a', 'muted'),
+ ('Current Liabilities', 'n/a', 'muted'),
+ ('Current Liabilities', 'n/a', 'muted'),
+ ('Payables', 'n/a', 'muted'),
+ ('Total Current Liabilities', 'n/a', 'muted'),
+ ('Plus Non-current Liabilities', 'n/a', 'muted'),
+ ('Total LIABILITIES', 'n/a', 'muted'),
+
+ ('EQUITY', '0.0%', 'muted'),
+ ('Unallocated Earnings', '-320.0%', 'red'),
+ ('Current Year Unallocated Earnings', '-420.0%', 'red'),
+ ('Previous Years Unallocated Earnings', 'n/a', 'muted'),
+ ('Total Unallocated Earnings', '-320.0%', 'red'),
+ ('Retained Earnings', 'n/a', 'muted'),
+ ('Current Year Retained Earnings', 'n/a', 'muted'),
+ ('Previous Years Retained Earnings', 'n/a', 'muted'),
+ ('Total Retained Earnings', 'n/a', 'muted'),
+ ('Total EQUITY', '0.0%', 'muted'),
+
+
+ ('LIABILITIES + EQUITY', '-80.0%', 'green'),
+ ]
+ )
+
+ def test_financial_report_horizontal_group(self):
+ line_id = self._get_basic_line_dict_id_from_report_line_ref('odex30_account_reports.account_financial_report_receivable0')
+ self.report.horizontal_group_ids |= self.horizontal_group
+
+ options = self._generate_options(
+ self.report,
+ fields.Date.from_string('2019-01-01'),
+ fields.Date.from_string('2019-12-31'),
+ default_options={
+ 'unfolded_lines': [line_id],
+ 'selected_horizontal_group_id': self.horizontal_group.id,
+ }
+ )
+ options = self._update_comparison_filter(options, self.report, 'custom', 1, date_to=fields.Date.from_string('2018-12-31'))
+
+ lines = self.report._get_lines(options)
+ self.assertHeadersValues(
+ options['column_headers'],
+ [
+ ['As of 12/31/2019', 'As of 12/31/2018'],
+ ['partner_a', 'partner_b'],
+ ['code0 account0', 'code1 account1'],
+ ]
+ )
+ self.assertLinesValues(
+ lines,
+ [ 0, 1, 2, 3, 4, 5, 6, 7, 8],
+ [
+ ('ASSETS', 1300.0, 0.0, 25.0, 0.0, 1250.0, 0.0, 0.0, 0.0),
+ ('Current Assets', 1300.0, 0.0, 25.0, 0.0, 1250.0, 0.0, 0.0, 0.0),
+ ('Bank and Cash Accounts', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Receivables', 1300.0, 0.0, 25.0, 0.0, 1250.0, 0.0, 0.0, 0.0),
+ ('code0 account0', 1300.0, 0.0, 25.0, 0.0, 1250.0, 0.0, 0.0, 0.0),
+ ('Total Receivables', 1300.0, 0.0, 25.0, 0.0, 1250.0, 0.0, 0.0, 0.0),
+ ('Current Assets', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Prepayments', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Total Current Assets', 1300.0, 0.0, 25.0, 0.0, 1250.0, 0.0, 0.0, 0.0),
+ ('Plus Fixed Assets', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Plus Non-current Assets', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Total ASSETS', 1300.0, 0.0, 25.0, 0.0, 1250.0, 0.0, 0.0, 0.0),
+
+ ('LIABILITIES', 0.0, 0.0, 0.0, -200.0, 0.0, 0.0, 0.0, 0.0),
+ ('Current Liabilities', 0.0, 0.0, 0.0, -200.0, 0.0, 0.0, 0.0, 0.0),
+ ('Current Liabilities', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Payables', 0.0, 0.0, 0.0, -200.0, 0.0, 0.0, 0.0, 0.0),
+ ('Total Current Liabilities', 0.0, 0.0, 0.0, -200.0, 0.0, 0.0, 0.0, 0.0),
+ ('Plus Non-current Liabilities', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Total LIABILITIES', 0.0, 0.0, 0.0, -200.0, 0.0, 0.0, 0.0, 0.0),
+
+ ('EQUITY', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Unallocated Earnings', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Current Year Unallocated Earnings', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Previous Years Unallocated Earnings', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Total Unallocated Earnings', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Retained Earnings', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Current Year Retained Earnings', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Previous Years Retained Earnings', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Total Retained Earnings', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+ ('Total EQUITY', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
+
+ ('LIABILITIES + EQUITY', 0.0, 0.0, 0.0, -200.0, 0.0, 0.0, 0.0, 0.0),
+ ],
+ options,
+ )
+
+ def test_financial_report_horizontal_group_total(self):
+ """
+ In case we don't have comparison, just one column and one level of groupby a new column is added which is the total
+ of the horizontal group
+ """
+ horizontal_group = self.env['account.report.horizontal.group'].create({
+ 'name': 'Horizontal Group total',
+ 'rule_ids': [
+ Command.create({
+ 'field_name': 'partner_id',
+ 'domain': f"[('id', 'in', {(self.partner_a + self.partner_b).ids})]",
+ }),
+ ],
+ })
+ self.report.horizontal_group_ids |= horizontal_group
+ options = self._generate_options(self.report, '2019-01-01', '2019-12-31', default_options={'selected_horizontal_group_id': horizontal_group.id})
+ self.assertHeadersValues(
+ options['column_headers'],
+ [
+ ['As of 12/31/2019'],
+ ['partner_a', 'partner_b'],
+ ]
+ )
+
+ self.assertTrue(options['show_horizontal_group_total'])
+ # Since we don't calculate the value when totals below section is activated, we disable it
+ self.env.company.totals_below_sections = False
+ self.assertHorizontalGroupTotal(
+ self.report._get_lines(options),
+ [
+ ('ASSETS', 6900.0, -4075.0, 2825.0),
+ ('Current Assets', 2700.0, -4075.0, -1375.0),
+ ('Bank and Cash Accounts', 0.0, -3000.0, -3000.0),
+ ('Receivables', 2300.0, 25.0, 2325.0),
+ ('Current Assets', 400.0, 0.0, 400.0),
+ ('Prepayments', 0.0, -1100.0, -1100.0),
+ ('Plus Fixed Assets', 0.0, 0.0, 0.0),
+ ('Plus Non-current Assets', 4200.0, 0.0, 4200.0),
+ ('LIABILITIES', 0.0, -200.0, -200.0),
+ ('Current Liabilities', 0.0, -200.0, -200.0),
+ ('Current Liabilities', 0.0, 0.0, 0.0),
+ ('Payables', 0.0, -200.0, -200.0),
+ ('Plus Non-current Liabilities', 0.0, 0.0, 0.0),
+ ('EQUITY', 250.0, 800.0, 1050.0),
+ ('Unallocated Earnings', 250.0, 0.0, 250.0),
+ ('Current Year Unallocated Earnings', 0.0, 0.0, 0.0),
+ ('Previous Years Unallocated Earnings', 250.0, 0.0, 250.0),
+ ('Retained Earnings', 0.0, 800.0, 800.0),
+ ('Current Year Retained Earnings', 0.0, 800.0, 800.0),
+ ('Previous Years Retained Earnings', 0.0, 0.0, 0.0),
+ ('LIABILITIES + EQUITY', 250.0, 600.0, 850.0),
+ ],
+ )
+
+ options = self._generate_options(self.report, '2019-01-01', '2019-12-31', default_options={'selected_horizontal_group_id': horizontal_group.id})
+ options = self._update_comparison_filter(options, self.report, 'custom', 1, date_to=fields.Date.from_string('2018-12-31'))
+ self.assertHeadersValues(
+ options['column_headers'],
+ [
+ ['As of 12/31/2019', 'As of 12/31/2018'],
+ ['partner_a', 'partner_b'],
+ ]
+ )
+
+ self.assertFalse(options['show_horizontal_group_total'])
+
+ self.assertHorizontalGroupTotal(
+ self.report._get_lines(options),
+ [
+ ('ASSETS', 6900.0, -4075.0, 5750.0, -3000.0),
+ ('Current Assets', 2700.0, -4075.0, 2250.0, -3000.0),
+ ('Bank and Cash Accounts', 0.0, -3000.0, 0.0, -3000.0),
+ ('Receivables', 2300.0, 25.0, 2250.0, 0.0),
+ ('Current Assets', 400.0, 0.0, 0.0, 0.0),
+ ('Prepayments', 0.0, -1100.0, 0.0, 0.0),
+ ('Plus Fixed Assets', 0.0, 0.0, 0.0, 0.0),
+ ('Plus Non-current Assets', 4200.0, 0.0, 3500.0, 0.0),
+ ('LIABILITIES', 0.0, -200.0, 0.0, 0.0),
+ ('Current Liabilities', 0.0, -200.0, 0.0, 0.0),
+ ('Current Liabilities', 0.0, 0.0, 0.0, 0.0),
+ ('Payables', 0.0, -200.0, 0.0, 0.0),
+ ('Plus Non-current Liabilities', 0.0, 0.0, 0.0, 0.0),
+ ('EQUITY', 250.0, 800.0, 250.0, 0.0),
+ ('Unallocated Earnings', 250.0, 0.0, 250.0, 0.0),
+ ('Current Year Unallocated Earnings', 0.0, 0.0, 250.0, 0.0),
+ ('Previous Years Unallocated Earnings', 250.0, 0.0, 0.0, 0.0),
+ ('Retained Earnings', 0.0, 800.0, 0.0, 0.0),
+ ('Current Year Retained Earnings', 0.0, 800.0, 0.0, 0.0),
+ ('Previous Years Retained Earnings', 0.0, 0.0, 0.0, 0.0),
+ ('LIABILITIES + EQUITY', 250.0, 600.0, 250.0, 0.0),
+ ],
+ )
+
+ def test_hide_if_zero_with_no_formulas(self):
+ """
+ Check if a report line stays displayed when hide_if_zero is True and no formulas
+ is set on the line but has some child which have balance != 0
+ We check also if the line is hidden when all its children have balance == 0
+ """
+ account1, account2 = self.env['account.account'].create([{
+ 'name': "test_financial_report_1",
+ 'code': "42241",
+ 'account_type': "asset_fixed",
+ }, {
+ 'name': "test_financial_report_2",
+ 'code': "42242",
+ 'account_type': "asset_fixed",
+ }])
+
+ moves = self.env['account.move'].create([
+ {
+ 'move_type': 'entry',
+ 'date': '2019-04-01',
+ 'line_ids': [
+ (0, 0, {'debit': 3.0, 'credit': 0.0, 'account_id': account1.id}),
+ (0, 0, {'debit': 0.0, 'credit': 3.0, 'account_id': self.company_data['default_account_revenue'].id}),
+ ],
+ },
+ {
+ 'move_type': 'entry',
+ 'date': '2019-05-01',
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 1.0, 'account_id': account2.id}),
+ (0, 0, {'debit': 1.0, 'credit': 0.0, 'account_id': self.company_data['default_account_revenue'].id}),
+ ],
+ },
+ {
+ 'move_type': 'entry',
+ 'date': '2019-04-01',
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 3.0, 'account_id': account2.id}),
+ (0, 0, {'debit': 3.0, 'credit': 0.0, 'account_id': self.company_data['default_account_revenue'].id}),
+ ],
+ },
+ ])
+ moves.action_post()
+ moves.line_ids.flush_recordset()
+
+ report = self.env["account.report"].create({
+ 'name': "test_financial_report_sum",
+ 'column_ids': [
+ Command.create({
+ 'name': "Balance",
+ 'expression_label': 'balance',
+ 'sequence': 1,
+ }),
+ ],
+ 'line_ids': [
+ Command.create({
+ 'name': "Title",
+ 'code': 'TT',
+ 'hide_if_zero': True,
+ 'sequence': 0,
+ 'children_ids': [
+ Command.create({
+ 'name': "report_line_1",
+ 'code': 'TEST_L1',
+ 'sequence': 1,
+ 'expression_ids': [
+ Command.create({
+ 'label': 'balance',
+ 'engine': 'domain',
+ 'formula': f"[('account_id', '=', {account1.id})]",
+ 'subformula': 'sum',
+ 'date_scope': 'from_beginning',
+ }),
+ ],
+ }),
+ Command.create({
+ 'name': "report_line_2",
+ 'code': 'TEST_L2',
+ 'sequence': 2,
+ 'expression_ids': [
+ Command.create({
+ 'label': 'balance',
+ 'engine': 'domain',
+ 'formula': f"[('account_id', '=', {account2.id})]",
+ 'subformula': 'sum',
+ 'date_scope': 'from_beginning',
+ }),
+ ],
+ }),
+ ]
+ }),
+ ],
+ })
+
+ # TODO without this, the create() puts newIds in the sublines, and flushing doesn't help. Seems to be an ORM bug.
+ self.env.invalidate_all()
+
+ options = self._generate_options(report, fields.Date.from_string('2019-05-01'), fields.Date.from_string('2019-05-01'))
+ options = self._update_comparison_filter(options, report, 'previous_period', 2)
+
+ self.assertLinesValues(
+ report._get_lines(options),
+ [ 0, 1, 2, 3],
+ [
+ ("Title", '', '', ''),
+ ("report_line_1", 3.0, 3.0, 0.0),
+ ("report_line_2", -4.0, -3.0, 0.0),
+ ],
+ options,
+ )
+
+ move = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2019-05-01',
+ 'line_ids': [
+ (0, 0, {'debit': 0.0, 'credit': 3.0, 'account_id': account1.id}),
+ (0, 0, {'debit': 4.0, 'credit': 0.0, 'account_id': account2.id}),
+ (0, 0, {'debit': 0.0, 'credit': 1.0, 'account_id': self.company_data['default_account_revenue'].id}),
+ ],
+ })
+
+ move.action_post()
+ move.line_ids.flush_recordset()
+
+ # With the comparison still on, the lines shouldn't be hidden
+ self.assertLinesValues(
+ report._get_lines(options),
+ [ 0, 1, 2, 3],
+ [
+ ("Title", '', '', ''),
+ ("report_line_1", 0.0, 3.0, 0.0),
+ ("report_line_2", 0.0, -3.0, 0.0),
+ ],
+ options,
+ )
+
+ # Removing the comparison should hide the lines, as they will be 0 in every considered period (the current one)
+ options = self._update_comparison_filter(options, report, 'previous_period', 0)
+ self.assertLinesValues(report._get_lines(options), [0, 1, 2, 3], [], options)
+
+ def test_option_hierarchy(self):
+ """ Check that the report lines are correct when the option "Hierarchy and subtotals is ticked"""
+ self.env['account.group'].create({
+ 'name': 'Sales',
+ 'code_prefix_start': '40',
+ 'code_prefix_end': '49',
+ })
+
+ move = self.env['account.move'].create({
+ 'date': '2020-02-02',
+ 'line_ids': [
+ Command.create({
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'name': 'name',
+ })
+ ],
+ })
+ move.action_post()
+ move.line_ids.flush_recordset()
+ profit_and_loss_report = self.env.ref('odex30_account_reports.profit_and_loss')
+ line_id = self._get_basic_line_dict_id_from_report_line_ref('odex30_account_reports.account_financial_report_revenue0')
+ options = self._generate_options(profit_and_loss_report, '2020-02-01', '2020-02-28')
+ options['unfolded_lines'] = [line_id]
+ options['hierarchy'] = True
+ self.env.company.totals_below_sections = False
+ lines = profit_and_loss_report._get_lines(options)
+
+ unfolded_lines = profit_and_loss_report._get_unfolded_lines(lines, line_id)
+ unfolded_lines = [{'name': line['name'], 'level': line['level']} for line in unfolded_lines]
+
+ self.assertEqual(
+ unfolded_lines,
+ [
+ {'level': 1, 'name': 'Revenue'},
+ {'level': 2, 'name': '40-49 Sales'},
+ {'level': 3, 'name': '400000 Product Sales'},
+ ]
+ )
+
+ def test_option_hierarchy_with_no_group_lines(self):
+ """ Check that the report lines of 'No Group' have correct ids with the option 'Hierarchy and subtotals' """
+ self.env['account.group'].create({
+ 'name': 'Sales',
+ 'code_prefix_start': '45',
+ 'code_prefix_end': '49',
+ })
+
+ move = self.env['account.move'].create({
+ 'date': '2020-02-02',
+ 'line_ids': [
+ Command.create({
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'name': 'name',
+ })
+ ],
+ })
+ move.action_post()
+ move.line_ids.flush_recordset()
+ profit_and_loss_report = self.env.ref('odex30_account_reports.profit_and_loss')
+ line_id = self._get_basic_line_dict_id_from_report_line_ref('odex30_account_reports.account_financial_report_revenue0')
+ options = self._generate_options(profit_and_loss_report, '2020-02-01', '2020-02-28')
+ options['unfolded_lines'] = [line_id]
+ options['hierarchy'] = True
+ self.env.company.totals_below_sections = False
+ lines = profit_and_loss_report._get_lines(options)
+ lines_array = [{'name': line['name'], 'level': line['level']} for line in lines]
+
+ self.assertEqual(
+ lines_array,
+ [
+ {'name': 'Revenue', 'level': 1},
+ {'name': '(No Group)', 'level': 2},
+ {'name': '400000 Product Sales', 'level': 3},
+ {'name': 'Less Costs of Revenue', 'level': 1},
+ {'name': 'Gross Profit', 'level': 0},
+ {'name': 'Less Operating Expenses', 'level': 1},
+ {'name': 'Operating Income (or Loss)', 'level': 0},
+ {'name': 'Plus Other Income', 'level': 1},
+ {'name': 'Less Other Expenses', 'level': 1},
+ {'name': 'Net Profit', 'level': 0},
+ ]
+ )
+
+ self.assertEqual(lines[1]['id'], lines[0]['id'] + '|' + '~account.group~')
+
+ def test_parse_line_id(self):
+ line_id_1 = self.env['account.report']._parse_line_id('markup1~account.account~5|markup2~res.partner~8|markup3~~')
+ line_id_2 = self.env['account.report']._parse_line_id('~account.report~14|{"groupby_prefix_group": "~"}~account.report~21')
+
+ self.assertEqual(line_id_1, [('markup1', 'account.account', 5), ('markup2', 'res.partner', 8), ('markup3', None, None)])
+ self.assertEqual(line_id_2, [('', 'account.report', 14), ({"groupby_prefix_group": "~"}, 'account.report', 21)])
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_followup_report.py b/dev_odex30_accounting/odex30_account_reports/tests/test_followup_report.py
new file mode 100644
index 0000000..a6ac0a1
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_followup_report.py
@@ -0,0 +1,170 @@
+from .common import TestAccountReportsCommon
+
+from odoo import Command, fields
+from freezegun import freeze_time
+from odoo.tools import format_date
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestFollowupReport(TestAccountReportsCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.report = cls.env.ref('odex30_account_reports.followup_report')
+ # Initiate Invoices
+ with freeze_time('2025-10-08'):
+ cls.today = fields.Date.today()
+ invoices_data = [
+ # Partner A invoices
+ {'partner': cls.partner_a, 'amount': 100.0, 'due_date': '2025-01-01'},
+ {'partner': cls.partner_a, 'amount': 100.0, 'due_date': cls.today},
+ {'partner': cls.partner_a, 'amount': 100.0, 'due_date': '2025-01-01', 'move_type': 'out_refund'},
+ # Partner B invoices
+ {'partner': cls.partner_b, 'amount': 100.0, 'due_date': '2025-01-01'},
+ {'partner': cls.partner_b, 'amount': 100.0, 'due_date': cls.today},
+ {'partner': cls.partner_b, 'amount': 400.0, 'due_date': '2025-01-01', 'move_type': 'out_refund'},
+ ]
+ for invoice_data in invoices_data:
+ cls.init_invoice(
+ move_type=invoice_data.get('move_type', 'out_invoice'),
+ partner=invoice_data['partner'],
+ amounts=[invoice_data['amount']],
+ invoice_date_due=invoice_data['due_date'],
+ invoice_date=invoice_data.get('invoice_date', '2025-01-01'),
+ )
+ cls.formatted_today = format_date(cls.env, cls.today, date_format='MM/dd/YYY')
+
+ @classmethod
+ def init_invoice(cls, move_type, partner=None, invoice_date=None, post=False, products=None, amounts=None, taxes=None, company=False, currency=None, journal=None, invoice_date_due=None):
+ move = super().init_invoice(move_type, partner, invoice_date, False, products, amounts, taxes, company, currency, journal)
+ if invoice_date_due:
+ move.invoice_payment_term_id = False
+ move.invoice_date_due = invoice_date_due
+ move.action_post()
+ return move
+
+ @freeze_time('2025-10-08')
+ def test_followup_report_unfold(self):
+ ''' Test unfolding a line when rendering the whole report, having overdue and due sections '''
+ options = self._generate_options(self.report, fields.Date.from_string('2025-01-01'), fields.Date.from_string('2025-01-31'))
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Amount Balance
+ [ 0, 3, 5],
+ [
+ ('partner_a', 100.0, 100.0),
+ ('partner_b', -200.0, -200.0),
+ ('Total', -100.0, -100.0),
+ ],
+ options
+ )
+
+ options['unfolded_lines'] = [self.report._get_generic_line_id('res.partner', self.partner_a.id)]
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Due Date Amount Balance
+ [ 0, 2, 3, 5],
+ [
+ ('partner_a', '', 100.0, 100.0),
+ ('Overdue', '', '', ''),
+ ('INV/2025/00001', '01/01/2025', 100.0, 100.0),
+ ('RINV/2025/00001', '01/01/2025', -100.0, 0.0),
+ ('Due', '', '', ''),
+ ('INV/2025/00002', self.formatted_today, 100.0, 100.0),
+ ('Total partner_a', '', 100.0, 100.0),
+ ('partner_b', '', -200.0, -200.0),
+ ('Total', '', -100.0, -100.0),
+ ],
+ options
+ )
+
+ @freeze_time('2025-10-08')
+ def test_followup_report_load_more(self):
+ ''' Test loading more lines when reaching the limit '''
+ self.report.load_more_limit = 2
+
+ invoices_data = [
+ {'amount': 100.0, 'due_date': '2024-12-03'},
+ {'amount': 100.0, 'due_date': self.today}
+ ]
+
+ for invoice_data in invoices_data:
+ self.init_invoice(
+ move_type='out_invoice',
+ partner=self.partner_a,
+ amounts=[invoice_data['amount']],
+ invoice_date_due=invoice_data['due_date'],
+ invoice_date='2025-01-01',
+ )
+
+ options = self._generate_options(self.report, fields.Date.from_string('2025-01-01'), fields.Date.from_string('2025-01-31'))
+ options['unfolded_lines'] = [self.report._get_generic_line_id('res.partner', self.partner_a.id)]
+
+ report_lines = self.report._get_lines(options)
+
+ self.assertLinesValues(
+ report_lines,
+ # Name Due Date Amount Balance
+ [ 0, 2, 3, 5],
+ [
+ ('partner_a', '', 300.0, 300.0),
+ ('Overdue', '', '', ''),
+ ('INV/2025/00005', '12/03/2024', 100.0, 100.0),
+ ('INV/2025/00001', '01/01/2025', 100.0, 200.0),
+ ('Load more...', '', '', ''),
+ ('Total partner_a', '', 300.0, 300.0),
+ ('partner_b', '', -200.0, -200.0),
+ ('Total', '', 100.0, 100.0),
+ ],
+ options
+ )
+
+ options['unfolded_lines'] = [line['id'] for line in report_lines if line.get('unfolded')]
+
+ load_more_1 = self.report.get_expanded_lines(
+ options,
+ report_lines[0]['id'],
+ report_lines[4]['groupby'],
+ '_report_expand_unfoldable_line_partner_ledger',
+ report_lines[4]['progress'],
+ report_lines[4]['offset'],
+ None,
+ )
+
+ self.assertLinesValues(
+ load_more_1,
+ # Name Due Date Amount Balance
+ [ 0, 2, 3, 5],
+ [
+ ('RINV/2025/00001', '01/01/2025', -100.0, 100.0),
+ ('Due', '', '', ''),
+ ('INV/2025/00002', self.formatted_today, 100.0, 200.0),
+ ('Load more...', '', '', ''),
+ ],
+ options
+ )
+
+ options['unfolded_lines'] = options['unfolded_lines'] + [line['id'] for line in load_more_1 if line.get('unfolded')]
+
+ load_more_2 = self.report.get_expanded_lines(
+ options,
+ report_lines[0]['id'],
+ load_more_1[3]['groupby'],
+ '_report_expand_unfoldable_line_partner_ledger',
+ load_more_1[3]['progress'],
+ load_more_1[3]['offset'],
+ None,
+ )
+
+ self.assertLinesValues(
+ load_more_2,
+ # Name Due Date Amount Balance
+ [ 0, 2, 3, 5],
+ [
+ ('INV/2025/00006', self.formatted_today, 100.0, 300.0),
+ ],
+ options
+ )
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_general_ledger_report.py b/dev_odex30_accounting/odex30_account_reports/tests/test_general_ledger_report.py
new file mode 100644
index 0000000..3252a4d
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_general_ledger_report.py
@@ -0,0 +1,686 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=C0326
+from .common import TestAccountReportsCommon
+import odoo.tests
+
+from odoo import fields, Command
+from odoo.tests import tagged
+from freezegun import freeze_time
+
+import json
+
+@tagged('post_install', '-at_install')
+class TestGeneralLedgerReport(TestAccountReportsCommon, odoo.tests.HttpCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ # Give codes in company_1 to the accounts in company_2.
+ context = {'allowed_company_ids': [cls.company_data['company'].id, cls.company_data_2['company'].id]}
+ cls.company_data_2['default_account_payable'].with_context(context).code = '211010'
+ cls.company_data_2['default_account_revenue'].with_context(context).code = '400010'
+ cls.company_data_2['default_account_expense'].with_context(context).code = '600010'
+ cls.env['account.account'].search([
+ ('company_ids', '=', cls.company_data_2['company'].id),
+ ('account_type', '=', 'equity_unaffected')
+ ]).with_context(context).code = '999989'
+
+ # Entries in 2016 for company_1 to test the initial balance.
+ cls.move_2016_1 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2016-01-01'),
+ 'journal_id': cls.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'name': '2016_1_1', 'account_id': cls.company_data['default_account_payable'].id}),
+ (0, 0, {'debit': 200.0, 'credit': 0.0, 'name': '2016_1_2', 'account_id': cls.company_data['default_account_expense'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 300.0, 'name': '2016_1_3', 'account_id': cls.company_data['default_account_revenue'].id}),
+ ],
+ })
+ cls.move_2016_1.action_post()
+
+ # Entries in 2016 for company_2 to test the initial balance in multi-companies/multi-currencies.
+ cls.move_2016_2 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2016-06-01'),
+ 'journal_id': cls.company_data_2['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'name': '2016_2_1', 'account_id': cls.company_data_2['default_account_payable'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 100.0, 'name': '2016_2_2', 'account_id': cls.company_data_2['default_account_revenue'].id}),
+ ],
+ })
+ cls.move_2016_2.action_post()
+
+ # Entry in 2017 for company_1 to test the report at current date.
+ cls.move_2017_1 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2017-01-01'),
+ 'journal_id': cls.company_data['default_journal_sale'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'name': '2017_1_1', 'account_id': cls.company_data['default_account_receivable'].id}),
+ (0, 0, {'debit': 2000.0, 'credit': 0.0, 'name': '2017_1_2', 'account_id': cls.company_data['default_account_revenue'].id}),
+ (0, 0, {'debit': 3000.0, 'credit': 0.0, 'name': '2017_1_3', 'account_id': cls.company_data['default_account_revenue'].id}),
+ (0, 0, {'debit': 4000.0, 'credit': 0.0, 'name': '2017_1_4', 'account_id': cls.company_data['default_account_revenue'].id}),
+ (0, 0, {'debit': 5000.0, 'credit': 0.0, 'name': '2017_1_5', 'account_id': cls.company_data['default_account_revenue'].id}),
+ (0, 0, {'debit': 6000.0, 'credit': 0.0, 'name': '2017_1_6', 'account_id': cls.company_data['default_account_revenue'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 6000.0, 'name': '2017_1_7', 'account_id': cls.company_data['default_account_expense'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 7000.0, 'name': '2017_1_8', 'account_id': cls.company_data['default_account_expense'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 8000.0, 'name': '2017_1_9', 'account_id': cls.company_data['default_account_expense'].id}),
+ ],
+ })
+ cls.move_2017_1.action_post()
+
+ # Entry in 2017 for company_2 to test the current period in multi-companies/multi-currencies.
+ cls.move_2017_2 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2017-06-01'),
+ 'journal_id': cls.company_data_2['default_journal_bank'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 400.0, 'credit': 0.0, 'name': '2017_2_1', 'account_id': cls.company_data_2['default_account_expense'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 400.0, 'name': '2017_2_2', 'account_id': cls.company_data_2['default_account_revenue'].id}),
+ ],
+ })
+ cls.move_2017_2.action_post()
+
+ # Archive 'default_journal_bank' to ensure archived entries are not filtered out.
+ cls.company_data_2['default_journal_bank'].active = False
+
+ # Deactive all currencies to ensure group_multi_currency is disabled.
+ cls.env['res.currency'].search([('name', '!=', 'USD')]).with_context(force_deactivate=True).active = False
+
+ cls.report = cls.env.ref('odex30_account_reports.general_ledger_report')
+
+ # -------------------------------------------------------------------------
+ # TESTS: General Ledger
+ # -------------------------------------------------------------------------
+ def test_general_ledger_unaffected_earnings_current_fiscal_year(self):
+ def invoice_move(date):
+ return self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string(date),
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'name': 'payable', 'account_id': self.company_data['default_account_payable'].id}),
+ (0, 0, {'debit': 2000.0, 'credit': 0.0, 'name': 'expense', 'account_id': self.company_data['default_account_expense'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 3000.0, 'name': 'revenue', 'account_id': self.company_data['default_account_revenue'].id}),
+ ],
+ })
+
+ move_2009_12 = invoice_move('2009-12-31')
+ move_2009_12.action_post()
+
+ move_2010_01 = invoice_move('2010-01-31')
+ move_2010_01.action_post()
+
+ move_2010_02 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2010-02-01'),
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'name': 'payable', 'account_id': self.company_data['default_account_payable'].id}),
+ (0, 0, {'debit': 200.0, 'credit': 0.0, 'name': 'expense', 'account_id': self.company_data['default_account_expense'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 300.0, 'name': 'revenue', 'account_id': self.company_data['default_account_revenue'].id}),
+ ],
+ })
+ move_2010_02.action_post()
+
+ move_2010_03 = invoice_move('2010-03-01')
+ move_2010_03.action_post()
+
+ options = self._generate_options(self.report, fields.Date.from_string('2010-02-01'), fields.Date.from_string('2010-02-28'))
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 4, 5, 6],
+ [
+ ('211000 Account Payable', 2100.0, 0.0, 2100.0),
+ ('400000 Product Sales', 0.0, 3300.0, -3300.0),
+ ('600000 Expenses', 2200.0, 0.0, 2200.0),
+ ('999999 Undistributed Profits/Losses', 2000.0, 3000.0, -1000.0),
+ ('Total', 6300.0, 6300.0, 0.0),
+ ],
+ options,
+ )
+
+ def test_general_ledger_unaffected_earnings_previous_fiscal_year(self):
+ def invoice_move(date):
+ return self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string(date),
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'name': 'payable', 'account_id': self.company_data['default_account_payable'].id}),
+ (0, 0, {'debit': 2000.0, 'credit': 0.0, 'name': 'expense', 'account_id': self.company_data['default_account_expense'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 3000.0, 'name': 'revenue', 'account_id': self.company_data['default_account_revenue'].id}),
+ ],
+ })
+
+ move_2009_12 = invoice_move('2009-12-31')
+ move_2009_12.action_post()
+
+ move_2010_01 = invoice_move('2010-01-31')
+ move_2010_01.action_post()
+
+ move_2010_02 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2010-02-01'),
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'name': 'payable', 'account_id': self.company_data['default_account_payable'].id}),
+ (0, 0, {'debit': 200.0, 'credit': 0.0, 'name': 'expense', 'account_id': self.company_data['default_account_expense'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 300.0, 'name': 'revenue', 'account_id': self.company_data['default_account_revenue'].id}),
+ ],
+ })
+ move_2010_02.action_post()
+
+ move_2010_03 = invoice_move('2010-03-01')
+ move_2010_03.action_post()
+
+ options = self._generate_options(self.report, fields.Date.from_string('2010-01-01'), fields.Date.from_string('2010-02-28'))
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 4, 5, 6],
+ [
+ ('211000 Account Payable', 2100.0, 0.0, 2100.0),
+ ('400000 Product Sales', 0.0, 3300.0, -3300.0),
+ ('600000 Expenses', 2200.0, 0.0, 2200.0),
+ ('999999 Undistributed Profits/Losses', 2000.0, 3000.0, -1000.0),
+ ('Total', 6300.0, 6300.0, 0.0),
+ ],
+ options,
+ )
+
+ def test_general_ledger_fold_unfold_multicompany_multicurrency(self):
+ ''' Test unfolding a line when rendering the whole report. '''
+ options = self._generate_options(self.report, fields.Date.from_string('2017-01-01'), fields.Date.from_string('2017-12-31'))
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 4, 5, 6],
+ [
+ ('121000 Account Receivable', 1000.0, 0.0, 1000.0),
+ ('211000 Account Payable', 100.0, 0.0, 100.0),
+ ('211010 Account Payable', 50.0, 0.0, 50.0),
+ ('400000 Product Sales', 20000.0, 0.0, 20000.0),
+ ('400010 Product Sales', 0.0, 200.0, -200.0),
+ ('600000 Expenses', 0.0, 21000.0, -21000.0),
+ ('600010 Expenses', 200.0, 0.0, 200.0),
+ ('999989 Undistributed Profits/Losses', 0.0, 50.0, -50.0),
+ ('999999 Undistributed Profits/Losses', 200.0, 300.0, -100.0),
+ ('Total', 21550.0, 21550.0, 0.0),
+ ],
+ options,
+ )
+
+ options['unfold_all'] = True
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 4, 5, 6],
+ [
+ ('121000 Account Receivable', 1000.0, 0.0, 1000.0),
+ ('INV/2017/00001', 1000.0, 0.0, 1000.0),
+ ('Total 121000 Account Receivable', 1000.0, 0.0, 1000.0),
+ ('211000 Account Payable', 100.0, 0.0, 100.0),
+ ('211010 Account Payable', 50.0, 0.0, 50.0),
+ ('400000 Product Sales', 20000.0, 0.0, 20000.0),
+ ('INV/2017/00001', 2000.0, 0.0, 2000.0),
+ ('INV/2017/00001', 3000.0, 0.0, 5000.0),
+ ('INV/2017/00001', 4000.0, 0.0, 9000.0),
+ ('INV/2017/00001', 5000.0, 0.0, 14000.0),
+ ('INV/2017/00001', 6000.0, 0.0, 20000.0),
+ ('Total 400000 Product Sales', 20000.0, 0.0, 20000.0),
+ ('400010 Product Sales', 0.0, 200.0, -200.0),
+ ('BNK1/2017/00001', 0.0, 200.0, -200.0),
+ ('Total 400010 Product Sales', 0.0, 200.0, -200.0),
+ ('600000 Expenses', 0.0, 21000.0, -21000.0),
+ ('INV/2017/00001', 0.0, 6000.0, -6000.0),
+ ('INV/2017/00001', 0.0, 7000.0, -13000.0),
+ ('INV/2017/00001', 0.0, 8000.0, -21000.0),
+ ('Total 600000 Expenses', 0.0, 21000.0, -21000.0),
+ ('600010 Expenses', 200.0, 0.0, 200.0),
+ ('BNK1/2017/00001', 200.0, 0.0, 200.0),
+ ('Total 600010 Expenses', 200.0, 0.0, 200.0),
+ ('999989 Undistributed Profits/Losses', 0.0, 50.0, -50.0),
+ ('999999 Undistributed Profits/Losses', 200.0, 300.0, -100.0),
+ ('Total', 21550.0, 21550.0, 0.0),
+ ],
+ options,
+ )
+
+ def test_general_ledger_multiple_years_initial_balance(self):
+ # Entries in 2015 for company_1 to test the initial balance.
+ move_2015_1 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2015-01-01'),
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'name': '2015_1_1', 'account_id': self.company_data['default_account_payable'].id}),
+ (0, 0, {'debit': 200.0, 'credit': 0.0, 'name': '2015_1_2', 'account_id': self.company_data['default_account_expense'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 300.0, 'name': '2015_1_3', 'account_id': self.company_data['default_account_revenue'].id}),
+ ],
+ })
+ move_2015_1.action_post()
+
+ options = self._generate_options(self.report, fields.Date.from_string('2017-01-01'), fields.Date.from_string('2017-12-31'))
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 4, 5, 6],
+ [
+ ('121000 Account Receivable', 1000.0, 0.0, 1000.0),
+ ('211000 Account Payable', 200.0, 0.0, 200.0),
+ ('211010 Account Payable', 50.0, 0.0, 50.0),
+ ('400000 Product Sales', 20000.0, 0.0, 20000.0),
+ ('400010 Product Sales', 0.0, 200.0, -200.0),
+ ('600000 Expenses', 0.0, 21000.0, -21000.0),
+ ('600010 Expenses', 200.0, 0.0, 200.0),
+ ('999989 Undistributed Profits/Losses', 0.0, 50.0, -50.0),
+ ('999999 Undistributed Profits/Losses', 400.0, 600.0, -200.0),
+ ('Total', 21850.0, 21850.0, 0.0),
+ ],
+ options,
+ )
+
+ options['unfold_all'] = True
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 4, 5, 6],
+ [
+ ('121000 Account Receivable', 1000.0, 0.0, 1000.0),
+ ('INV/2017/00001', 1000.0, 0.0, 1000.0),
+ ('Total 121000 Account Receivable', 1000.0, 0.0, 1000.0),
+ ('211000 Account Payable', 200.0, 0.0, 200.0),
+ ('211010 Account Payable', 50.0, 0.0, 50.0),
+ ('400000 Product Sales', 20000.0, 0.0, 20000.0),
+ ('INV/2017/00001', 2000.0, 0.0, 2000.0),
+ ('INV/2017/00001', 3000.0, 0.0, 5000.0),
+ ('INV/2017/00001', 4000.0, 0.0, 9000.0),
+ ('INV/2017/00001', 5000.0, 0.0, 14000.0),
+ ('INV/2017/00001', 6000.0, 0.0, 20000.0),
+ ('Total 400000 Product Sales', 20000.0, 0.0, 20000.0),
+ ('400010 Product Sales', 0.0, 200.0, -200.0),
+ ('BNK1/2017/00001', 0.0, 200.0, -200.0),
+ ('Total 400010 Product Sales', 0.0, 200.0, -200.0),
+ ('600000 Expenses', 0.0, 21000.0, -21000.0),
+ ('INV/2017/00001', 0.0, 6000.0, -6000.0),
+ ('INV/2017/00001', 0.0, 7000.0, -13000.0),
+ ('INV/2017/00001', 0.0, 8000.0, -21000.0),
+ ('Total 600000 Expenses', 0.0, 21000.0, -21000.0),
+ ('600010 Expenses', 200.0, 0.0, 200.0),
+ ('BNK1/2017/00001', 200.0, 0.0, 200.0),
+ ('Total 600010 Expenses', 200.0, 0.0, 200.0),
+ ('999989 Undistributed Profits/Losses', 0.0, 50.0, -50.0),
+ ('999999 Undistributed Profits/Losses', 400.0, 600.0, -200.0),
+ ('Total', 21850.0, 21850.0, 0.0),
+ ],
+ options,
+ )
+
+ def test_general_ledger_load_more(self):
+ ''' Test unfolding a line to use the load more. '''
+ self.env.companies = self.env.company
+ self.report.load_more_limit = 2
+
+ options = self._generate_options(self.report, fields.Date.from_string('2017-01-01'), fields.Date.from_string('2017-12-31'))
+ options['unfolded_lines'] = [self.report._get_generic_line_id('account.account', self.company_data["default_account_revenue"].id)]
+
+ report_lines = self.report._get_lines(options)
+
+ self.assertLinesValues(
+ report_lines,
+ # Name Debit Credit Balance
+ [ 0, 4, 5, 6],
+ [
+ ('121000 Account Receivable', 1000.0, 0.0, 1000.0),
+ ('211000 Account Payable', 100.0, 0.0, 100.0),
+ ('400000 Product Sales', 20000.0, 0.0, 20000.0),
+ ('INV/2017/00001', 2000.0, 0.0, 2000.0),
+ ('INV/2017/00001', 3000.0, 0.0, 5000.0),
+ ('Load more...', '', '', ''),
+ ('Total 400000 Product Sales', 20000.0, 0.0, 20000.0),
+ ('600000 Expenses', 0.0, 21000.0, -21000.0),
+ ('999999 Undistributed Profits/Losses', 200.0, 300.0, -100.0),
+ ('Total', 21300.0, 21300.0, 0.0),
+ ],
+ options,
+ )
+
+ load_more_1 = self.report.get_expanded_lines(
+ options,
+ report_lines[3]['id'],
+ report_lines[6]['groupby'],
+ '_report_expand_unfoldable_line_general_ledger',
+ report_lines[6]['progress'],
+ report_lines[6]['offset'],
+ None,
+ )
+
+ self.assertLinesValues(
+ load_more_1,
+ # Name Debit Credit Balance
+ [ 0, 4, 5, 6],
+ [
+ ('INV/2017/00001', 4000.0, 0.0, 9000.0),
+ ('INV/2017/00001', 5000.0, 0.0, 14000.0),
+ ('Load more...', '', '', ''),
+ ],
+ options,
+ )
+
+ load_more_2 = self.report.get_expanded_lines(
+ options,
+ report_lines[3]['id'],
+ load_more_1[2]['groupby'],
+ '_report_expand_unfoldable_line_general_ledger',
+ load_more_1[2]['progress'],
+ load_more_1[2]['offset'],
+ None,
+ )
+
+ self.assertLinesValues(
+ load_more_2,
+ # Name Debit Credit Balance
+ [ 0, 4, 5, 6],
+ [
+ ('INV/2017/00001', 6000.0, 0.0, 20000.0),
+ ],
+ options,
+ )
+
+ def test_general_ledger_foreign_currency_account(self):
+ ''' Ensure the total in foreign currency of an account is displayed only if all journal items are sharing the
+ same currency.
+ '''
+ self.env.user.groups_id |= self.env.ref('base.group_multi_currency')
+
+ foreign_curr_account = self.env['account.account'].create({
+ 'name': 'foreign_curr_account',
+ 'code': 'test',
+ 'account_type': 'liability_current',
+ 'currency_id': self.other_currency.id,
+ })
+
+ move_2016 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': self.company_data['default_journal_sale'].id,
+ 'line_ids': [
+ (0, 0, {
+ 'name': 'curr_1',
+ 'debit': 100.0,
+ 'credit': 0.0,
+ 'amount_currency': 100.0,
+ 'currency_id': self.company_data['currency'].id,
+ 'account_id': self.company_data['default_account_receivable'].id,
+ }),
+ (0, 0, {
+ 'name': 'curr_2',
+ 'debit': 0.0,
+ 'credit': 100.0,
+ 'amount_currency': -300.0,
+ 'currency_id': self.other_currency.id,
+ 'account_id': foreign_curr_account.id,
+ }),
+ ],
+ })
+ move_2016.action_post()
+
+ move_2017 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2017-01-01',
+ 'journal_id': self.company_data['default_journal_sale'].id,
+ 'line_ids': [
+ (0, 0, {
+ 'name': 'curr_1',
+ 'debit': 1000.0,
+ 'credit': 0.0,
+ 'amount_currency': 1000.0,
+ 'currency_id': self.company_data['currency'].id,
+ 'account_id': self.company_data['default_account_receivable'].id,
+ }),
+ (0, 0, {
+ 'name': 'curr_2',
+ 'debit': 0.0,
+ 'credit': 1000.0,
+ 'amount_currency': -2000.0,
+ 'currency_id': self.other_currency.id,
+ 'account_id': foreign_curr_account.id,
+ }),
+ ],
+ })
+ move_2017.action_post()
+ move_2017.line_ids.flush_recordset()
+
+ # Init options.
+ options = self._generate_options(self.report, fields.Date.from_string('2017-01-01'), fields.Date.from_string('2017-12-31'))
+ options['unfolded_lines'] = [self.report._get_generic_line_id('account.account', foreign_curr_account.id)]
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Amount_currency Debit Credit Balance
+ [ 0, 4, 5, 6, 7],
+ [
+ ('121000 Account Receivable', '', 2100.0, 0.0, 2100.0),
+ ('211000 Account Payable', '', 100.0, 0.0, 100.0),
+ ('211010 Account Payable', '', 50.0, 0.0, 50.0),
+ ('400000 Product Sales', '', 20000.0, 0.0, 20000.0),
+ ('400010 Product Sales', '', 0.0, 200.0, -200.0),
+ ('600000 Expenses', '', 0.0, 21000.0, -21000.0),
+ ('600010 Expenses', '', 200.0, 0.0, 200.0),
+ ('999989 Undistributed Profits/Losses', '', 0.0, 50.0, -50.0),
+ ('999999 Undistributed Profits/Losses', '', 200.0, 300.0, -100.0),
+ ('test foreign_curr_account', -2300.0, 0.0, 1100.0, -1100.0),
+ ('Initial Balance', -300.0, 0.0, 100.0, -100.0),
+ ('INV/2017/00002', -2000.0, 0.0, 1000.0, -1100.0),
+ ('Total test foreign_curr_account', -2300.0, 0.0, 1100.0, -1100.0),
+ ('Total', '', 22650.0, 22650.0, 0.0),
+ ],
+ options,
+ currency_map={4: {'currency': self.other_currency}},
+ )
+
+ def test_general_ledger_filter_search_bar_print(self):
+ """ Test the lines generated when a user filters on the search bar and prints the report """
+ options = self._generate_options(self.report, '2017-01-01', '2017-12-31', default_options={'export_mode': 'print'})
+ options['filter_search_bar'] = '400'
+ options['unfold_all'] = True
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 4, 5, 6],
+ [
+ ('400000 Product Sales', 20000.0, 0.0, 20000.0),
+ ('INV/2017/00001', 2000.0, 0.0, 2000.0),
+ ('INV/2017/00001', 3000.0, 0.0, 5000.0),
+ ('INV/2017/00001', 4000.0, 0.0, 9000.0),
+ ('INV/2017/00001', 5000.0, 0.0, 14000.0),
+ ('INV/2017/00001', 6000.0, 0.0, 20000.0),
+ ('Total 400000 Product Sales', 20000.0, 0.0, 20000.0),
+ ('400010 Product Sales', 0.0, 200.0, -200.0),
+ ('BNK1/2017/00001', 0.0, 200.0, -200.0),
+ ('Total 400010 Product Sales', 0.0, 200.0, -200.0),
+ ('Total', 20000.0, 200.0, 19800.0),
+ ],
+ options,
+ )
+
+ options['filter_search_bar'] = '999'
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 4, 5, 6],
+ [
+ ('999989 Undistributed Profits/Losses', 0.0, 50.0, -50.0),
+ ('999999 Undistributed Profits/Losses', 200.0, 300.0, -100.0),
+ ('Total', 200.0, 350.0, -150.0),
+ ],
+ options,
+ )
+
+ def test_general_ledger_communication(self):
+ invoice_1 = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': '2010-01-01',
+ 'payment_reference': 'payment_ref1',
+ 'ref': 'ref1',
+ 'invoice_line_ids': [(0, 0, {
+ 'name': 'test1',
+ 'tax_ids': [],
+ 'quantity': 1,
+ 'price_unit': 1,
+ })]
+ })
+ invoice_1.action_post()
+
+ invoice_2 = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': '2010-01-01',
+ 'payment_reference': 'payment_ref2',
+ 'invoice_line_ids': [(0, 0, {
+ 'name': 'test2',
+ 'tax_ids': [],
+ 'quantity': 1,
+ 'price_unit': 2,
+ })]
+ })
+ invoice_2.action_post()
+
+ self.env.company.totals_below_sections = False
+ options = self._generate_options(self.report, '2010-01-01', '2010-01-01', default_options={'unfold_all': True})
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Communication
+ [ 0, 2],
+ [
+ ('121000 Account Receivable', ''),
+ (invoice_1.name, 'ref1 - payment_ref1'),
+ (invoice_2.name, 'payment_ref2'),
+ ('400000 Product Sales', ''),
+ (invoice_1.name, 'ref1 - test1'),
+ (invoice_2.name, 'test2'),
+ ('Total', ''),
+ ],
+ options,
+ )
+
+ def test_general_ledger_income_expense_initial_balance(self):
+ ''' Test that when the report period does not start at the beginning of the FY,
+ any AMLs prior to the report period but after the beginning of the FY are
+ displayed in the initial balance for Income and Expense accounts. '''
+
+ self.env.companies = self.env.company
+
+ move_2017 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2017-02-01'),
+ 'journal_id': self.company_data['default_journal_sale'].id,
+ 'line_ids': [
+ Command.create({'debit': 1000.0, 'credit': 0.0, 'name': '2017_3_1', 'account_id': self.company_data['default_account_receivable'].id}),
+ Command.create({'debit': 0.0, 'credit': 1000.0, 'name': '2017_3_2', 'account_id': self.company_data['default_account_revenue'].id}),
+ ],
+ })
+ move_2017.action_post()
+
+ # Init options.
+ options = self._generate_options(self.report, '2017-02-01', '2017-03-01')
+ options['unfolded_lines'] = [self.report._get_generic_line_id('account.account', self.company_data['default_account_revenue'].id)]
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 4, 5, 6],
+ [
+ ('121000 Account Receivable', 2000.0, 0.0, 2000.0),
+ ('211000 Account Payable', 100.0, 0.0, 100.0),
+ ('400000 Product Sales', 20000.0, 1000.0, 19000.0),
+ ('Initial Balance', 20000.0, 0.0, 20000.0),
+ ('INV/2017/00002', 0.0, 1000.0, 19000.0),
+ ('Total 400000 Product Sales', 20000.0, 1000.0, 19000.0),
+ ('600000 Expenses', 0.0, 21000.0, -21000.0),
+ ('999999 Undistributed Profits/Losses', 200.0, 300.0, -100.0),
+ ('Total', 22300.0, 22300.0, 0.0),
+ ],
+ options,
+ )
+
+ @freeze_time('2017-07-11')
+ def test_tour_account_reports_search(self):
+ move_07_2017 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2017-07-10'),
+ 'journal_id': self.company_data['default_journal_sale'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'name': '2017_1_1',
+ 'account_id': self.company_data['default_account_receivable'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 1000.0, 'name': '2017_1_2',
+ 'account_id': self.company_data['default_account_revenue'].id}),
+ ],
+ })
+ move_07_2017.action_post()
+
+ self.start_tour("/odoo", 'account_reports_search', login=self.env.user.login)
+
+ def test_general_ledger_hierarchy_non_numerical_column_value(self):
+ """
+ This test will check the value of the different (non-numerical) columns of the general ledger in case the
+ hierarchy options is enabled
+ """
+ options = self._generate_options(self.report, '2017-01-01', '2017-12-31')
+ options['hierarchy'] = True
+
+ # String and Date figure type should be empty when using hierarchy.
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Date Communication Partner
+ [0, 1, 2, 3],
+ [
+ ('(No Group)', '', '', ''),
+ ('Total', '', '', ''),
+ ],
+ options,
+ )
+
+ def test_general_ledger_same_date_ordering(self):
+ self.env.company.account_sale_tax_id = None
+ self.env.company.totals_below_sections = False
+
+ report = self.env.ref('odex30_account_reports.general_ledger_report')
+ options = self._generate_options(report, fields.Date.from_string('2010-01-01'), fields.Date.from_string('2010-01-01'), default_options={'unfold_all': True})
+
+ move_1 = self.init_invoice('out_invoice', invoice_date='2010-01-01', amounts=[100])
+ move_2 = self.init_invoice('out_invoice', invoice_date='2010-01-01', amounts=[200])
+
+ # Make sure no sequence is set on them by default, so that move_2 can receive a lower sequence when posting
+ (move_1 + move_2).write({'name': ''})
+
+ # Post the moves in reverse order than the one they were created in, so that their line ids' respective order does not match their sequences'
+ move_2.action_post()
+ move_1.action_post()
+
+ self.assertLinesValues(
+ report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 4, 5, 6],
+ [
+ ('121000 Account Receivable', 300.0, 0.0, 300.0),
+ (move_2.name, 200.0, 0.0, 200.0),
+ (move_1.name, 100.0, 0.0, 300.0),
+ ('400000 Product Sales', 0.0, 300.0, -300.0),
+ (move_2.name, 0.0, 200.0, -200.0),
+ (move_1.name, 0.0, 100.0, -300.0),
+ ('Total', 300.0, 300.0, 0.0),
+ ],
+ options
+ )
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_journal_report.py b/dev_odex30_accounting/odex30_account_reports/tests/test_journal_report.py
new file mode 100644
index 0000000..affbf59
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_journal_report.py
@@ -0,0 +1,513 @@
+# pylint: disable=C0326
+from .common import TestAccountReportsCommon
+
+from odoo import Command, fields
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestJournalReport(TestAccountReportsCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.report = cls.env.ref('odex30_account_reports.journal_report')
+
+ ##############
+ # Bank entries
+ ##############
+
+ cls.default_bank_journal = cls.company_data['default_journal_bank']
+ cls.default_sale_journal = cls.company_data['default_journal_sale']
+
+ # Entries in 2016 for company_1 to test the starting balance of bank journals.
+ cls.liquidity_account = cls.default_bank_journal.default_account_id
+ cls.move_bank_0 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': cls.company_data['default_journal_bank'].id,
+ 'line_ids': [
+ Command.create({'debit': 100.0, 'credit': 0.0, 'name': '2016_1_1', 'account_id': cls.liquidity_account.id}),
+ Command.create({'debit': 0.0, 'credit': 100.0, 'name': '2016_1_2', 'account_id': cls.company_data['default_account_revenue'].id}),
+ ],
+ })
+ cls.move_bank_0.action_post()
+
+ # Entries in 2017 for company_1 to test the bank journal at current date.
+ cls.move_bank_1 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2017-01-01',
+ 'journal_id': cls.company_data['default_journal_bank'].id,
+ 'line_ids': [
+ Command.create({'debit': 200.0, 'credit': 0.0, 'name': '2017_1_1', 'account_id': cls.liquidity_account.id}),
+ Command.create({'debit': 0.0, 'credit': 200.0, 'name': '2017_1_2', 'account_id': cls.company_data['default_account_revenue'].id}),
+ ],
+ })
+ cls.move_bank_1.action_post()
+
+ ##############
+ # Sales entries
+ ##############
+
+ # Invoice in 2017 for company_1 to test a sale journal at current date.
+ cls.move_sales_0 = cls.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': cls.partner_a.id,
+ 'invoice_date': '2017-01-01',
+ 'journal_id': cls.company_data['default_journal_sale'].id,
+ 'payment_reference': 'ref123',
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 3000.0,
+ 'account_id': cls.company_data['default_account_revenue'].id,
+ 'tax_ids': [],
+ })],
+ })
+ cls.move_sales_0.action_post()
+
+ # Invoice in 2017 for company_1, with foreign currency to test a sale journal at current date.
+ cls.other_currency = cls.setup_other_currency('EUR', rounding=0.001, rates=[('2016-01-01', 2.0), ('2017-01-01', 2.0)])
+
+ cls.move_sales_1 = cls.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': cls.partner_a.id,
+ 'invoice_date': '2017-01-01',
+ 'journal_id': cls.company_data['default_journal_sale'].id,
+ 'currency_id': cls.other_currency.id,
+ 'payment_reference': 'ref234',
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 3000.0,
+ 'account_id': cls.company_data['default_account_revenue'].id,
+ 'tax_ids': [],
+ })],
+ })
+ cls.move_sales_1.action_post()
+
+ # Invoice in 2017 for company_1, with foreign currency but no ref.
+ cls.move_sales_2 = cls.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': cls.partner_a.id,
+ 'invoice_date': '2017-01-01',
+ 'journal_id': cls.company_data['default_journal_sale'].id,
+ 'currency_id': cls.other_currency.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 2000.0,
+ 'account_id': cls.company_data['default_account_revenue'].id,
+ 'tax_ids': [],
+ })],
+ })
+ cls.move_sales_2.action_post()
+ cls.move_sales_2.payment_reference = ''
+
+ # Set up a tax report, tax report line, and all needed to get a tax with a grid.
+ cls.company_data['company'].country_id = cls.env.ref('base.us')
+ cls.tax_report = cls.env['account.report'].create({
+ 'name': 'Tax report',
+ 'root_report_id': cls.env.ref('account.generic_tax_report').id,
+ 'country_id': cls.company_data['company'].country_id.id,
+ 'filter_fiscal_position': True,
+ 'availability_condition': 'country',
+ 'column_ids': [Command.create({
+ 'name': 'Balance',
+ 'expression_label': 'balance',
+ 'sequence': 1,
+ })],
+ 'line_ids': [Command.create({
+ 'name': '10%',
+ 'code': 'c10',
+ 'sequence': 1,
+ 'expression_ids': [Command.create({
+ 'label': 'balance',
+ 'engine': 'tax_tags',
+ 'formula': 'c10',
+ })]
+ })]
+ })
+ cls.test_tax = cls.env['account.tax'].create({
+ 'name': 'Tax 10%',
+ 'amount': 10.0,
+ 'amount_type': 'percent',
+ 'type_tax_use': 'sale',
+ 'invoice_repartition_line_ids': [
+ Command.create({'repartition_type': 'base'}),
+ Command.create({
+ 'repartition_type': 'tax',
+ 'tag_ids': [Command.link(cls.tax_report.line_ids.expression_ids._get_matching_tags("+").id)],
+ })]
+ })
+ # Invoice in 2017 for company_1, with taxes
+ cls.move_sales_3 = cls.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': cls.partner_a.id,
+ 'invoice_date': '2017-01-01',
+ 'payment_reference': 'ref345',
+ 'journal_id': cls.company_data['default_journal_sale'].id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 1500.0,
+ 'account_id': cls.company_data['default_account_revenue'].id,
+ 'tax_ids': [cls.test_tax.id],
+ })],
+ })
+ cls.move_sales_3.action_post()
+
+ cls.move_payment_0 = cls.env['account.payment'].create({
+ 'amount': 370,
+ 'partner_id': cls.partner_a.id,
+ 'payment_reference': 'PBNK1/2017/00000001',
+ 'payment_type': 'inbound',
+ 'destination_account_id': cls.company_data['default_account_revenue'].id,
+ 'date': '2017-01-01'
+ })
+ cls.move_payment_0.action_post()
+
+ def _filter_tax_section_lines(self, lines, exclude):
+ def filter_condition(line):
+ markup = self.report._get_markup(line['id'])
+ return exclude ^ (isinstance(markup, str) and markup.startswith('tax_report_section'))
+
+ return list(filter(filter_condition, lines))
+
+ def assert_journal_vals_for_export(self, report, options, actual_journal_vals, expected_journals_data):
+ self.assertEqual(len(actual_journal_vals), len(expected_journals_data))
+ for actual_item, expected_item in zip(actual_journal_vals, expected_journals_data):
+ # Check the columns
+ if 'columns' in expected_item:
+ self.assertEqual(expected_item['columns'], [col['label'] for col in actual_item['columns']])
+
+ # Check the lines
+ if 'lines' in expected_item:
+ self.assertEqual(len(expected_item['lines']), len(actual_item['lines']))
+ for expected_line, actual_line in zip(expected_item['lines'], actual_item['lines']):
+ self.assertDictEqual(expected_line, {expected_key: actual_line.get(expected_key, {}).get('data') for expected_key in expected_line})
+
+ def test_journal_lines(self):
+ """
+ Check the journal report lines for the journal of type sale within the first month of 2017
+ """
+ options_2016 = self._generate_options(self.report, '2016-01-01', '2016-01-31', default_options={'unfold_all': True, 'show_payment_lines': False})
+ lines_2016 = self.report._get_lines(options_2016)
+ self.assertLinesValues(
+ self._filter_tax_section_lines(lines_2016, True),
+ [ 1, 2, 3, 4],
+ [
+ ('', '', '', ''),
+ ('BNK1', 100, 100, ''),
+ ('101401', 100, 0, 100),
+ ('400000', 0, 100, -100),
+ ],
+ options_2016,
+ )
+ tax_summary_lines_2016 = self._filter_tax_section_lines(lines_2016, False)
+ self.assertFalse(tax_summary_lines_2016)
+
+ options_2017 = self._generate_options(self.report, '2017-01-01', '2017-01-31', default_options={'unfold_all': True, 'show_payment_lines': False})
+ lines_2017 = self.report._get_lines(options_2017)
+ self.assertLinesValues(
+ self._filter_tax_section_lines(lines_2017, True),
+ [ 1, 2, 3, 4],
+ [
+ ('', '', '', ''),
+ ('INV', 7150, 7150, ''),
+ ('121000', 7150, 0, 7150),
+ ('400000', 0, 7150, -7150),
+ ('BNK1', 200, 200, ''),
+ ('101401', 200, 0, 200),
+ ('400000', 0, 200, -200),
+ ],
+ options_2017,
+ )
+ tax_summary_lines_2017 = self._filter_tax_section_lines(lines_2017, False)
+ tax_tag = self.tax_report.line_ids.expression_ids._get_matching_tags("+")
+ self.assertDictEqual(
+ tax_summary_lines_2017[0]['tax_grid_summary_lines'],
+ {'United States': {'c10': {'tag_ids': tax_tag.ids, '+': '$\xa0150.00', '-': '$\xa00.00', '+_no_format': 150.0, 'impact': '$\xa0150.00'}}}
+ )
+ self.assertEqual(tax_summary_lines_2017[1]['name'], "Global Tax Summary")
+ self.assertDictEqual(
+ tax_summary_lines_2017[2]['tax_grid_summary_lines'],
+ {'United States': {'c10': {'tag_ids': tax_tag.ids, '+': '$\xa0150.00', '-': '$\xa00.00', '+_no_format': 150.0, 'impact': '$\xa0150.00'}}}
+ )
+
+ options_global = self._generate_options(self.report, '2016-01-01', '2017-01-31', default_options={'unfold_all': True, 'show_payment_lines': False})
+ lines_global = self.report._get_lines(options_global)
+ self.assertLinesValues(
+ self._filter_tax_section_lines(lines_global, True),
+ [ 1, 2, 3, 4],
+ [
+ ('', '', '', ''),
+ ('INV', 7150, 7150, ''),
+ ('121000', 7150, 0, 7150),
+ ('400000', 0, 7150, -7150),
+ ('BNK1', 300, 300, ''),
+ ('101401', 300, 0, 300),
+ ('400000', 0, 300, -300),
+ ],
+ options_global,
+ )
+ tax_summary_lines_global = self._filter_tax_section_lines(lines_global, False)
+ self.assertDictEqual(
+ tax_summary_lines_global[0]['tax_grid_summary_lines'],
+ {'United States': {'c10': {'tag_ids': tax_tag.ids, '+': '$\xa0150.00', '-': '$\xa00.00', '+_no_format': 150.0, 'impact': '$\xa0150.00'}}}
+ )
+ self.assertEqual(tax_summary_lines_global[1]['name'], "Global Tax Summary")
+ self.assertDictEqual(
+ tax_summary_lines_global[2]['tax_grid_summary_lines'],
+ {'United States': {'c10': {'tag_ids': tax_tag.ids, '+': '$\xa0150.00', '-': '$\xa00.00', '+_no_format': 150.0, 'impact': '$\xa0150.00'}}}
+ )
+
+ def test_show_payment_lines_option(self):
+ """
+ Check the journal report lines of the default bank journal with payments included
+ """
+ options_no_payment = self._generate_options(self.report, '2017-01-01', '2017-01-31', default_options={'unfold_all': True, 'show_payment_lines': False})
+
+ self.assertLinesValues(
+ self._filter_tax_section_lines(self.report._get_lines(options_no_payment), True),
+ [ 1, 2, 3, 4],
+ [
+ ('', '', '', ''),
+ ('INV', 7150, 7150, ''),
+ ('121000', 7150, 0, 7150),
+ ('400000', 0, 7150, -7150),
+ ('BNK1', 200, 200, ''),
+ ('101401', 200, 0, 200),
+ ('400000', 0, 200, -200),
+ ],
+ options_no_payment,
+ )
+
+ options_show_payment = self._generate_options(self.report, '2017-01-01', '2017-01-31', default_options={'unfold_all': True, 'show_payment_lines': True})
+ self.assertLinesValues(
+ self._filter_tax_section_lines(self.report._get_lines(options_show_payment), True),
+ [ 1, 2, 3, 4],
+ [
+ ('', '', '', ''),
+ ('INV', 7150, 7150, ''),
+ ('121000', 7150, 0, 7150),
+ ('400000', 0, 7150, -7150),
+ ('BNK1', 570, 570, ''),
+ ('101401', 200, 0, 200),
+ ('101403', 370, 0, 370),
+ ('400000', 0, 570, -570),
+ ],
+ options_show_payment,
+ )
+
+ def test_document_data_basic(self):
+ """
+ Check that the data generated by the document data generator is valid
+ """
+ options = self._generate_options(self.report, '2017-01-01', '2017-01-31', default_options={'show_payment_lines': False})
+
+ journal_report_handler = self.env[self.report.custom_handler_model_name]
+ move_2_pref = self.move_sales_2.payment_reference
+ self.assert_journal_vals_for_export(
+ self.report,
+ options,
+ journal_report_handler._generate_document_data_for_export(self.report, options, 'pdf')['journals_vals'],
+ [
+ {
+ 'id': self.default_sale_journal.id,
+ 'columns': ['document', 'account_label', 'name', 'debit', 'credit', 'taxes', 'tax_grids'],
+ 'lines': [
+ {'name': 'partner_a ref123', 'debit': '$\xa03,000.00', 'credit': '$\xa00.00'},
+ {'name': 'ref123', 'debit': '$\xa00.00', 'credit': '$\xa03,000.00'},
+
+ {'name': 'partner_a ref234', 'debit': '$\xa01,500.00', 'credit': '$\xa00.00'},
+ {'name': 'ref234', 'debit': '$\xa00.00', 'credit': '$\xa01,500.00'},
+
+ {'currency_id': self.other_currency.id, 'amount': 3000}, # Special line for multicurrency
+
+ {'name': f'partner_a {move_2_pref}', 'debit': '$\xa01,000.00', 'credit': '$\xa00.00'},
+ {'name': move_2_pref, 'debit': '$\xa00.00', 'credit': '$\xa01,000.00'},
+
+ {'currency_id': self.other_currency.id, 'amount': 2000}, # Special line for multicurrency
+
+ {'name': 'partner_a ref345', 'debit': '$\xa01,650.00', 'credit': '$\xa00.00'},
+ {'name': 'ref345', 'debit': '$\xa00.00', 'credit': '$\xa01,500.00', 'taxes': 'T: Tax 10%'},
+ {'name': 'Tax 10%', 'debit': '$\xa00.00', 'credit': '$\xa0150.00', 'taxes': 'B: $\xa01,500.00'},
+
+ {}, # Empty line
+
+ {'name': 'Total', 'debit': '$\xa07,150.00', 'credit': '$\xa07,150.00'},
+ ],
+ },
+ {
+ 'id': self.default_bank_journal.id,
+ 'columns': ['document', 'account_label', 'name', 'debit', 'credit', 'balance'],
+ 'lines': [
+ {'name': 'Starting Balance', 'balance': '$\xa0100.00'},
+ {'name': '2017_1_2', 'debit': '$\xa00.00', 'credit': '$\xa0200.00', 'balance': '$\xa0300.00'},
+
+ {}, # Empty line
+
+ {'name': 'Total', 'debit': None, 'credit': None, 'balance': '$\xa0300.00'},
+ ],
+ },
+ ],
+ )
+
+ def test_document_data_for_bank_journal_with_show_payment_option(self):
+ """
+ Check that show payment affect the result of the bank journal data when this filter is changed
+ """
+ options = self._generate_options(self.report, '2017-01-01', '2017-01-31', default_options={'show_payment_lines': True})
+ journal_report_handler = self.env[self.report.custom_handler_model_name]
+ move_2_pref = self.move_sales_2.payment_reference
+ self.assert_journal_vals_for_export(
+ self.report,
+ options,
+ journal_report_handler._generate_document_data_for_export(self.report, options, 'pdf')['journals_vals'],
+ [
+ {
+ 'id': self.default_sale_journal.id,
+ 'columns': ['document', 'account_label', 'name', 'debit', 'credit', 'taxes', 'tax_grids'],
+ 'lines': [
+ {'name': 'partner_a ref123', 'debit': '$\xa03,000.00', 'credit': '$\xa00.00'},
+ {'name': 'ref123', 'debit': '$\xa00.00', 'credit': '$\xa03,000.00'},
+
+ {'name': 'partner_a ref234', 'debit': '$\xa01,500.00', 'credit': '$\xa00.00'},
+ {'name': 'ref234', 'debit': '$\xa00.00', 'credit': '$\xa01,500.00'},
+
+ {'currency_id': self.other_currency.id, 'amount': 3000}, # Special line for multicurrency
+
+ {'name': f'partner_a {move_2_pref}', 'debit': '$\xa01,000.00', 'credit': '$\xa00.00'},
+ {'name': move_2_pref, 'debit': '$\xa00.00', 'credit': '$\xa01,000.00'},
+
+ {'currency_id': self.other_currency.id, 'amount': 2000}, # Special line for multicurrency
+
+ {'name': 'partner_a ref345', 'debit': '$\xa01,650.00', 'credit': '$\xa00.00'},
+ {'name': 'ref345', 'debit': '$\xa00.00', 'credit': '$\xa01,500.00', 'taxes': 'T: Tax 10%'},
+ {'name': 'Tax 10%', 'debit': '$\xa00.00', 'credit': '$\xa0150.00', 'taxes': 'B: $\xa01,500.00'},
+
+ {}, # Empty line
+
+ {'name': 'Total', 'debit': '$\xa07,150.00', 'credit': '$\xa07,150.00'},
+ ],
+ },
+ {
+ 'id': self.default_bank_journal.id,
+ 'columns': ['document', 'account_label', 'name', 'debit', 'credit', 'balance'],
+ 'lines': [
+ {'name': 'Starting Balance', 'balance': '$\xa0100.00'},
+
+ {'name': '2017_1_2', 'debit': '$\xa00.00', 'credit': '$\xa0200.00', 'balance': '$\xa0300.00'},
+
+ # Payment
+ {'name': 'Manual Payment', 'debit': '$\xa0370.00', 'credit': '$\xa00.00', 'balance': None},
+ {'name': 'Manual Payment', 'debit': '$\xa00.00', 'credit': '$\xa0370.00', 'balance': None},
+
+ {}, # Empty line
+
+ {'name': 'Total', 'debit': None, 'credit': None, 'balance': '$\xa0300.00'},
+ ],
+ },
+ ],
+ )
+
+ def test_document_data_for_bank_multicurrency(self):
+ """
+ Test that data from bank journal can support multi currency bank moves
+ """
+ options = self._generate_options(self.report, '2017-01-01', '2017-01-31', default_options={'show_payment_lines': True})
+ journal_report_handler = self.env[self.report.custom_handler_model_name]
+ self.assert_journal_vals_for_export(
+ self.report,
+ options,
+ list(filter(lambda x: x['id'] == self.default_bank_journal.id, journal_report_handler._generate_document_data_for_export(self.report, options, 'pdf')['journals_vals'])),
+ [
+ {
+ 'id': self.default_bank_journal.id,
+ 'columns': ['document', 'account_label', 'name', 'debit', 'credit', 'balance'],
+ 'lines': [
+ {'name': 'Starting Balance', 'balance': '$\xa0100.00'},
+
+ {'name': '2017_1_2', 'debit': '$\xa00.00', 'credit': '$\xa0200.00', 'balance': '$\xa0300.00'},
+
+ # Payment
+ {'name': 'Manual Payment', 'debit': '$\xa0370.00', 'credit': '$\xa00.00', 'balance': None},
+ {'name': 'Manual Payment', 'debit': '$\xa00.00', 'credit': '$\xa0370.00', 'balance': None},
+
+ {}, # Empty line
+
+ {'name': 'Total', 'debit': None, 'credit': None, 'balance': '$\xa0300.00'},
+ ]
+ }
+ ]
+ )
+
+ new_bank_move = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2017-01-01',
+ 'journal_id': self.company_data['default_journal_bank'].id,
+ 'currency_id': self.other_currency.id,
+ 'line_ids': [
+ Command.create({
+ 'debit': 100.0,
+ 'credit': 0.0,
+ 'name': '2017_1_3',
+ 'account_id': self.liquidity_account.id,
+ 'currency_id': self.other_currency.id,
+ 'amount_currency': 200
+ }),
+ Command.create({
+ 'debit': 0.0,
+ 'credit': 100.0,
+ 'name': '2017_1_4',
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'currency_id': self.other_currency.id,
+ 'amount_currency': -200
+ }),
+ ],
+ })
+ new_bank_move.action_post()
+
+ self.assert_journal_vals_for_export(
+ self.report,
+ options,
+ list(filter(lambda x: x['id'] == self.default_bank_journal.id, journal_report_handler._generate_document_data_for_export(self.report, options, 'pdf')['journals_vals'])),
+ [
+ {
+ 'id': self.default_bank_journal.id,
+ 'columns': ['document', 'account_label', 'name', 'debit', 'credit', 'balance', 'amount_currency'],
+ 'lines': [
+ {'name': 'Starting Balance', 'balance': '$\xa0100.00'},
+
+ {'name': '2017_1_2', 'debit': '$\xa00.00', 'credit': '$\xa0200.00', 'balance': '$\xa0300.00'},
+ {'name': '2017_1_4', 'debit': '$\xa00.00', 'credit': '$\xa0100.00', 'balance': '$\xa0400.00', 'amount_currency': '200.000\xa0€'},
+
+ # Payment --
+ {'name': 'Manual Payment', 'debit': '$\xa0370.00', 'credit': '$\xa00.00', 'balance': None},
+ {'name': 'Manual Payment', 'debit': '$\xa00.00', 'credit': '$\xa0370.00', 'balance': None},
+
+ {}, # Empty line
+
+ {'name': 'Total', 'debit': None, 'credit': None, 'balance': '$\xa0400.00'}
+ ],
+ },
+ ],
+ )
+
+ def test_global_tax_summary_rounding_unit(self):
+ """ Test that Global Tax Summary applies rounding filter correctly. """
+ report = self.env.ref('odex30_account_reports.journal_report')
+ options = self._generate_options(report, '2017-01-01', '2017-01-31')
+ options['unfold_all'] = True
+
+ # Get tax summary lines with default formatting
+ lines = report._get_lines(options)
+ tax_lines = list(filter(lambda line: line.get('is_tax_section_line'), lines))
+
+ default_balance = tax_lines[0]['tax_grid_summary_lines']['United States']['c10']['+']
+ self.assertEqual(default_balance, '$\xa0150.00') # Default: $150.00
+
+ # Test with thousands rounding unit, call via dispatch_report_action like the frontend does
+ options['rounding_unit'] = 'thousands'
+ report.dispatch_report_action(options, 'format_column_values_from_client', lines)
+
+ thousands_balance = tax_lines[0]['tax_grid_summary_lines']['United States']['c10']['+']
+ self.assertEqual(thousands_balance, '$\xa00') # Thousands: $0 (150/1000 = 0.15 rounded to 0)
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_multicurrencies_revaluation_report.py b/dev_odex30_accounting/odex30_account_reports/tests/test_multicurrencies_revaluation_report.py
new file mode 100644
index 0000000..51ba6d8
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_multicurrencies_revaluation_report.py
@@ -0,0 +1,1219 @@
+# -*- coding: utf-8 -*-
+from freezegun import freeze_time
+from .common import TestAccountReportsCommon
+
+from odoo import fields, Command
+from odoo.tests import tagged
+from odoo.exceptions import UserError
+
+
+@tagged('post_install', '-at_install')
+class TestMultiCurrenciesRevaluationReport(TestAccountReportsCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.other_currency_2 = cls.setup_other_currency('XAF', rates=[('2016-01-01', 10.0), ('2017-01-01', 20.0)])
+
+ cls.expense_account_1 = cls.company_data['default_account_expense']
+ cls.expense_account_2 = cls.copy_account(cls.company_data['default_account_expense'])
+
+ cls.env['res.currency.rate'].create({
+ 'name': '2023-01-20',
+ 'rate': 1,
+ 'currency_id': cls.other_currency.id,
+ 'company_id': cls.company_data['company'].id,
+ })
+
+ cls.env['res.currency.rate'].create({
+ 'name': '2023-01-25',
+ 'rate': 2,
+ 'currency_id': cls.other_currency.id,
+ 'company_id': cls.company_data['company'].id,
+ })
+
+ cls.env['res.currency.rate'].create({
+ 'name': '2023-01-30',
+ 'rate': 4,
+ 'currency_id': cls.other_currency.id,
+ 'company_id': cls.company_data['company'].id,
+ })
+
+ cls.env['res.currency.rate'].create({
+ 'name': '2023-01-20',
+ 'rate': 1,
+ 'currency_id': cls.other_currency_2.id,
+ 'company_id': cls.company_data['company'].id,
+ })
+
+
+ cls.report = cls.env.ref('odex30_account_reports.multicurrency_revaluation_report')
+
+ @classmethod
+ def pay_move(cls, move, amount, date, account_type='liability_payable', currency=None, partner_type=None):
+ if not currency:
+ currency = move.currency_id
+
+ assert amount
+ if amount > 0:
+ payment_type = 'outbound'
+ payment_method = 'account.account_payment_method_manual_out'
+ partner_type = 'supplier' if not partner_type else partner_type
+ else:
+ payment_type = 'inbound'
+ payment_method = 'account.account_payment_method_manual_in'
+ partner_type = 'customer' if not partner_type else partner_type
+
+ payment = cls.env['account.payment'].create({
+ 'payment_type': payment_type,
+ 'amount': abs(amount),
+ 'currency_id': currency.id,
+ 'journal_id': cls.company_data['default_journal_bank'].id,
+ 'date': fields.Date.from_string(date),
+ 'partner_id': move.partner_id.id,
+ 'payment_method_id': cls.env.ref(payment_method).id,
+ 'partner_type': partner_type,
+ })
+ payment.action_post()
+ lines_to_reconcile = move.line_ids.filtered(lambda x: x.account_type == account_type)
+ lines_to_reconcile += payment.move_id.line_ids.filtered(lambda x: x.account_type == account_type)
+ lines_to_reconcile.reconcile()
+
+ @classmethod
+ def create_move_one_line(cls, move_type, journal_id, partner_id, date, invoice_date, currency_id, account_id, quantity, price_unit, payment_term_id=None):
+ move = cls.env['account.move'].create({
+ 'move_type': move_type,
+ 'partner_id': partner_id,
+ 'date': date,
+ 'invoice_date': invoice_date,
+ 'journal_id': journal_id,
+ 'currency_id': currency_id,
+ 'invoice_payment_term_id': payment_term_id,
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'My Super Product',
+ 'account_id': account_id,
+ 'quantity': quantity,
+ 'price_unit': price_unit,
+ 'tax_ids': [Command.clear()],
+ 'currency_id': cls.other_currency.id,
+ }),
+ ],
+ })
+ move.action_post()
+ return move
+
+ def test_multi_currencies(self):
+ """ In this test we will do two moves with same currency (CAD) and 3 payments for the first move with
+ 3 different currencies (CAD, Dar, USD)
+ """
+ first_bill = self.create_move_one_line(
+ partner_id=self.partner_a.id,
+ move_type='in_invoice',
+ journal_id=self.company_data['default_journal_purchase'].id,
+ date='2023-01-21',
+ invoice_date='2023-01-21',
+ currency_id=self.other_currency.id,
+ account_id=self.company_data['default_account_expense'].id,
+ quantity=1,
+ price_unit=800.0
+ )
+
+ self.create_move_one_line(
+ partner_id=self.partner_a.id,
+ move_type='in_invoice',
+ journal_id=self.company_data['default_journal_purchase'].id,
+ date='2023-01-21',
+ invoice_date='2023-01-21',
+ currency_id=self.other_currency.id,
+ account_id=self.company_data['default_account_expense'].id,
+ quantity=1,
+ price_unit=200.0
+ )
+
+ self.pay_move(
+ first_bill,
+ 400,
+ '2023-01-21',
+ account_type=self.company_data['default_account_payable'].account_type,
+ currency=self.other_currency
+ )
+
+ self.pay_move(
+ first_bill,
+ 250,
+ '2023-01-21',
+ account_type=self.company_data['default_account_payable'].account_type,
+ currency=self.other_currency_2
+ )
+
+ self.pay_move(
+ first_bill,
+ 150,
+ '2023-01-21',
+ account_type=self.company_data['default_account_payable'].account_type,
+ currency=self.company_data['currency']
+ )
+
+ # Test the report in 2023.
+ options = self._generate_options(self.report, '2023-01-01', '2023-12-31')
+ options['unfold_all'] = True
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 4.0 CAD)', -200.0, -200.0, -50.0, 150.0),
+ ('211000 Account Payable', -200.0, -200.0, -50.0, 150.0),
+ ('BILL/2023/01/0002', -200.0, -200.0, -50.0, 150.0),
+ ('Total 211000 Account Payable', -200.0, -200.0, -50.0, 150.0),
+ ('Total CAD', -200.0, -200.0, -50.0, 150.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ def test_same_currency(self):
+ """ In this test we will do two moves with same currency and a bank statement line to reconcile the first payment.
+ The payment and the move have the same currency (CAD)
+ """
+ first_bill = self.create_move_one_line(
+ partner_id=self.partner_a.id,
+ move_type='in_invoice',
+ journal_id=self.company_data['default_journal_purchase'].id,
+ date='2023-01-21',
+ invoice_date='2023-01-21',
+ currency_id=self.other_currency.id,
+ account_id=self.company_data['default_account_expense'].id,
+ quantity=1,
+ price_unit=800.0
+ )
+
+ self.create_move_one_line(
+ partner_id=self.partner_a.id,
+ move_type='in_invoice',
+ journal_id=self.company_data['default_journal_purchase'].id,
+ date='2023-01-21',
+ invoice_date='2023-01-21',
+ currency_id=self.other_currency.id,
+ account_id=self.company_data['default_account_expense'].id,
+ quantity=1,
+ price_unit=200.0
+ )
+
+ bank_statement = self.env['account.bank.statement.line'].create({
+ 'journal_id': self.company_data['default_journal_bank'].id,
+ 'payment_ref': 'payment_move_line',
+ 'partner_id': self.partner_a.id,
+ 'foreign_currency_id': self.other_currency.id,
+ 'amount': -400,
+ 'amount_currency': -800,
+ 'date': '2023-01-01',
+ })
+
+ wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=bank_statement.id).new({})
+ wizard._action_add_new_amls(first_bill.line_ids.filtered(lambda account: account.account_type == 'liability_payable'))
+ wizard._action_validate()
+
+ # Test the report in 2023.
+ options = self._generate_options(self.report, '2023-01-01', '2023-12-31')
+ options['unfold_all'] = True
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 4.0 CAD)', -200.0, -200.0, -50.0, 150.0),
+ ('211000 Account Payable', -200.0, -200.0, -50.0, 150.0),
+ ('BILL/2023/01/0002', -200.0, -200.0, -50.0, 150.0),
+ ('Total 211000 Account Payable', -200.0, -200.0, -50.0, 150.0),
+ ('Total CAD', -200.0, -200.0, -50.0, 150.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ def test_exclude_account_for_adjustment_entry(self):
+ """ In this test we will check if the exclude functionality of the report works as intended. We will do a bill
+ and an invoice. Then we will do a bank statement line to reconcile a part of the bill.
+ So the bill has a partial payment and should still be there in the report, and the invoice has no payment.
+ We then exclude the rest of the bill.
+ """
+
+ first_bill = self.create_move_one_line(
+ partner_id=self.partner_a.id,
+ move_type='in_invoice',
+ journal_id=self.company_data['default_journal_purchase'].id,
+ date='2023-01-01',
+ invoice_date='2023-01-01',
+ currency_id=self.other_currency.id,
+ account_id=self.company_data['default_account_expense'].id,
+ quantity=1,
+ price_unit=800.0
+ )
+
+ # Invoice
+ self.create_move_one_line(
+ partner_id=self.partner_a.id,
+ move_type='out_invoice',
+ journal_id=self.company_data['default_journal_sale'].id,
+ date='2023-01-01',
+ invoice_date='2023-01-01',
+ currency_id=self.other_currency.id,
+ account_id=self.copy_account(self.company_data['default_account_revenue']).id,
+ quantity=1,
+ price_unit=100.0
+ )
+
+ bank_statement = self.env['account.bank.statement.line'].create({
+ 'journal_id': self.company_data['default_journal_bank'].id,
+ 'payment_ref': 'payment_move_line',
+ 'partner_id': self.partner_a.id,
+ 'foreign_currency_id': self.other_currency.id,
+ 'amount': -300,
+ 'amount_currency': -600,
+ 'date': '2023-01-01',
+ })
+
+ wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=bank_statement.id).new({})
+ wizard._action_add_new_amls(first_bill.line_ids.filtered(lambda account: account.account_type == 'liability_payable'))
+ wizard._action_validate()
+
+ # Test the report in 2023.
+ options = self._generate_options(self.report, '2023-01-01', '2023-12-31')
+ options['unfold_all'] = True
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 4.0 CAD)', -100.0, -50.0, -25.0, 25.0),
+ ('121000 Account Receivable', 100.0, 50.0, 25.0, -25.0),
+ ('INV/2023/00001', 100.0, 50.0, 25.0, -25.0),
+ ('Total 121000 Account Receivable', 100.0, 50.0, 25.0, -25.0),
+ ('211000 Account Payable', -200.0, -100.0, -50.0, 50.0),
+ ('BILL/2023/01/0001', -200.0, -100.0, -50.0, 50.0),
+ ('Total 211000 Account Payable', -200.0, -100.0, -50.0, 50.0),
+ ('Total CAD', -100.0, -50.0, -25.0, 25.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ oldest_line_id = self.report._get_generic_line_id('account.report.line', self.env.ref('odex30_account_reports.multicurrency_revaluation_to_adjust').id)
+ old_line_id = self.report._get_generic_line_id('res.currency', self.other_currency.id, markup={'groupby': 'currency_id'}, parent_line_id=oldest_line_id)
+ line_id = self.report._get_generic_line_id('account.account', first_bill.line_ids.account_id.filtered(lambda account: account.account_type == 'liability_payable').id, markup={'groupby': 'account_id'}, parent_line_id=old_line_id)
+
+ self.env['account.multicurrency.revaluation.report.handler'].action_multi_currency_revaluation_toggle_provision(options, {'line_id': line_id})
+ options['unfold_all'] = True
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 4.0 CAD)', 100.0, 50.0, 25.0, -25.0),
+ ('121000 Account Receivable', 100.0, 50.0, 25.0, -25.0),
+ ('INV/2023/00001', 100.0, 50.0, 25.0, -25.0),
+ ('Total 121000 Account Receivable', 100.0, 50.0, 25.0, -25.0),
+ ('Total CAD', 100.0, 50.0, 25.0, -25.0),
+
+ ('Excluded Accounts', '', '', '', ''),
+ ('CAD (1 USD = 4.0 CAD)', -200.0, -100.0, -50.0, 50.0),
+ ('211000 Account Payable', -200.0, -100.0, -50.0, 50.0),
+ ('BILL/2023/01/0001', -200.0, -100.0, -50.0, 50.0),
+ ('Total 211000 Account Payable', -200.0, -100.0, -50.0, 50.0),
+ ('Total CAD', -200.0, -100.0, -50.0, 50.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ def test_same_rate(self):
+ """ Make sure no adjustment lines are generated if the rate is unchanged
+ (i.e. do not create 0 balance adjustment lines)
+ """
+ self.create_move_one_line(
+ partner_id=self.partner_a.id,
+ move_type='in_invoice',
+ journal_id=self.company_data['default_journal_purchase'].id,
+ date='2023-01-21',
+ invoice_date='2023-01-21',
+ currency_id=self.other_currency.id,
+ account_id=self.company_data['default_account_expense'].id,
+ quantity=1,
+ price_unit=1000.0,
+ )
+
+ options = self._generate_options(self.report, '2023-01-21', '2023-01-21')
+ options['unfold_all'] = True
+
+ # Check the gold currency.
+ self.assertLinesValues(
+ # pylint: disable = C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 1.0 CAD)', -1000.0, -1000.0, -1000.0, 0.0),
+ ('211000 Account Payable', -1000.0, -1000.0, -1000.0, 0.0),
+ ('BILL/2023/01/0001', -1000.0, -1000.0, -1000.0, 0.0),
+ ('Total 211000 Account Payable', -1000.0, -1000.0, -1000.0, 0.0),
+ ('Total CAD', -1000.0, -1000.0, -1000.0, 0.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ with self.assertRaises(UserError, msg="No adjustment should be needed"):
+ self.env.context = {**self.env.context, 'multicurrency_revaluation_report_options': {**options, 'unfold_all': False}}
+ self.env['account.multicurrency.revaluation.wizard'].create({
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'expense_provision_account_id': self.company_data['default_account_expense'].id,
+ 'income_provision_account_id': self.company_data['default_account_revenue'].id,
+ })
+
+ def test_changing_rate_between_move_and_payment(self):
+ """ In this test, we will do a use case where a move is created and before the payment is done, the rate of the
+ currency changes. We deal with the possibility to have multiple payment for a move with different dates and rates
+ """
+ bill = self.create_move_one_line(
+ partner_id=self.partner_a.id,
+ move_type='in_invoice',
+ journal_id=self.company_data['default_journal_purchase'].id,
+ date='2023-01-21',
+ invoice_date='2023-01-21',
+ currency_id=self.other_currency.id,
+ account_id=self.company_data['default_account_expense'].id,
+ quantity=1,
+ price_unit=1000.0,
+ )
+
+ options = self._generate_options(self.report, '2023-01-01', '2023-01-26')
+ options['unfold_all'] = True
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 2.0 CAD)', -1000.0, -1000.0, -500.0, 500.0),
+ ('211000 Account Payable', -1000.0, -1000.0, -500.0, 500.0),
+ ('BILL/2023/01/0001', -1000.0, -1000.0, -500.0, 500.0),
+ ('Total 211000 Account Payable', -1000.0, -1000.0, -500.0, 500.0),
+ ('Total CAD', -1000.0, -1000.0, -500.0, 500.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ # First payment for the bill at the given date to check if it appears in the report when changing the date_to
+ self.pay_move(
+ bill,
+ 500,
+ '2023-01-26',
+ account_type=self.company_data['default_account_payable'].account_type,
+ currency=self.other_currency
+ )
+
+ # Second payment at a later date to fully paid the bill
+ self.pay_move(
+ bill,
+ 500,
+ '2023-02-01',
+ account_type=self.company_data['default_account_payable'].account_type,
+ currency=self.other_currency
+ )
+
+ options = self._generate_options(self.report, '2023-01-01', '2023-01-31')
+ options['unfold_all'] = True
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 4.0 CAD)', -500.0, -500.0, -125.0, 375.0),
+ ('211000 Account Payable', -500.0, -500.0, -125.0, 375.0),
+ ('BILL/2023/01/0001', -500.0, -500.0, -125.0, 375.0),
+ ('Total 211000 Account Payable', -500.0, -500.0, -125.0, 375.0),
+ ('Total CAD', -500.0, -500.0, -125.0, 375.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ @freeze_time('2023-01-26')
+ def test_payment_in_company_currency_invoice_in_foreign_currency_fully_reconcile(self):
+ """ In this test, we will create a move with a foreign currency and do a payment in the company currency,
+ but thanks to the changing of rates, the move is fully reconcile
+ """
+ bill = self.create_move_one_line(
+ partner_id=self.partner_a.id,
+ move_type='in_invoice',
+ journal_id=self.company_data['default_journal_purchase'].id,
+ date='2023-01-21',
+ invoice_date='2023-01-21',
+ currency_id=self.other_currency.id,
+ account_id=self.company_data['default_account_expense'].id,
+ quantity=1,
+ price_unit=1000.0,
+ )
+
+ # We pay the bill for 500 but thanks to the changing of the rate (1 --> 2), 500 become 1000 and the move is
+ # fully reconciled, so we don't need to display anything on the report
+ self.pay_move(
+ bill,
+ 500,
+ '2023-01-26',
+ account_type=self.company_data['default_account_payable'].account_type,
+ currency=self.company_data['currency'],
+ )
+
+ options = self._generate_options(self.report, '2023-01-01', '2023-02-20')
+ self.assertEqual(len(self.report._get_lines(options)), 0)
+
+ @freeze_time('2023-01-26')
+ def test_payment_in_company_currency_invoice_in_foreign_currency_not_fully_reconcile(self):
+ """ In this test, we will create a move with a foreign currency and do a payment in the company currency """
+ bill = self.create_move_one_line(
+ partner_id=self.partner_a.id,
+ move_type='in_invoice',
+ journal_id=self.company_data['default_journal_purchase'].id,
+ date='2023-01-21',
+ invoice_date='2023-01-21',
+ currency_id=self.other_currency.id,
+ account_id=self.company_data['default_account_expense'].id,
+ quantity=1,
+ price_unit=1000.0,
+ )
+
+ # We pay the first part of the bill, thanks to the changing of rates we have paid 600
+ self.pay_move(
+ bill,
+ 300,
+ '2023-01-26',
+ account_type=self.company_data['default_account_payable'].account_type,
+ currency=self.company_data['currency'],
+ )
+
+ options = self._generate_options(self.report, '2023-01-01', '2023-01-26')
+ options['unfold_all'] = True
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 2.0 CAD)', -400.0, -400.0, -200.0, 200.0),
+ ('211000 Account Payable', -400.0, -400.0, -200.0, 200.0),
+ ('BILL/2023/01/0001', -400.0, -400.0, -200.0, 200.0),
+ ('Total 211000 Account Payable', -400.0, -400.0, -200.0, 200.0),
+ ('Total CAD', -400.0, -400.0, -200.0, 200.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ # We check the report again with other date to witness the new changing of rates
+ options = self._generate_options(self.report, '2023-01-01', '2023-02-01')
+ options['unfold_all'] = True
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 4.0 CAD)', -400.0, -400.0, -100.0, 300.0),
+ ('211000 Account Payable', -400.0, -400.0, -100.0, 300.0),
+ ('BILL/2023/01/0001', -400.0, -400.0, -100.0, 300.0),
+ ('Total 211000 Account Payable', -400.0, -400.0, -100.0, 300.0),
+ ('Total CAD', -400.0, -400.0, -100.0, 300.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ @freeze_time('2023-01-28')
+ def test_pay_all_move_check_before_full_payment(self):
+ """ In this test we pay all the move, and then we check when coming back before the payment if the report display
+ the lines.
+ """
+ bill = self.create_move_one_line(
+ partner_id=self.partner_a.id,
+ move_type='in_invoice',
+ journal_id=self.company_data['default_journal_purchase'].id,
+ date='2023-01-21',
+ invoice_date='2023-01-21',
+ currency_id=self.other_currency.id,
+ account_id=self.company_data['default_account_expense'].id,
+ quantity=1,
+ price_unit=1000.0,
+ )
+
+ # We pay the first part of the bill, thanks to the changing of rates we have paid 600
+ self.pay_move(
+ bill,
+ 1000,
+ '2023-01-28',
+ account_type=self.company_data['default_account_payable'].account_type,
+ currency=self.company_data['currency'],
+ )
+
+ # The report shouldn't display anything after the full payment.
+ options = self._generate_options(self.report, '2023-01-01', '2023-01-29')
+ options['unfold_all'] = True
+ self.assertEqual(len(self.report._get_lines(options)), 0)
+
+ # The report should display the bill before the full payment.
+ options = self._generate_options(self.report, '2023-01-01', '2023-01-26')
+ options['unfold_all'] = True
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 2.0 CAD)', -1000.0, -1000.0, -500.0, 500.0),
+ ('211000 Account Payable', -1000.0, -1000.0, -500.0, 500.0),
+ ('BILL/2023/01/0001', -1000.0, -1000.0, -500.0, 500.0),
+ ('Total 211000 Account Payable', -1000.0, -1000.0, -500.0, 500.0),
+ ('Total CAD', -1000.0, -1000.0, -500.0, 500.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ @freeze_time('2023-01-26')
+ def test_move_credit_note(self):
+ """ Create a credit note, change the currency rate and then the payment. Check if the report gives the correct
+ values before and after the payment
+ """
+ bill = self.create_move_one_line(
+ partner_id=self.partner_a.id,
+ move_type='in_invoice',
+ journal_id=self.company_data['default_journal_purchase'].id,
+ date='2023-01-21',
+ invoice_date='2023-01-21',
+ currency_id=self.other_currency.id,
+ account_id=self.company_data['default_account_expense'].id,
+ quantity=1,
+ price_unit=1000.0,
+ )
+
+ options = self._generate_options(self.report, '2023-01-01', '2023-01-30')
+ options['unfold_all'] = True
+ self.assertEqual(len(self.report._get_lines(options)), 6)
+
+ self.pay_move(
+ bill,
+ 1000,
+ '2023-01-26',
+ account_type=self.company_data['default_account_payable'].account_type,
+ currency=self.company_data['currency'],
+ )
+
+ options = self._generate_options(self.report, '2023-01-01', '2023-01-30')
+ options['unfold_all'] = True
+
+ self.assertEqual(len(self.report._get_lines(options)), 0)
+
+ move_reversal = self.env['account.move.reversal'].with_context(active_model='account.move', active_ids=bill.ids).create({
+ 'journal_id': bill.journal_id.id,
+ 'date': '2023-01-26'
+ })
+ reversal = move_reversal.reverse_moves()
+ self.env['account.move'].browse(reversal['res_id']).action_post()
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 4.0 CAD)', 1000.0, 500.0, 250.0, -250.0),
+ ('211000 Account Payable', 1000.0, 500.0, 250.0, -250.0),
+ ('RBILL/2023/01/0001 (Reversal of: BILL/2023/01/0001)', 1000.0, 500.0, 250.0, -250.0),
+ ('Total 211000 Account Payable', 1000.0, 500.0, 250.0, -250.0),
+ ('Total CAD', 1000.0, 500.0, 250.0, -250.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ @freeze_time('2023-01-26')
+ def test_with_payment_term(self):
+ """ In this test, we will create a new payment term where you need to pay 30% of the amount directly, and then
+ you have 60 days for the rest. We will check the report before and after the payment to make sure it's working
+ correctly.
+ """
+ account_payment_term_advance_60days = self.env['account.payment.term'].create({
+ 'name': "account_payment_term_advance_60days",
+ 'company_id': self.company_data['company'].id,
+ 'line_ids': [
+ Command.create({
+ 'value_amount': 30,
+ 'value': 'percent',
+ 'nb_days': 0,
+ }),
+ Command.create({
+ 'value_amount': 70,
+ 'value': 'percent',
+ 'nb_days': 60,
+ }),
+ ]
+ })
+
+ bill = self.create_move_one_line(
+ partner_id=self.partner_a.id,
+ move_type='in_invoice',
+ journal_id=self.company_data['default_journal_purchase'].id,
+ date='2023-01-21',
+ invoice_date='2023-01-21',
+ currency_id=self.other_currency.id,
+ account_id=self.company_data['default_account_expense'].id,
+ quantity=1,
+ price_unit=1000.0,
+ payment_term_id=account_payment_term_advance_60days.id,
+ )
+
+ options = self._generate_options(self.report, '2023-01-01', '2023-01-30')
+ options['unfold_all'] = True
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 4.0 CAD)', -1000.0, -1000.0, -250.0, 750.0),
+ ('211000 Account Payable', -1000.0, -1000.0, -250.0, 750.0),
+ ('BILL/2023/01/0001 installment #1', -300.0, -300.0, -75.0, 225.0),
+ ('BILL/2023/01/0001 installment #2', -700.0, -700.0, -175.0, 525.0),
+ ('Total 211000 Account Payable', -1000.0, -1000.0, -250.0, 750.0),
+ ('Total CAD', -1000.0, -1000.0, -250.0, 750.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ # The price is double since the rate is x2 So the amount of the payment is 300
+ self.pay_move(
+ bill,
+ 150,
+ '2023-01-26',
+ account_type=self.company_data['default_account_payable'].account_type,
+ currency=self.company_data['currency'],
+ )
+
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 4.0 CAD)', -700.0, -700.0, -175.0, 525.0),
+ ('211000 Account Payable', -700.0, -700.0, -175.0, 525.0),
+ ('BILL/2023/01/0001 installment #2', -700.0, -700.0, -175.0, 525.0),
+ ('Total 211000 Account Payable', -700.0, -700.0, -175.0, 525.0),
+ ('Total CAD', -700.0, -700.0, -175.0, 525.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ # We check when coming back before the payment the lines are ok
+ options = self._generate_options(self.report, '2023-01-01', '2023-01-25')
+ options['unfold_all'] = True
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 2.0 CAD)', -1000.0, -1000.0, -500.0, 500.0),
+ ('211000 Account Payable', -1000.0, -1000.0, -500.0, 500.0),
+ ('BILL/2023/01/0001 installment #1', -300.0, -300.0, -150.0, 150.0),
+ ('BILL/2023/01/0001 installment #2', -700.0, -700.0, -350.0, 350.0),
+ ('Total 211000 Account Payable', -1000.0, -1000.0, -500.0, 500.0),
+ ('Total CAD', -1000.0, -1000.0, -500.0, 500.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ def test_transfer_invoice_to_another_partner(self):
+ """ This test verifies that we still find the bill amount in the report when payable is move
+ to another partner.
+ """
+ bill = self.create_move_one_line(
+ partner_id=self.partner_a.id,
+ move_type='in_invoice',
+ journal_id=self.company_data['default_journal_purchase'].id,
+ date='2023-01-21',
+ invoice_date='2023-01-21',
+ currency_id=self.other_currency.id,
+ account_id=self.company_data['default_account_expense'].id,
+ quantity=1,
+ price_unit=1000.0
+ )
+
+ entry = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'date': '2023-01-22',
+ 'line_ids': [
+ Command.create({
+ 'partner_id': self.partner_a.id,
+ 'currency_id': self.other_currency.id,
+ 'amount_currency': 1000.0,
+ 'account_id': self.company_data['default_account_payable'].id,
+ }),
+ Command.create({
+ 'partner_id': self.partner_b.id,
+ 'currency_id': self.other_currency.id,
+ 'amount_currency': -1000.0,
+ 'account_id': self.company_data['default_account_payable'].id,
+ }),
+ ]
+ })
+ entry.action_post()
+
+ lines_to_reconcile = entry.line_ids.filtered(lambda line: line.partner_id == self.partner_a and line.account_type == self.company_data['default_account_payable'].account_type)
+ lines_to_reconcile += bill.line_ids.filtered(lambda line: line.account_type == self.company_data['default_account_payable'].account_type)
+ lines_to_reconcile.reconcile()
+
+ options = self._generate_options(self.report, '2023-01-01', '2023-01-31')
+ options['unfold_all'] = True
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 4.0 CAD)', -1000.0, -1000.0, -250.0, 750.0),
+ ('211000 Account Payable', -1000.0, -1000.0, -250.0, 750.0),
+ ('MISC/2023/01/0001', -1000.0, -1000.0, -250.0, 750.0),
+ ('Total 211000 Account Payable', -1000.0, -1000.0, -250.0, 750.0),
+ ('Total CAD', -1000.0, -1000.0, -250.0, 750.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ @freeze_time('2023-01-26')
+ def test_refund_invoice_keep_exchange_diff_line(self):
+ """ Create an invoice, cancel it with a credit note.
+ Check the report, unreconcile the credit note and
+ check the report again.
+ """
+ # Create a customer invoice with a rate of 1 USD = 1 CAD
+ invoice = self.create_move_one_line(
+ partner_id=self.partner_a.id,
+ move_type='out_invoice',
+ journal_id=self.company_data['default_journal_sale'].id,
+ date='2023-01-21',
+ invoice_date='2023-01-21',
+ currency_id=self.other_currency.id,
+ account_id=self.company_data['default_account_revenue'].id,
+ quantity=1,
+ price_unit=1000.0,
+ )
+
+ # Reverse the customer invoice with a rate of 1 USD = 2 CAD to create a partial credit note
+ move_reversal = self.env['account.move.reversal'].with_context(active_model='account.move', active_ids=invoice.ids).create({
+ 'journal_id': invoice.journal_id.id,
+ 'date': '2023-01-26',
+ })
+ reversal = move_reversal.reverse_moves()
+ credit_note = self.env['account.move'].browse(reversal['res_id'])
+ credit_note.invoice_line_ids[0].price_unit = 300 # Only reverse for 300
+ credit_note.action_post()
+ line_to_reconciles = (invoice + credit_note).line_ids.filtered(lambda l: l.account_type == self.company_data['default_account_receivable'].account_type)
+
+ # Checking the report after reconciliation between the invoice and the credit note (Rate 1 USD = 4 CAD)
+ options = self._generate_options(self.report, '2023-01-01', '2023-01-30')
+ options['unfold_all'] = True
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 4.0 CAD)', 700.0, 700.0, 175.0, -525.0),
+ ('121000 Account Receivable', 700.0, 700.0, 175.0, -525.0),
+ ('INV/2023/00001', 700.0, 700.0, 175.0, -525.0),
+ ('Total 121000 Account Receivable', 700.0, 700.0, 175.0, -525.0),
+ ('Total CAD', 700.0, 700.0, 175.0, -525.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ # Delete the reconciliation
+ partial = self.env['account.partial.reconcile'].search([
+ ('debit_move_id', '=', line_to_reconciles[0].id),
+ ('credit_move_id', '=', line_to_reconciles[1].id),
+ ])
+ partial.unlink()
+
+ # Check the report in february, the exchange diff should disappear as it was computed in january (Rate 1 USD = 4 CAD)
+ options = self._generate_options(self.report, '2023-01-01', '2023-02-15')
+ options['unfold_all'] = True
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 4.0 CAD)', 700.0, 850.0, 175.0, -675.0),
+ ('121000 Account Receivable', 700.0, 850.0, 175.0, -675.0),
+ ('RINV/2023/00001 (Reversal of: INV/2023/00001)', -300.0, -150.0, -75.0, 75.0),
+ ('INV/2023/00001', 1000.0, 1000.0, 250.0, -750.0),
+ ('Total 121000 Account Receivable', 700.0, 850.0, 175.0, -675.0),
+ ('Total CAD', 700.0, 850.0, 175.0, -675.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ def test_invoice_with_different_rate_than_the_existing_one(self):
+ """ This test has for purpose to check that the customized rate on an entry
+ is well-kept. If a user creates an entry in multi currency and creates a
+ rate for this entry specifically (by changing the debit/credit and amount_currency).
+ The report should use this rate for the balance in foreign currency and the balance
+ at operation rate.
+ """
+ # Special rate of 3
+ entry = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2023-01-21',
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ Command.create({
+ 'name': 'expense line',
+ 'debit': 300.0,
+ 'credit': 0.0,
+ 'currency_id': self.other_currency.id,
+ 'amount_currency': 900,
+ 'account_id': self.company_data['default_account_expense'].id,
+ }),
+ Command.create({
+ 'name': 'payable line',
+ 'partner_id': self.partner_a.id,
+ 'currency_id': self.other_currency.id,
+ 'debit': 0.0,
+ 'credit': 300.0,
+ 'amount_currency': -900.0,
+ 'account_id': self.company_data['default_account_payable'].id,
+ }),
+ ],
+ })
+ entry.action_post()
+
+ # Opening the report for a rate at 4 instead of 3
+ options = self._generate_options(self.report, '2023-01-01', '2023-01-31')
+ options['unfold_all'] = True
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 4.0 CAD)', -900.0, -300.0, -225.0, 75.0),
+ ('211000 Account Payable', -900.0, -300.0, -225.0, 75.0),
+ ('MISC/2023/01/0001 payable line', -900.0, -300.0, -225.0, 75.0),
+ ('Total 211000 Account Payable', -900.0, -300.0, -225.0, 75.0),
+ ('Total CAD', -900.0, -300.0, -225.0, 75.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ def test_current_liability_reco_bank_journal_aml(self):
+ """ This test creates a reconcilable current liability account with a foreign currency,
+ creates an entry with this account and reconciles it with a bank journal aml.
+ Before reconciliation, the bank journal aml shouldn't impact the report
+ because the amount is already realized.
+ Once this aml is reconciled with the current liability aml, the report should be impacted.
+ """
+ special_liability_current_account = self.env['account.account'].create({
+ 'name': '201 GOL',
+ 'code': '201',
+ 'account_type': 'liability_current',
+ 'reconcile': True,
+ 'currency_id': self.other_currency.id
+ })
+ self.company_data['default_journal_bank'].currency_id = self.other_currency.id
+
+ entry = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2023-01-21',
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ Command.create({
+ 'name': 'liability line',
+ 'debit': 50.0,
+ 'credit': 0.0,
+ 'currency_id': self.other_currency.id,
+ 'amount_currency': 100.0,
+ 'account_id': special_liability_current_account.id,
+ }),
+ Command.create({
+ 'name': 'revenue line',
+ 'currency_id': self.other_currency.id,
+ 'debit': 0.0,
+ 'credit': 50.0,
+ 'amount_currency': -100.0,
+ 'account_id': self.company_data['default_account_revenue'].id,
+ }),
+ ],
+ })
+ entry.action_post()
+
+ self.env['account.bank.statement.line'].create({
+ 'journal_id': self.company_data['default_journal_bank'].id,
+ 'payment_ref': 'payment_move_line',
+ 'foreign_currency_id': self.other_currency.id,
+ 'amount': -10.0,
+ 'amount_currency': -30.0,
+ 'date': '2023-01-23',
+ })
+
+ bank_entry = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2023-01-23',
+ 'journal_id': self.company_data['default_journal_bank'].id,
+ 'line_ids': [
+ Command.create({
+ 'name': 'liability line',
+ 'debit': 0.0,
+ 'credit': 10.0,
+ 'currency_id': self.other_currency.id,
+ 'amount_currency': -30.0,
+ 'account_id': special_liability_current_account.id,
+ }),
+ Command.create({
+ 'name': 'revenue line',
+ 'currency_id': self.other_currency.id,
+ 'debit': 10.0,
+ 'credit': 0.0,
+ 'amount_currency': 30.0,
+ 'account_id': self.company_data['default_journal_bank'].default_account_id.id,
+ }),
+ ],
+ })
+ bank_entry.action_post()
+
+ # Checking the report before reconciliation
+ options = self._generate_options(self.report, '2023-01-01', '2023-01-31')
+ options['unfold_all'] = True
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 4.0 CAD)', 90.0, 40.0, 22.5, -17.5),
+ ('101401 Bank', 20.0, 0.0, 5.0, 5.0),
+ ('BNK1/2023/00002 revenue line', 30.0, 10.0, 7.5, -2.5),
+ ('BNK1/2023/00001 payment_move_line', -10.0, -10.0, -2.5, 7.5),
+ ('Total 101401 Bank', 20.0, 0.0, 5.0, 5.0),
+ ('201 201 GOL', 70.0, 40.0, 17.5, -22.5),
+ ('BNK1/2023/00002 liability line', -30.0, -10.0, -7.5, 2.5),
+ ('MISC/2023/01/0001 liability line', 100.0, 50.0, 25.0, -25.0),
+ ('Total 201 201 GOL', 70.0, 40.0, 17.5, -22.5),
+ ('Total CAD', 90.0, 40.0, 22.5, -17.5),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ line_to_reconciles = (entry + bank_entry).line_ids.filtered(lambda l: l.account_type == special_liability_current_account.account_type)
+ line_to_reconciles.reconcile()
+
+ # After reconciliation, the bank journal aml should impact the report
+ options = self._generate_options(self.report, '2023-01-01', '2023-01-31')
+ options['unfold_all'] = True
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 4.0 CAD)', 90.0, 35.0, 22.5, -12.5),
+ ('101401 Bank', 20.0, 0.0, 5.0, 5.0),
+ ('BNK1/2023/00002 revenue line', 30.0, 10.0, 7.5, -2.5),
+ ('BNK1/2023/00001 payment_move_line', -10.0, -10.0, -2.5, 7.5),
+ ('Total 101401 Bank', 20.0, 0.0, 5.0, 5.0),
+ ('201 201 GOL', 70.0, 35.0, 17.5, -17.5),
+ ('MISC/2023/01/0001 liability line', 70.0, 35.0, 17.5, -17.5),
+ ('Total 201 201 GOL', 70.0, 35.0, 17.5, -17.5),
+ ('Total CAD', 90.0, 35.0, 22.5, -12.5),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ def test_no_pl_account_present(self):
+ """
+ When putting a currency on a p&l account, the account should NOT be present in the report.
+ This test will check that the exclusion of the account_type present in a p&l are not displayed.
+ """
+
+ self.company_data['default_account_expense'].currency_id = self.other_currency.id
+ self.create_move_one_line(
+ partner_id=self.partner_a.id,
+ move_type='in_invoice',
+ journal_id=self.company_data['default_journal_purchase'].id,
+ date='2023-01-21',
+ invoice_date='2023-01-21',
+ currency_id=self.other_currency.id,
+ account_id=self.company_data['default_account_expense'].id,
+ quantity=1,
+ price_unit=1000.0
+ )
+
+ options = self._generate_options(self.report, '2023-01-01', '2023-01-26')
+ options['unfold_all'] = True
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 2.0 CAD)', -1000.0, -1000.0, -500.0, 500.0),
+ ('211000 Account Payable', -1000.0, -1000.0, -500.0, 500.0),
+ ('BILL/2023/01/0001', -1000.0, -1000.0, -500.0, 500.0),
+ ('Total 211000 Account Payable', -1000.0, -1000.0, -500.0, 500.0),
+ ('Total CAD', -1000.0, -1000.0, -500.0, 500.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+
+ def test_adjustment_entry_with_tax_on_expense_account(self):
+ """ Make sure the adjustment entry is correctly generated even when
+ the expense account has default taxes.
+ """
+ self.create_move_one_line(
+ partner_id=self.partner_a.id,
+ move_type='out_invoice',
+ journal_id=self.company_data['default_journal_sale'].id,
+ date='2023-01-21',
+ invoice_date='2023-01-21',
+ currency_id=self.other_currency.id,
+ account_id=self.company_data['default_account_revenue'].id,
+ quantity=1,
+ price_unit=1000.0,
+ )
+
+ options = self._generate_options(self.report, '2023-01-01', '2023-01-26')
+ options['unfold_all'] = True
+ self.assertLinesValues(
+ # pylint: disable=C0326
+ self.report._get_lines(options),
+ # Name Balance in foreign currency Balance at op. rate Balance at curr rate Adjustment
+ [ 0, 1, 2, 3, 4],
+ [
+ ('Accounts To Adjust', '', '', '', ''),
+ ('CAD (1 USD = 2.0 CAD)', 1000.0, 1000.0, 500.0, -500.0),
+ ('121000 Account Receivable', 1000.0, 1000.0, 500.0, -500.0),
+ ('INV/2023/00001', 1000.0, 1000.0, 500.0, -500.0),
+ ('Total 121000 Account Receivable', 1000.0, 1000.0, 500.0, -500.0),
+ ('Total CAD', 1000.0, 1000.0, 500.0, -500.0),
+ ],
+ options,
+ currency_map={
+ 1: {'currency': self.other_currency},
+ },
+ )
+ expense_account = self.company_data['default_account_expense']
+ expense_account.tax_ids = [self.company_data['default_tax_purchase'].id]
+ self.env.context = {**self.env.context, 'multicurrency_revaluation_report_options': {**options, 'unfold_all': False}}
+ wizard = self.env['account.multicurrency.revaluation.wizard'].create({
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'expense_provision_account_id': expense_account.id,
+ 'income_provision_account_id': self.company_data['default_account_revenue'].id,
+ })
+ entry_data = wizard.create_entries()
+ entry = self.env['account.move'].browse(entry_data['res_id'])
+
+ self.assertRecordValues(entry.invoice_line_ids, [{
+ 'name': 'Provision for CAD (1 USD = 2.0 CAD)',
+ 'debit': 0.00,
+ 'credit': 500.0,
+ }, {
+ 'name': 'Expense Provision for CAD',
+ 'debit': 500.00,
+ 'credit': 0.0,
+ }])
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_partner_ledger_report.py b/dev_odex30_accounting/odex30_account_reports/tests/test_partner_ledger_report.py
new file mode 100644
index 0000000..4e3556c
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_partner_ledger_report.py
@@ -0,0 +1,714 @@
+# pylint: disable=C0326
+from .common import TestAccountReportsCommon
+
+from odoo import Command, fields
+from odoo.tests import tagged
+
+import json
+
+
+@tagged('post_install', '-at_install')
+class TestPartnerLedgerReport(TestAccountReportsCommon):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.partner_category_a = cls.env['res.partner.category'].create({'name': 'partner_categ_a'})
+ cls.partner_category_b = cls.env['res.partner.category'].create({'name': 'partner_categ_b'})
+
+ cls.partner_a.write({'category_id': [Command.set([cls.partner_category_a.id, cls.partner_category_b.id])]})
+ cls.partner_b.write({'category_id': [Command.set([cls.partner_category_a.id])]})
+ cls.partner_c = cls._create_partner(name='partner_c', category_id=[Command.set([cls.partner_category_b.id])])
+
+ # Entries in 2016 for company_1 to test the initial balance.
+ cls.move_2016_1 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2016-01-01'),
+ 'journal_id': cls.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'name': '2016_1_1', 'account_id': cls.company_data['default_account_payable'].id, 'partner_id': cls.partner_a.id}),
+ (0, 0, {'debit': 200.0, 'credit': 0.0, 'name': '2016_1_1', 'account_id': cls.company_data['default_account_payable'].id, 'partner_id': cls.partner_b.id}),
+ (0, 0, {'debit': 0.0, 'credit': 300.0, 'name': '2016_1_2', 'account_id': cls.company_data['default_account_receivable'].id, 'partner_id': cls.partner_c.id}),
+ ],
+ })
+ cls.move_2016_1.action_post()
+
+ # Entries in 2016 for company_2 to test the initial balance in multi-companies/multi-currencies.
+ cls.move_2016_2 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2016-06-01'),
+ 'journal_id': cls.company_data_2['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'name': '2016_2_1', 'account_id': cls.company_data_2['default_account_payable'].id, 'partner_id': cls.partner_a.id}),
+ (0, 0, {'debit': 0.0, 'credit': 100.0, 'name': '2016_2_2', 'account_id': cls.company_data_2['default_account_receivable'].id, 'partner_id': cls.partner_c.id}),
+ ],
+ })
+ cls.move_2016_2.action_post()
+
+ # Entry in 2017 for company_1 to test the report at current date.
+ cls.move_2017_1 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2017-01-01'),
+ 'journal_id': cls.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'name': '2017_1_1', 'account_id': cls.company_data['default_account_payable'].id, 'partner_id': cls.partner_b.id}),
+ (0, 0, {'debit': 2000.0, 'credit': 0.0, 'name': '2017_1_2', 'account_id': cls.company_data['default_account_payable'].id, 'partner_id': cls.partner_a.id}),
+ (0, 0, {'debit': 3000.0, 'credit': 0.0, 'name': '2017_1_3', 'account_id': cls.company_data['default_account_payable'].id, 'partner_id': cls.partner_a.id}),
+ (0, 0, {'debit': 4000.0, 'credit': 0.0, 'name': '2017_1_4', 'account_id': cls.company_data['default_account_receivable'].id, 'partner_id': cls.partner_a.id}),
+ (0, 0, {'debit': 5000.0, 'credit': 0.0, 'name': '2017_1_5', 'account_id': cls.company_data['default_account_receivable'].id, 'partner_id': cls.partner_a.id}),
+ (0, 0, {'debit': 6000.0, 'credit': 0.0, 'name': '2017_1_6', 'account_id': cls.company_data['default_account_receivable'].id, 'partner_id': cls.partner_a.id}),
+ (0, 0, {'debit': 0.0, 'credit': 6000.0, 'name': '2017_1_7', 'account_id': cls.company_data['default_account_receivable'].id, 'partner_id': cls.partner_c.id}),
+ (0, 0, {'debit': 0.0, 'credit': 7000.0, 'name': '2017_1_8', 'account_id': cls.company_data['default_account_receivable'].id, 'partner_id': cls.partner_c.id}),
+ (0, 0, {'debit': 0.0, 'credit': 8000.0, 'name': '2017_1_9', 'account_id': cls.company_data['default_account_receivable'].id, 'partner_id': cls.partner_c.id}),
+ ],
+ })
+ cls.move_2017_1.action_post()
+
+ # Entry in 2017 for company_2 to test the current period in multi-companies/multi-currencies.
+ cls.move_2017_2 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2017-06-01'),
+ 'journal_id': cls.company_data_2['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 400.0, 'credit': 0.0, 'name': '2017_2_1', 'account_id': cls.company_data_2['default_account_receivable'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 400.0, 'name': '2017_2_2', 'account_id': cls.company_data_2['default_account_receivable'].id}),
+ ],
+ })
+ cls.move_2017_2.action_post()
+
+ cls.report = cls.env.ref('odex30_account_reports.partner_ledger_report')
+
+ def test_partner_ledger_unfold(self):
+ ''' Test unfolding a line when rendering the whole report. '''
+ options = self._generate_options(self.report, fields.Date.from_string('2017-01-01'), fields.Date.from_string('2017-12-31'))
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('partner_a', 20150.0, 0.0, 20150.0),
+ ('partner_b', 1200.0, 0.0, 1200.0),
+ ('partner_c', 0.0, 21350.0, -21350.0),
+ ('Unknown Partner', 200.0, 200.0, 0.0),
+ ('Total', 21550.0, 21550.0, 0.0),
+ ],
+ options,
+ )
+
+ options['unfolded_lines'] = [self.report._get_generic_line_id('res.partner', self.partner_a.id)]
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('partner_a', 20150.0, 0.0, 20150.0),
+ ('Initial Balance', 150.0, 0.0, 150.0),
+ ('MISC/2017/01/0001 2017_1_2', 2000.0, 0.0, 2150.0),
+ ('MISC/2017/01/0001 2017_1_3', 3000.0, 0.0, 5150.0),
+ ('MISC/2017/01/0001 2017_1_4', 4000.0, 0.0, 9150.0),
+ ('MISC/2017/01/0001 2017_1_5', 5000.0, 0.0, 14150.0),
+ ('MISC/2017/01/0001 2017_1_6', 6000.0, 0.0, 20150.0),
+ ('Total partner_a', 20150.0, 0.0, 20150.0),
+ ('partner_b', 1200.0, 0.0, 1200.0),
+ ('partner_c', 0.0, 21350.0, -21350.0),
+ ('Unknown Partner', 200.0, 200.0, 0.0),
+ ('Total', 21550.0, 21550.0, 0.0),
+ ],
+ options,
+ )
+
+ def test_partner_ledger_load_more(self):
+ ''' Test unfolding a line to use the load more. '''
+ self.report.load_more_limit = 2
+
+ options = self._generate_options(self.report, fields.Date.from_string('2017-01-01'), fields.Date.from_string('2017-12-31'))
+ options['unfolded_lines'] = [self.report._get_generic_line_id('res.partner', self.partner_a.id)]
+
+ report_lines = self.report._get_lines(options)
+
+ self.assertLinesValues(
+ report_lines,
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('partner_a', 20150.0, 0.0, 20150.0),
+ ('Initial Balance', 150.0, 0.0, 150.0),
+ ('MISC/2017/01/0001 2017_1_2', 2000.0, 0.0, 2150.0),
+ ('MISC/2017/01/0001 2017_1_3', 3000.0, 0.0, 5150.0),
+ ('Load more...', '', '', ''),
+ ('Total partner_a', 20150.0, 0.0, 20150.0),
+ ('partner_b', 1200.0, 0.0, 1200.0),
+ ('partner_c', 0.0, 21350.0, -21350.0),
+ ('Unknown Partner', 200.0, 200.0, 0.0),
+ ('Total', 21550.0, 21550.0, 0.0),
+ ],
+ options,
+ )
+
+ load_more_1 = self.report.get_expanded_lines(
+ options,
+ report_lines[0]['id'],
+ report_lines[4]['groupby'],
+ '_report_expand_unfoldable_line_partner_ledger',
+ report_lines[4]['progress'],
+ report_lines[4]['offset'],
+ None,
+ )
+
+ self.assertLinesValues(
+ load_more_1,
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('MISC/2017/01/0001 2017_1_4', 4000.0, 0.0, 9150.0),
+ ('MISC/2017/01/0001 2017_1_5', 5000.0, 0.0, 14150.0),
+ ('Load more...', '', '', ''),
+ ],
+ options,
+ )
+
+ load_more_2 = self.report.get_expanded_lines(
+ options,
+ report_lines[0]['id'],
+ load_more_1[2]['groupby'],
+ '_report_expand_unfoldable_line_partner_ledger',
+ load_more_1[2]['progress'],
+ load_more_1[2]['offset'],
+ None,
+ )
+
+ self.assertLinesValues(
+ load_more_2,
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('MISC/2017/01/0001 2017_1_6', 6000.0, 0.0, 20150.0),
+ ],
+ options,
+ )
+
+ def test_partner_ledger_filter_account_types(self):
+ ''' Test building the report with a filter on account types.
+ When filtering on receivable accounts (i.e. trade_receivable and/or non_trade_receivable), partner_b should disappear from the report.
+ '''
+ options = self._generate_options(self.report, fields.Date.from_string('2017-01-01'), fields.Date.from_string('2017-12-31'))
+ options['unfolded_lines'] = [self.report._get_generic_line_id('res.partner', self.partner_a.id)]
+ options = self._update_multi_selector_filter(options, 'account_type', ['non_trade_receivable', 'trade_receivable'])
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('partner_a', 15000.0, 0.0, 15000.0),
+ ('MISC/2017/01/0001 2017_1_4', 4000.0, 0.0, 4000.0),
+ ('MISC/2017/01/0001 2017_1_5', 5000.0, 0.0, 9000.0),
+ ('MISC/2017/01/0001 2017_1_6', 6000.0, 0.0, 15000.0),
+ ('Total partner_a', 15000.0, 0.0, 15000.0),
+ ('partner_c', 0.0, 21350.0, -21350.0),
+ ('Unknown Partner', 200.0, 200.0, 0.0),
+ ('Total', 15200.0, 21550.0, -6350.0),
+ ],
+ options,
+ )
+
+ def test_partner_ledger_filter_partners(self):
+ ''' Test the filter on top allowing to filter on res.partner.'''
+ options = self._generate_options(self.report, fields.Date.from_string('2017-01-01'), fields.Date.from_string('2017-12-31'))
+ options['partner_ids'] = (self.partner_a + self.partner_c).ids
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('partner_a', 20150.0, 0.0, 20150.0),
+ ('partner_c', 0.0, 21350.0, -21350.0),
+ ('Total', 20150.0, 21350.0, -1200.0),
+ ],
+ options,
+ )
+
+ def test_partner_ledger_filter_partner_categories(self):
+ ''' Test the filter on top allowing to filter on res.partner.category.'''
+ options = self._generate_options(self.report, fields.Date.from_string('2017-01-01'), fields.Date.from_string('2017-12-31'))
+ options['partner_categories'] = self.partner_category_a.ids
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('partner_a', 20150.0, 0.0, 20150.0),
+ ('partner_b', 1200.0, 0.0, 1200.0),
+ ('Total', 21350.0, 0.0, 21350.0),
+ ],
+ options,
+ )
+
+ def test_partner_ledger_unknown_partner(self):
+ ''' Test the partner ledger for whenever a line appearing in it has no partner assigned.
+ Check that reconciling this line with an invoice/bill of a partner does affect his balance.
+ '''
+ options = self._generate_options(self.report, fields.Date.from_string('2017-01-01'), fields.Date.from_string('2017-12-31'))
+
+ misc_move = self.env['account.move'].create({
+ 'date': '2017-03-31',
+ 'line_ids': [
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'account_id': self.company_data['default_account_revenue'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 1000.0, 'account_id': self.company_data['default_account_receivable'].id}),
+ ],
+ })
+ misc_move.action_post()
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('partner_a', 20150.0, 0.0, 20150.0),
+ ('partner_b', 1200.0, 0.0, 1200.0),
+ ('partner_c', 0.0, 21350.0, -21350.0),
+ ('Unknown Partner', 200.0, 1200.0, -1000.0),
+ ('Total', 21550.0, 22550.0, -1000.0),
+ ],
+ options,
+ )
+
+ debit_line = self.move_2017_1.line_ids.filtered(lambda line: line.debit == 4000.0)
+ credit_line = misc_move.line_ids.filtered(lambda line: line.credit == 1000.0)
+ (debit_line + credit_line).reconcile()
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('partner_a', 20150.0, 1000.0, 19150.0),
+ ('partner_b', 1200.0, 0.0, 1200.0),
+ ('partner_c', 0.0, 21350.0, -21350.0),
+ ('Unknown Partner', 1200.0, 1200.0, 0.0),
+ ('Total', 22550.0, 23550.0, -1000.0),
+ ],
+ options,
+ )
+
+ # Unfold 'partner_a'
+ options['unfolded_lines'] = [self.report._get_generic_line_id('res.partner', self.partner_a.id)]
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('partner_a', 20150.0, 1000.0, 19150.0),
+ ('Initial Balance', 150.0, 0.0, 150.0),
+ ('MISC/2017/01/0001 2017_1_2', 2000.0, 0.0, 2150.0),
+ ('MISC/2017/01/0001 2017_1_3', 3000.0, 0.0, 5150.0),
+ ('MISC/2017/01/0001 2017_1_4', 4000.0, 0.0, 9150.0),
+ ('MISC/2017/01/0001 2017_1_5', 5000.0, 0.0, 14150.0),
+ ('MISC/2017/01/0001 2017_1_6', 6000.0, 0.0, 20150.0),
+ ('MISC/2017/03/0001', 0.0, 1000.0, 19150.0),
+ ('Total partner_a', 20150.0, 1000.0, 19150.0),
+ ('partner_b', 1200.0, 0.0, 1200.0),
+ ('partner_c', 0.0, 21350.0, -21350.0),
+ ('Unknown Partner', 1200.0, 1200.0, 0.0),
+ ('Total', 22550.0, 23550.0, -1000.0),
+ ],
+ options,
+ )
+
+ # Unfold 'Unknown Partner'
+ options['unfolded_lines'] = [self.report._get_generic_line_id('res.partner', None, markup='no_partner')]
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('partner_a', 20150.0, 1000.0, 19150.0),
+ ('partner_b', 1200.0, 0.0, 1200.0),
+ ('partner_c', 0.0, 21350.0, -21350.0),
+ ('Unknown Partner', 1200.0, 1200.0, 0.0),
+ ('MISC/2017/03/0001', 0.0, 1000.0, -1000.0),
+ ('MISC/2017/06/0001 2017_2_1', 200.0, 0.0, -800.0),
+ ('MISC/2017/06/0001 2017_2_2', 0.0, 200.0, -1000.0),
+ ('MISC/2017/03/0001', 1000.0, 0.0, 0.0),
+ ('Total Unknown Partner', 1200.0, 1200.0, 0.0),
+ ('Total', 22550.0, 23550.0, -1000.0),
+ ],
+ options,
+ )
+
+ # Change the dates to exclude the reconciliation max date: situation is back to the beginning
+ options = self._generate_options(self.report, fields.Date.from_string('2017-01-01'), fields.Date.from_string('2017-03-30'))
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('partner_a', 20150.0, 0.0, 20150.0),
+ ('partner_b', 1200.0, 0.0, 1200.0),
+ ('partner_c', 0.0, 21350.0, -21350.0),
+ ('Total', 21350.0, 21350.0, 0.0),
+ ],
+ options,
+ )
+
+ # Change the dates to have a date_from > to the reconciliation max date and check the initial balances are correct
+ options = self._generate_options(self.report, fields.Date.from_string('2017-04-01'), fields.Date.from_string('2017-04-01'))
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('partner_a', 20150.0, 1000.0, 19150.0),
+ ('partner_b', 1200.0, 0.0, 1200.0),
+ ('partner_c', 0.0, 21350.0, -21350.0),
+ ('Unknown Partner', 1000.0, 1000.0, 0.0),
+ ('Total', 22350.0, 23350.0, -1000.0),
+ ],
+ options,
+ )
+
+ # Unfold 'partner_a' to check the initial balance line is correct
+ options['unfolded_lines'] = [self.report._get_generic_line_id('res.partner', self.partner_a.id)]
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('partner_a', 20150.00, 1000.0, 19150.00),
+ ('Initial Balance', 20150.00, 1000.0, 19150.00),
+ ('Total partner_a', 20150.00, 1000.0, 19150.00),
+ ('partner_b', 1200.0, 0.0, 1200.0),
+ ('partner_c', 0.0, 21350.00, -21350.00),
+ ('Unknown Partner', 1000.0, 1000.0, 0.0),
+ ('Total', 22350.00, 23350.00, -1000.0),
+ ],
+ options,
+ )
+
+ def test_partner_ledger_prefix_groups(self):
+ partner_names = [
+ 'A',
+ 'A partner',
+ 'A nice partner',
+ 'A new partner',
+ 'An original partner',
+ 'Another partner',
+ 'Anonymous partner',
+ 'Annoyed partner',
+ 'Brave partner',
+ ]
+
+ test_date = '2010-12-13'
+ test_partners = self.env['res.partner']
+ for name in partner_names:
+ partner = self.env['res.partner'].create({'name': name})
+ test_partners += partner
+ invoice = self.init_invoice('out_invoice', partner=partner, invoice_date=test_date, amounts=[42.0], taxes=[], post=True)
+
+ # Without prefix groups
+ options = self._generate_options(self.report, test_date, test_date)
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Journal Debit Credit Balance
+ [ 0, 1, 6, 7, 9],
+ [
+ ('A', '', 42.0, 0.0, 42.0),
+ ('A new partner', '', 42.0, 0.0, 42.0),
+ ('A nice partner', '', 42.0, 0.0, 42.0),
+ ('A partner', '', 42.0, 0.0, 42.0),
+ ('An original partner', '', 42.0, 0.0, 42.0),
+ ('Annoyed partner', '', 42.0, 0.0, 42.0),
+ ('Anonymous partner', '', 42.0, 0.0, 42.0),
+ ('Another partner', '', 42.0, 0.0, 42.0),
+ ('Brave partner', '', 42.0, 0.0, 42.0),
+ ('Total', '', 378.0, 0.0, 378.0),
+ ],
+ options,
+ )
+
+ # With prefix groups
+ self.report.prefix_groups_threshold = 3
+ options = self._generate_options(self.report, test_date, test_date, default_options={'unfold_all': True})
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Journal Debit Credit Balance
+ [ 0, 1, 6, 7, 9],
+ [
+ ('A (8 lines)', '', 336.0, 0.0, 336.0),
+ ('A', '', 42.0, 0.0, 42.0),
+ ('INV/2010/00001', 'INV', 42.0, 0.0, 42.0),
+ ('Total A', '', 42.0, 0.0, 42.0),
+ ('A[ ] (3 lines)', '', 126.0, 0.0, 126.0),
+ ('A N (2 lines)', '', 84.0, 0.0, 84.0),
+ ('A new partner', '', 42.0, 0.0, 42.0),
+ ('INV/2010/00004', 'INV', 42.0, 0.0, 42.0),
+ ('Total A new partner', '', 42.0, 0.0, 42.0),
+ ('A nice partner', '', 42.0, 0.0, 42.0),
+ ('INV/2010/00003', 'INV', 42.0, 0.0, 42.0),
+ ('Total A nice partner', '', 42.0, 0.0, 42.0),
+ ('Total A N (2 lines)', '', 84.0, 0.0, 84.0),
+ ('A P (1 line)', '', 42.0, 0.0, 42.0),
+ ('A partner', '', 42.0, 0.0, 42.0),
+ ('INV/2010/00002', 'INV', 42.0, 0.0, 42.0),
+ ('Total A partner', '', 42.0, 0.0, 42.0),
+ ('Total A P (1 line)', '', 42.0, 0.0, 42.0),
+ ('Total A[ ] (3 lines)', '', 126.0, 0.0, 126.0),
+ ('AN (4 lines)', '', 168.0, 0.0, 168.0),
+ ('AN[ ] (1 line)', '', 42.0, 0.0, 42.0),
+ ('An original partner', '', 42.0, 0.0, 42.0),
+ ('INV/2010/00005', 'INV', 42.0, 0.0, 42.0),
+ ('Total An original partner', '', 42.0, 0.0, 42.0),
+ ('Total AN[ ] (1 line)', '', 42.0, 0.0, 42.0),
+ ('ANN (1 line)', '', 42.0, 0.0, 42.0),
+ ('Annoyed partner', '', 42.0, 0.0, 42.0),
+ ('INV/2010/00008', 'INV', 42.0, 0.0, 42.0),
+ ('Total Annoyed partner', '', 42.0, 0.0, 42.0),
+ ('Total ANN (1 line)', '', 42.0, 0.0, 42.0),
+ ('ANO (2 lines)', '', 84.0, 0.0, 84.0),
+ ('Anonymous partner', '', 42.0, 0.0, 42.0),
+ ('INV/2010/00007', 'INV', 42.0, 0.0, 42.0),
+ ('Total Anonymous partner', '', 42.0, 0.0, 42.0),
+ ('Another partner', '', 42.0, 0.0, 42.0),
+ ('INV/2010/00006', 'INV', 42.0, 0.0, 42.0),
+ ('Total Another partner', '', 42.0, 0.0, 42.0),
+ ('Total ANO (2 lines)', '', 84.0, 0.0, 84.0),
+ ('Total AN (4 lines)', '', 168.0, 0.0, 168.0),
+ ('Total A (8 lines)', '', 336.0, 0.0, 336.0),
+ ('B (1 line)', '', 42.0, 0.0, 42.0),
+ ('Brave partner', '', 42.0, 0.0, 42.0),
+ ('INV/2010/00009', 'INV', 42.0, 0.0, 42.0),
+ ('Total Brave partner', '', 42.0, 0.0, 42.0),
+ ('Total B (1 line)', '', 42.0, 0.0, 42.0),
+ ('Total', '', 378.0, 0.0, 378.0),
+ ],
+ options,
+ )
+
+ def test_filter_unreconciled_entries_only(self):
+ new_partner = self.env['res.partner'].create({'name': 'Obiwan Kenobi'})
+ move_1 = self.init_invoice('out_invoice', partner=new_partner, invoice_date='2019-01-01', amounts=[1000.0], taxes=[], post=True)
+ move_2 = self.init_invoice('out_invoice', partner=new_partner, invoice_date='2019-01-01', amounts=[5000.0], taxes=[], post=True)
+
+ self.env['account.payment.register'].create({
+ 'payment_date': '2019-01-01',
+ 'line_ids': move_1.line_ids.filtered(lambda l: l.display_type == 'payment_term'),
+ 'amount': 700.0,
+ })._create_payments()
+ self.env['account.payment.register'].create({
+ 'payment_date': '2019-01-01',
+ 'line_ids': move_2.line_ids.filtered(lambda l: l.display_type == 'payment_term'),
+ })._create_payments()
+
+ self.assertEqual(move_1.payment_state, 'partial')
+ self.assertEqual(move_2.payment_state, 'in_payment')
+
+ options = self._generate_options(self.report, '2019-01-01', '2019-12-31', default_options={'partner_ids': new_partner.ids})
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('Obiwan Kenobi', 6000.0, 5700.0, 300.0),
+ ('Total', 6000.0, 5700.0, 300.0),
+ ],
+ options
+ )
+
+ options['unreconciled'] = True
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('Obiwan Kenobi', 1000.0, 700.0, 300.0),
+ ('Total', 1000.0, 700.0, 300.0),
+ ],
+ options
+ )
+
+ def test_print_pdf_exclude_partner_with_name_similar_to_another_partner_email(self):
+ """
+ This test verifies that when printing a PDF report, the report accurately reflects the data displayed on the view
+ by excluding partners whose email addresses are similar to other partners' names if the search bar is used to filter out partners.
+ """
+ partner = self.env['res.partner'].create({'name': 'Great Customer', 'email': 'partner_a@test.com'})
+ self.init_invoice('out_invoice', partner=partner, invoice_date='2019-02-14', amounts=[1000.0], taxes=[], post=True)
+ options = self._generate_options(self.report, '2019-02-01', '2019-02-28', default_options={
+ 'filter_search_bar': 'partner_a',
+ 'export_mode': 'print',
+ })
+ lines = self.report._get_lines(options)
+ self.assertFalse(partner.name in [line['name'] for line in lines])
+
+ def test_partner_ledger_search_with_unknown_partner(self):
+ ''' Test that the lines with no partners are included in the report when searching for "Unknown Partner" '''
+ options = self._generate_options(self.report, '2019-02-01', '2019-02-28', default_options={
+ 'filter_search_bar': 'un',
+ 'export_mode': 'print',
+ })
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('Unknown Partner', 200.0, 200.0, 0.0),
+ ('Total', 200.0, 200.0, 0.0),
+ ],
+ options,
+ )
+
+ partner = self.env['res.partner'].create({'name': 'Unexpected Customer'})
+ self.init_invoice('out_invoice', partner=partner, invoice_date='2019-02-14', amounts=[1000.0], taxes=[], post=True)
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('Unexpected Customer', 1000.0, 0.0, 1000.0),
+ ('Unknown Partner', 200.0, 200.0, 0.0),
+ ('Total', 1200.0, 200.0, 1000.0),
+ ],
+ options,
+ )
+
+ def test_no_amount_currency_col_in_single_currency(self):
+ # In multi-currency, we get the amount_currency column
+ self.assertGreater(len(self.env['res.currency'].search([])), 1)
+ options = self._generate_options(self.report, fields.Date.from_string('2023-01-01'), fields.Date.from_string('2023-12-31'))
+ self.assertTrue(any(col['expression_label'] == 'amount_currency' for col in options['columns']))
+
+ # Not in single-currency
+ self.env['res.currency'].search([('name', '!=', 'USD')]).with_context(force_deactivate=True).active = False
+ self.assertEqual(len(self.env['res.currency'].search([])), 1)
+ options = self._generate_options(self.report, fields.Date.from_string('2023-01-01'), fields.Date.from_string('2023-12-31'))
+ self.assertFalse(any(col['expression_label'] == 'amount_currency' for col in options['columns']))
+
+ def test_partner_ledger_fully_reconcile_previous_year(self):
+ """
+ Verify the report's behavior when a partner is fully reconciled and has a zero balance.
+ If the partner's balance is zero:
+ If the partner has any unreconciled items, display the partner's information in the report.
+ If the partner has no unreconciled items but has an initial balance > 0, show the partner.
+ If the partner has no unreconciled items and has an initial balance = 0, hide the partner.
+ """
+ new_partner = self.env['res.partner'].create({'name': 'Anakin Skywalker'})
+ move = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2019-01-01',
+ 'partner_id': new_partner.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 500.0,
+ 'tax_ids': [],
+ })]
+ })
+ move.action_post()
+
+ payment_1 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2019-01-01',
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'partner_id': new_partner.id,
+ 'line_ids': [
+ Command.create({'debit': 0.0, 'credit': 500.0, 'account_id': self.company_data['default_account_receivable'].id}),
+ Command.create({'debit': 500.0, 'credit': 0.0, 'account_id': self.company_data['default_journal_bank'].default_account_id.id}),
+ ],
+ })
+ payment_1.action_post()
+
+ options = self._generate_options(self.report, '2020-01-01', '2020-12-31', default_options={'partner_ids': new_partner.ids})
+ # Balance is 0 but there are unreconciled entries, so we show the line
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('Anakin Skywalker', 500.0, 500.0, 0.0),
+ ('Total', 500.0, 500.0, 0.0),
+ ],
+ options,
+ )
+
+ (payment_1 + move).line_ids.filtered(
+ lambda line: line.account_id == self.company_data['default_account_receivable']
+ ).reconcile()
+
+ # Balance is still 0 and there is no more unreconciled entries, the partner is hide from the report
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('Total', 0.0, 0.0, 0.0),
+ ],
+ options,
+ )
+
+ def test_partner_ledger_fully_reconcile_current_year(self):
+ new_partner = self.env['res.partner'].create({'name': 'Darth Vader'})
+ move = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'invoice_date': '2019-01-01',
+ 'partner_id': new_partner.id,
+ 'invoice_line_ids': [Command.create({
+ 'quantity': 1,
+ 'price_unit': 500.0,
+ 'tax_ids': [],
+ })]
+ })
+ move.action_post()
+
+ payment_1 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2019-01-01',
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'partner_id': new_partner.id,
+ 'line_ids': [
+ Command.create({'debit': 0.0, 'credit': 500.0, 'account_id': self.company_data['default_account_receivable'].id}),
+ Command.create({'debit': 500.0, 'credit': 0.0, 'account_id': self.company_data['default_journal_bank'].default_account_id.id}),
+ ],
+ })
+ payment_1.action_post()
+
+ options = self._generate_options(self.report, '2019-01-01', '2019-12-31', default_options={'partner_ids': new_partner.ids})
+ # Balance is 0 but there are unreconciled entries, so we show the line
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('Darth Vader', 500.0, 500.0, 0.0),
+ ('Total', 500.0, 500.0, 0.0),
+ ],
+ options,
+ )
+
+ (payment_1 + move).line_ids.filtered(
+ lambda line: line.account_id == self.company_data['default_account_receivable']
+ ).reconcile()
+
+ # Balance is still 0 and there is no more unreconciled entries, but the moves is in the current report period,
+ # so we still show the partner in the report
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # Name Debit Credit Balance
+ [ 0, 6, 7, 9],
+ [
+ ('Darth Vader', 500.0, 500.0, 0.0),
+ ('Total', 500.0, 500.0, 0.0),
+ ],
+ options,
+ )
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_reconciliation_report.py b/dev_odex30_accounting/odex30_account_reports/tests/test_reconciliation_report.py
new file mode 100644
index 0000000..b1a1f0e
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_reconciliation_report.py
@@ -0,0 +1,859 @@
+# pylint: disable=C0326
+from .common import TestAccountReportsCommon
+from odoo import fields, Command
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestReconciliationReport(TestAccountReportsCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.other_currency = cls.setup_other_currency('EUR')
+ cls.other_currency_2 = cls.setup_other_currency('GBP', rates=[('2016-01-01', 10.0), ('2017-01-01', 20.0)])
+
+ def test_reconciliation_report_single_currency(self):
+ """
+ Tests the impact of positive/negative payments/statements on the reconciliation report in a single-currency
+ environment.
+ """
+ bank_journal = self.env['account.journal'].create({
+ 'name': 'Bank',
+ 'code': 'BNKKK',
+ 'type': 'bank',
+ 'company_id': self.company_data['company'].id,
+ })
+
+ # ==== Statements ====
+
+ self.env['account.bank.statement'].create({
+ 'name': 'statement_1',
+ 'date': '2014-12-31',
+ 'balance_start': 0.0,
+ 'balance_end_real': 100.0,
+ 'line_ids': [
+ Command.create({'payment_ref': 'line_1', 'amount': 600.0, 'date': '2014-12-31', 'journal_id': bank_journal.id}),
+ Command.create({'payment_ref': 'line_2', 'amount': -500.0, 'date': '2014-12-31', 'journal_id': bank_journal.id}),
+ ],
+ })
+
+ statement_2 = self.env['account.bank.statement'].create({
+ 'name': 'statement_2',
+ 'date': '2015-01-05',
+ 'balance_start': 200.0, # create an unexplained difference of 100.0.
+ 'balance_end_real': - 200.0,
+ 'journal_id': bank_journal.id,
+ 'line_ids': [
+ Command.create({'payment_ref': 'line_1', 'amount': 100.0, 'date': '2015-01-01', 'partner_id': self.partner_a.id, 'journal_id': bank_journal.id}),
+ Command.create({'payment_ref': 'line_2', 'amount': 200.0, 'date': '2015-01-02', 'journal_id': bank_journal.id}),
+ Command.create({'payment_ref': 'line_3', 'amount': -300.0, 'date': '2015-01-03', 'partner_id': self.partner_a.id, 'journal_id': bank_journal.id}),
+ Command.create({'payment_ref': 'line_4', 'amount': -400.0, 'date': '2015-01-04', 'journal_id': bank_journal.id}),
+ ],
+ })
+
+ # ==== Payments ====
+ self.inbound_payment_method_line.journal_id = bank_journal.id
+ self.outbound_payment_method_line.journal_id = bank_journal.id
+ payment_1 = self.env['account.payment'].create({
+ 'amount': 150.0,
+ 'payment_type': 'inbound',
+ 'partner_type': 'customer',
+ 'date': '2015-01-01',
+ 'journal_id': bank_journal.id,
+ 'partner_id': self.partner_a.id,
+ 'payment_method_line_id': self.inbound_payment_method_line.id,
+ })
+
+ payment_2 = self.env['account.payment'].create({
+ 'amount': 250.0,
+ 'payment_type': 'outbound',
+ 'partner_type': 'supplier',
+ 'date': '2015-01-02',
+ 'journal_id': bank_journal.id,
+ 'partner_id': self.partner_a.id,
+ 'payment_method_line_id': self.outbound_payment_method_line.id,
+ })
+
+ payment_3 = self.env['account.payment'].create({
+ 'amount': 350.0,
+ 'payment_type': 'outbound',
+ 'partner_type': 'customer',
+ 'date': '2015-01-03',
+ 'journal_id': bank_journal.id,
+ 'partner_id': self.partner_a.id,
+ 'payment_method_line_id': self.inbound_payment_method_line.id,
+ })
+
+ payment_4 = self.env['account.payment'].create({
+ 'amount': 450.0,
+ 'payment_type': 'inbound',
+ 'partner_type': 'supplier',
+ 'date': '2015-01-04',
+ 'journal_id': bank_journal.id,
+ 'partner_id': self.partner_a.id,
+ 'payment_method_line_id': self.outbound_payment_method_line.id,
+ })
+
+ (payment_1 + payment_2 + payment_3 + payment_4).action_post()
+
+ # ==== Reconciliation ====
+
+ st_line = statement_2.line_ids.filtered(lambda line: line.payment_ref == 'line_1')
+ payment_line = payment_1.move_id.line_ids.filtered(lambda line: line.account_id == payment_1.payment_method_line_id.payment_account_id)
+ wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({})
+ wizard._action_add_new_amls(payment_line, allow_partial=False)
+ wizard._action_validate()
+
+ st_line = statement_2.line_ids.filtered(lambda line: line.payment_ref == 'line_3')
+ payment_line = payment_2.move_id.line_ids.filtered(lambda line: line.account_id == payment_2.payment_method_line_id.payment_account_id)
+ wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({})
+ wizard._action_add_new_amls(payment_line, allow_partial=False)
+ wizard._action_validate()
+
+ # ==== Report ====
+
+ report = self.env.ref('odex30_account_reports.bank_reconciliation_report').with_context(
+ active_id=bank_journal.id,
+ active_model=bank_journal._name
+ )
+
+ options = self._generate_options(report, '2016-01-02', '2016-01-02')
+ options['unfold_all'] = True
+ lines = report._get_lines(options)
+
+ self.assertLinesValues(
+ lines,
+ # Name Date Amount
+ [0, 1, 3],
+ [
+ ('Balance of \'101405 Bank\'', '', -200.0),
+ ('Last statement balance', '', -200.0),
+ ('Including Unreconciled Receipts', '', 200.0),
+ ('BNKKK/2015/00002', '01/02/2015', 200.0),
+ ('Including Unreconciled Payments', '', -400.0),
+ ('BNKKK/2015/00004', '01/04/2015', -400.0),
+ ('Transactions without statement', '', 0.0),
+ ('Including Unreconciled Receipts', '', 0.0),
+ ('Including Unreconciled Payments', '', 0.0),
+ ('Misc. operations', '', 0.0),
+ ('Outstanding Receipts/Payments', '', 100.0),
+ ('(+) Outstanding Receipts', '', 450.0),
+ ('PBNKKK/2015/00004', '01/04/2015', 450.0),
+ ('(-) Outstanding Payments', '', -350.0),
+ ('PBNKKK/2015/00003', '01/03/2015', -350.0),
+ ],
+ options,
+ currency_map={3: {'currency': bank_journal.currency_id}},
+ ignore_folded=False,
+ )
+
+ def test_reconciliation_report_multi_currencies(self):
+ """ Tests the management of multi-currencies in the reconciliation report. """
+ self.env.user.groups_id |= self.env.ref('base.group_multi_currency')
+ self.env.user.groups_id |= self.env.ref('base.group_no_one')
+
+ company_currency = self.company_data['currency'] # USD
+ journal_currency = self.other_currency # EUR
+ choco_currency = self.other_currency_2 # GBP
+
+ # ==== Journal with a foreign currency ====
+
+ bank_journal = self.env['account.journal'].create({
+ 'name': 'Bank',
+ 'code': 'BNKKK',
+ 'type': 'bank',
+ 'company_id': self.company_data['company'].id,
+ 'currency_id': journal_currency.id
+ })
+
+ # ==== Statement ====
+
+ bank_statement = self.env['account.bank.statement'].create({
+ 'name': 'statement',
+ 'line_ids': [
+
+ # Transaction in the company currency.
+ (0, 0, {
+ 'payment_ref': 'line_1',
+ 'date': '2016-01-01',
+ 'amount': 100.0,
+ 'journal_id': bank_journal.id,
+ 'amount_currency': 50.01,
+ 'foreign_currency_id': company_currency.id,
+ }),
+
+ # Transaction in a third currency.
+ (0, 0, {
+ 'payment_ref': 'line_3',
+ 'date': '2016-01-01',
+ 'amount': 100.0,
+ 'journal_id': bank_journal.id,
+ 'amount_currency': 999.99,
+ 'foreign_currency_id': choco_currency.id,
+ }),
+
+ ],
+ })
+
+ # Partially reconcile the suspense amount associated with each bank statement line
+ suspense_account = bank_journal.suspense_account_id
+ other_account = bank_journal.company_id.default_cash_difference_income_account_id
+
+ # the first is in company currency
+ bank_move_1 = bank_statement.line_ids[0].move_id
+ bank_move_1_suspense_line = bank_move_1.line_ids.filtered(lambda l: l.account_id == suspense_account)
+ bank_move_1.button_draft()
+ bank_move_1.write({'line_ids': [
+ Command.create({'account_id': other_account.id, 'credit': 10.00}),
+ Command.update(bank_move_1_suspense_line.id, {'credit': 40.01}),
+ ]})
+ bank_move_1.action_post()
+
+ # the second is in neither company nor journal currency
+ bank_move_2 = bank_statement.line_ids[1].move_id
+ bank_move_2_suspense_line = bank_move_2.line_ids.filtered(lambda l: l.account_id == suspense_account)
+ bank_move_2.button_draft()
+ bank_move_2.write({'line_ids': [
+ Command.create({
+ 'account_id': other_account.id,
+ 'currency_id': bank_move_2_suspense_line.currency_id.id,
+ 'credit': 3.33,
+ 'amount_currency': -99.99
+ }),
+ Command.update(bank_move_2_suspense_line.id, {
+ 'credit': 30.0,
+ 'amount_currency': -900.0
+ }),
+ ]})
+ bank_move_2.action_post()
+
+ # ==== Payments ====
+ self.inbound_payment_method_line.journal_id = bank_journal.id
+ # Payment in the company's currency.
+ payment_1 = self.env['account.payment'].create({
+ 'amount': 1000.0,
+ 'payment_type': 'inbound',
+ 'partner_type': 'customer',
+ 'date': '2016-01-01',
+ 'journal_id': bank_journal.id,
+ 'partner_id': self.partner_a.id,
+ 'currency_id': company_currency.id,
+ 'payment_method_line_id': self.inbound_payment_method_line.id,
+ })
+
+ # Payment in the same foreign currency as the journal.
+ payment_2 = self.env['account.payment'].create({
+ 'amount': 2000.0,
+ 'payment_type': 'inbound',
+ 'partner_type': 'customer',
+ 'date': '2016-01-01',
+ 'journal_id': bank_journal.id,
+ 'partner_id': self.partner_a.id,
+ 'currency_id': journal_currency.id,
+ 'payment_method_line_id': self.inbound_payment_method_line.id,
+ })
+
+ # Payment in a third foreign currency.
+ payment_3 = self.env['account.payment'].create({
+ 'amount': 3000.0,
+ 'payment_type': 'inbound',
+ 'partner_type': 'customer',
+ 'date': '2016-01-01',
+ 'journal_id': bank_journal.id,
+ 'partner_id': self.partner_a.id,
+ 'currency_id': choco_currency.id,
+ 'payment_method_line_id': self.inbound_payment_method_line.id,
+ })
+ (payment_1 + payment_2 + payment_3).action_post()
+
+ # ==== Misc Entry ====
+
+ move = self.env['account.move'].create({
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'date': '2016-01-01',
+ 'line_ids': [
+ Command.create({
+ 'name': 'Line1',
+ 'debit': 100,
+ 'credit': 0,
+ 'amount_currency': 200,
+ 'account_id': bank_journal.default_account_id.id,
+ 'currency_id': journal_currency.id
+ }),
+ Command.create({
+ 'name': 'Line2',
+ 'debit': 0,
+ 'credit': 100,
+ 'account_id': other_account.id,
+ }),
+ ]
+ })
+ move.action_post()
+
+ # ==== Report ====
+
+ report = self.env.ref('odex30_account_reports.bank_reconciliation_report').with_context(
+ active_id=bank_journal.id,
+ active_model=bank_journal._name
+ )
+
+ with self.debug_mode():
+ options = self._generate_options(report, '2016-01-02', '2016-01-02')
+ options['unfold_all'] = True
+ lines = report._get_lines(options)
+
+ self.assertLinesValues(
+ lines,
+ # Name Date Am. Cur. Cur. Amount
+ [0, 1, 3, 4, 5],
+ [
+ ('Balance of \'101405 Bank\'', '', '', '', 400.0),
+ ('Last statement balance', '', '', '', 200.0),
+ ('Including Unreconciled Receipts', '', '', '', 170.005),
+ ('BNKKK/2016/00002', '01/01/2016', 900.00, choco_currency.name, 90.001),
+ ('BNKKK/2016/00001', '01/01/2016', 40.01, company_currency.name, 80.004),
+ ('Including Unreconciled Payments', '', '', '', 0.0),
+ ('Transactions without statement', '', '', '', 0.0),
+ ('Including Unreconciled Receipts', '', '', '', 0.0),
+ ('Including Unreconciled Payments', '', '', '', 0.0),
+ ('Misc. operations', '', '', '', 200.0),
+ ('Outstanding Receipts/Payments', '', '', '', 5900.0),
+ ('(+) Outstanding Receipts', '', '', '', 5900.0),
+ ('PBNKKK/2016/00003', '01/01/2016', 3000.0, choco_currency.name, 900.0),
+ ('PBNKKK/2016/00002', '01/01/2016', '', '', 2000.0),
+ ('PBNKKK/2016/00001', '01/01/2016', 1000.0, company_currency.name, 3000.0),
+ ('(-) Outstanding Payments', '', '', '', 0.0),
+ ],
+ options,
+ currency_map={
+ 3: {'currency_code_index': 4},
+ 5: {'currency': journal_currency},
+ },
+ ignore_folded=False,
+ )
+
+ def test_reconciliation_change_date(self):
+ """
+ Tests the impact of positive/negative payments/statements on the reconciliation report in a single-currency
+ environment.
+ """
+ bank_journal = self.company_data['default_journal_bank']
+
+ statement = self.env['account.bank.statement'].create({
+ 'name': 'statement_1',
+ 'date': '2019-01-10',
+ 'balance_start': 0.0,
+ 'balance_end_real': 130.0,
+ 'line_ids': [
+ (0, 0, {'payment_ref': 'line_1', 'amount': 10.0, 'date': '2019-01-01', 'journal_id': bank_journal.id}),
+ (0, 0, {'payment_ref': 'line_2', 'amount': 20.0, 'date': '2019-01-02', 'journal_id': bank_journal.id}),
+ (0, 0, {'payment_ref': 'line_3', 'amount': 30.0, 'date': '2019-01-03', 'journal_id': bank_journal.id}),
+ (0, 0, {'payment_ref': 'line_4', 'amount': -40.0, 'date': '2019-01-04', 'journal_id': bank_journal.id}),
+ (0, 0, {'payment_ref': 'line_5', 'amount': 50.0, 'date': '2019-01-05', 'journal_id': bank_journal.id}),
+ (0, 0, {'payment_ref': 'line_6', 'amount': 60.0, 'date': '2019-01-06', 'journal_id': bank_journal.id}),
+ ],
+ })
+
+ # This will allow to test if the balance of the bank account is changed
+ statement['balance_end_real'] = 140.0
+
+ payment = self.env['account.payment'].create({
+ 'amount': 1000.0,
+ 'payment_type': 'inbound',
+ 'partner_type': 'customer',
+ 'date': '2019-01-03',
+ 'journal_id': bank_journal.id,
+ 'partner_id': self.partner_a.id,
+ })
+ payment.action_post()
+
+ report = self.env.ref('odex30_account_reports.bank_reconciliation_report').with_context(
+ active_id=bank_journal.id,
+ active_model='account.journal'
+ )
+
+ options = self._generate_options(report, fields.Date.from_string('2019-01-01'), fields.Date.from_string('2019-01-01'))
+ options['all_entries'] = True
+ options['unfold_all'] = True
+
+ # The last statement is taken into account cause it has a line corresponding to the date of the report.
+ lines = report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Date Amount
+ [0, 1, 3],
+ [
+ ('Balance of \'101401 Bank\'', '', 140.0),
+ ('Last statement balance', '', 140.0),
+ ('Including Unreconciled Receipts', '', 10.0),
+ ('BNK1/2019/00001', '01/01/2019', 10.0),
+ ('Including Unreconciled Payments', '', 0.0),
+ ('Transactions without statement', '', 0.0),
+ ('Including Unreconciled Receipts', '', 0.0),
+ ('Including Unreconciled Payments', '', 0.0),
+ ('Misc. operations', '', 0.0),
+ ('Outstanding Receipts/Payments', '', 0.0),
+ ('(+) Outstanding Receipts', '', 0.0),
+ ('(-) Outstanding Payments', '', 0.0),
+ ],
+ options,
+ currency_map={3: {'currency': bank_journal.currency_id}},
+ ignore_folded=False,
+ )
+
+ options = self._generate_options(report, fields.Date.from_string('2019-01-01'), fields.Date.from_string('2019-01-04'))
+ options['all_entries'] = True
+ options['unfold_all'] = True
+ lines = report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Date Amount
+ [0, 1, 3],
+ [
+ ('Balance of \'101401 Bank\'', '', 140.0),
+ ('Last statement balance', '', 140.0),
+ ('Including Unreconciled Receipts', '', 60.0),
+ ('BNK1/2019/00003', '01/03/2019', 30.0),
+ ('BNK1/2019/00002', '01/02/2019', 20.0),
+ ('BNK1/2019/00001', '01/01/2019', 10.0),
+ ('Including Unreconciled Payments', '', -40.0),
+ ('BNK1/2019/00004', '01/04/2019', -40.0),
+ ('Transactions without statement', '', 0.0),
+ ('Including Unreconciled Receipts', '', 0.0),
+ ('Including Unreconciled Payments', '', 0.0),
+ ('Misc. operations', '', 0.0),
+ ('Outstanding Receipts/Payments', '', 1000.0),
+ ('(+) Outstanding Receipts', '', 1000.0),
+ ('PBNK1/2019/00001', '01/03/2019', 1000.0),
+ ('(-) Outstanding Payments', '', 0.0),
+ ],
+ options,
+ currency_map={3: {'currency': bank_journal.currency_id}},
+ ignore_folded=False,
+ )
+
+ def test_reconciliation_report_non_statement_payment(self):
+ """
+ Test that moves not linked to a bank statement/payment but linked for example to expenses are all showing in the
+ report
+ """
+ bank_journal = self.env['account.journal'].create({
+ 'name': 'Bank',
+ 'code': 'BNKKK',
+ 'type': 'bank',
+ 'company_id': self.company_data['company'].id,
+ })
+ bank_journal.inbound_payment_method_line_ids.payment_account_id = self.inbound_payment_method_line.payment_account_id
+
+ # ==== Misc ====
+ self.env['account.move'].create({
+ 'journal_id': bank_journal.id,
+ 'date': '2014-12-31',
+ 'line_ids': [
+ (0, 0, {
+ 'name': 'Source',
+ 'debit': 800,
+ 'credit': 0,
+ 'account_id': self.company_data['default_account_expense'].id,
+ }),
+ (0, 0, {
+ 'name': 'Destination',
+ 'debit': 0,
+ 'credit': 800,
+ 'account_id': self.inbound_payment_method_line.payment_account_id.id,
+ }),
+ ]
+ }).action_post()
+
+ self.env['account.move'].create({
+ 'journal_id': bank_journal.id,
+ 'date': '2015-12-31',
+ 'line_ids': [
+ (0, 0, {
+ 'name': 'Source',
+ 'debit': 500,
+ 'credit': 0,
+ 'account_id': self.company_data['default_account_expense'].id,
+ }),
+ (0, 0, {
+ 'name': 'Destination',
+ 'debit': 0,
+ 'credit': 500,
+ 'account_id': self.inbound_payment_method_line.payment_account_id.id,
+ }),
+ ]
+ }).action_post()
+
+ # ==== Report ====
+
+ report = self.env.ref('odex30_account_reports.bank_reconciliation_report').with_context(
+ active_id=bank_journal.id,
+ active_model='account.journal'
+ )
+
+ options = self._generate_options(report, '2016-01-02', '2016-01-02')
+ options['unfold_all'] = True
+ lines = report._get_lines(options)
+
+ self.assertLinesValues(
+ lines,
+ # Name Date Amount
+ [0, 1, 3],
+ [
+ ('Balance of \'101405 Bank\'', '', 0.0),
+ ('Last statement balance', '', 0.0),
+ ('Including Unreconciled Receipts', '', 0.0),
+ ('Including Unreconciled Payments', '', 0.0),
+ ('Transactions without statement', '', 0.0),
+ ('Including Unreconciled Receipts', '', 0.0),
+ ('Including Unreconciled Payments', '', 0.0),
+ ('Misc. operations', '', 0.0),
+ ('Outstanding Receipts/Payments', '', -1300.0),
+ ('(+) Outstanding Receipts', '', 0.0),
+ ('(-) Outstanding Payments', '', -1300.0),
+ ('BNKKK/2015/00001', '12/31/2015', -500.00),
+ ('BNKKK/2014/00001', '12/31/2014', -800.00),
+ ],
+ options,
+ currency_map={3: {'currency': bank_journal.currency_id}},
+ ignore_folded=False,
+ )
+
+ def test_reconciliation_report_misc_entry(self):
+ """ This test will check if the report correctly display the misc entry """
+ bank_journal = self.env['account.journal'].create({
+ 'name': 'Bank',
+ 'code': 'BNKKK',
+ 'type': 'bank',
+ 'company_id': self.company_data['company'].id,
+ })
+
+ # ==== Misc ====
+ self.env['account.move'].create({
+ 'journal_id': bank_journal.id,
+ 'date': '2016-01-02',
+ 'line_ids': [
+ (0, 0, {
+ 'name': 'Source',
+ 'debit': 800,
+ 'credit': 0,
+ 'account_id': bank_journal.default_account_id.id,
+ }),
+ (0, 0, {
+ 'name': 'Destination',
+ 'debit': 0,
+ 'credit': 800,
+ 'account_id': self.company_data['default_account_expense'].id,
+ }),
+ ]
+ }).action_post()
+
+ report = self.env.ref('odex30_account_reports.bank_reconciliation_report').with_context(
+ active_id=bank_journal.id,
+ active_model='account.journal'
+ )
+
+ options = self._generate_options(report, '2016-01-02', '2016-01-02')
+ options['unfold_all'] = True
+ lines = report._get_lines(options)
+
+ self.assertLinesValues(
+ lines,
+ # Name Date Amount
+ [0, 1, 3],
+ [
+ ('Balance of \'101405 Bank\'', '', 800.0),
+ ('Last statement balance', '', 0.0),
+ ('Including Unreconciled Receipts', '', 0.0),
+ ('Including Unreconciled Payments', '', 0.0),
+ ('Transactions without statement', '', 0.0),
+ ('Including Unreconciled Receipts', '', 0.0),
+ ('Including Unreconciled Payments', '', 0.0),
+ ('Misc. operations', '', 800.0),
+ ('Outstanding Receipts/Payments', '', 0.0),
+ ('(+) Outstanding Receipts', '', 0.0),
+ ('(-) Outstanding Payments', '', 0.0),
+ ],
+ options,
+ currency_map={3: {'currency': bank_journal.currency_id}},
+ ignore_folded=False,
+ )
+
+ # With the domain, since the date of the misc is not in the same year, the misc is not present
+ options = self._generate_options(report, '2014-01-02', '2014-01-02')
+ options['unfold_all'] = True
+ lines = report._get_lines(options)
+
+ self.assertLinesValues(
+ lines,
+ # Name Date Amount
+ [0, 1, 3],
+ [
+ ('Balance of \'101405 Bank\'', '', 0.0),
+ ('Last statement balance', '', 0.0),
+ ('Including Unreconciled Receipts', '', 0.0),
+ ('Including Unreconciled Payments', '', 0.0),
+ ('Transactions without statement', '', 0.0),
+ ('Including Unreconciled Receipts', '', 0.0),
+ ('Including Unreconciled Payments', '', 0.0),
+ ('Misc. operations', '', 0.0),
+ ('Outstanding Receipts/Payments', '', 0.0),
+ ('(+) Outstanding Receipts', '', 0.0),
+ ('(-) Outstanding Payments', '', 0.0),
+ ],
+ options,
+ currency_map={3: {'currency': bank_journal.currency_id}},
+ ignore_folded=False,
+ )
+
+ def test_reconciliation_report_delete_statement(self):
+ """ This test will do a basic flow where we create a statement and then we delete it to see how the report react"""
+
+ bank_journal = self.company_data['default_journal_bank']
+
+ statement = self.env['account.bank.statement'].create({
+ 'name': 'statement_1',
+ 'date': '2019-01-10',
+ 'balance_start': 0.0,
+ 'balance_end_real': 130.0,
+ 'line_ids': [
+ (0, 0, {'payment_ref': 'line_1', 'amount': 10.0, 'date': '2019-01-01', 'journal_id': bank_journal.id}),
+ (0, 0, {'payment_ref': 'line_2', 'amount': 20.0, 'date': '2019-01-02', 'journal_id': bank_journal.id}),
+ (0, 0, {'payment_ref': 'line_3', 'amount': 30.0, 'date': '2019-01-03', 'journal_id': bank_journal.id}),
+ (0, 0, {'payment_ref': 'line_4', 'amount': -40.0, 'date': '2019-01-04', 'journal_id': bank_journal.id}),
+ (0, 0, {'payment_ref': 'line_5', 'amount': 50.0, 'date': '2019-01-05', 'journal_id': bank_journal.id}),
+ (0, 0, {'payment_ref': 'line_6', 'amount': 60.0, 'date': '2019-01-06', 'journal_id': bank_journal.id}),
+ ],
+ })
+
+ report = self.env.ref('odex30_account_reports.bank_reconciliation_report').with_context(
+ active_id=bank_journal.id,
+ active_model='account.journal'
+ )
+
+ options = self._generate_options(report, fields.Date.from_string('2019-01-01'), fields.Date.from_string('2019-01-12'))
+ options['all_entries'] = True
+ options['unfold_all'] = True
+
+ lines = report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Date Amount
+ [0, 1, 3],
+ [
+ ('Balance of \'101401 Bank\'', '', 130.0),
+ ('Last statement balance', '', 130.0),
+ ('Including Unreconciled Receipts', '', 170.0),
+ ('BNK1/2019/00006', '01/06/2019', 60.0),
+ ('BNK1/2019/00005', '01/05/2019', 50.0),
+ ('BNK1/2019/00003', '01/03/2019', 30.0),
+ ('BNK1/2019/00002', '01/02/2019', 20.0),
+ ('BNK1/2019/00001', '01/01/2019', 10.0),
+ ('Including Unreconciled Payments', '', -40.0),
+ ('BNK1/2019/00004', '01/04/2019', -40.0),
+ ('Transactions without statement', '', 0.0),
+ ('Including Unreconciled Receipts', '', 0.0),
+ ('Including Unreconciled Payments', '', 0.0),
+ ('Misc. operations', '', 0.0),
+ ('Outstanding Receipts/Payments', '', 0.0),
+ ('(+) Outstanding Receipts', '', 0.0),
+ ('(-) Outstanding Payments', '', 0.0),
+ ],
+ options,
+ currency_map={3: {'currency': bank_journal.currency_id}},
+ ignore_folded=False,
+ )
+
+ statement.unlink()
+
+ options = self._generate_options(report, fields.Date.from_string('2019-01-01'), fields.Date.from_string('2019-01-12'))
+ options['all_entries'] = True
+ options['unfold_all'] = True
+
+ lines = report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Date Amount
+ [0, 1, 3],
+ [
+ ('Balance of \'101401 Bank\'', '', 130.0),
+ ('Last statement balance', '', 0.0),
+ ('Including Unreconciled Receipts', '', 0.0),
+ ('Including Unreconciled Payments', '', 0.0),
+ ('Transactions without statement', '', 130.0),
+ ('Including Unreconciled Receipts', '', 170.0),
+ ('BNK1/2019/00006', '01/06/2019', 60.0),
+ ('BNK1/2019/00005', '01/05/2019', 50.0),
+ ('BNK1/2019/00003', '01/03/2019', 30.0),
+ ('BNK1/2019/00002', '01/02/2019', 20.0),
+ ('BNK1/2019/00001', '01/01/2019', 10.0),
+ ('Including Unreconciled Payments', '', -40.0),
+ ('BNK1/2019/00004', '01/04/2019', -40.0),
+ ('Misc. operations', '', 0.0),
+ ('Outstanding Receipts/Payments', '', 0.0),
+ ('(+) Outstanding Receipts', '', 0.0),
+ ('(-) Outstanding Payments', '', 0.0),
+ ],
+ options,
+ currency_map={3: {'currency': bank_journal.currency_id}},
+ ignore_folded=False,
+ )
+
+ def test_reconciliation_report_transaction_without_statement(self):
+ """
+ This test will ensure that the section transaction without statement section contains the reconcile entries.
+ So the section should not always be the sum of his children.
+ """
+ bank_journal = self.company_data['default_journal_bank']
+
+ bank_statement_lines = self.env['account.bank.statement.line'].create([
+ {
+ 'payment_ref': 'Initial balance',
+ 'journal_id': bank_journal.id,
+ 'partner_id': self.partner_a.id,
+ 'amount': 1000.0,
+ 'date': '2019-01-01',
+ },
+ {
+ 'payment_ref': 'To be reconciled',
+ 'journal_id': bank_journal.id,
+ 'partner_id': self.partner_a.id,
+ 'amount': 100.0,
+ 'date': '2019-01-01',
+ }
+ ])
+
+ report = self.env.ref('odex30_account_reports.bank_reconciliation_report').with_context(
+ active_id=bank_journal.id,
+ active_model='account.journal'
+ )
+ options = self._generate_options(report, '2019-01-01', '2019-01-12')
+ options['all_entries'] = True
+ options['unfold_all'] = True
+
+ lines = report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Date Amount
+ [0, 1, 3],
+ [
+ ('Balance of \'101401 Bank\'', '', 1100.0),
+ ('Last statement balance', '', 0.0),
+ ('Including Unreconciled Receipts', '', 0.0),
+ ('Including Unreconciled Payments', '', 0.0),
+ ('Transactions without statement', '', 1100.0),
+ ('Including Unreconciled Receipts', '', 1100.0),
+ ('BNK1/2019/00002', '01/01/2019', 100.0),
+ ('BNK1/2019/00001', '01/01/2019', 1000.0),
+ ('Including Unreconciled Payments', '', 0.0),
+ ('Misc. operations', '', 0.0),
+ ('Outstanding Receipts/Payments', '', 0.0),
+ ('(+) Outstanding Receipts', '', 0.0),
+ ('(-) Outstanding Payments', '', 0.0),
+ ],
+ options,
+ currency_map={3: {'currency': bank_journal.currency_id}},
+ ignore_folded=False,
+ )
+
+ payment = self.env['account.payment'].create({
+ 'amount': 100.0,
+ 'payment_type': 'inbound',
+ 'date': '2019-01-01',
+ 'journal_id': bank_journal.id,
+ 'partner_id': self.partner_a.id,
+ })
+ payment.action_post()
+
+ payment_line = payment.move_id.line_ids.filtered(lambda line: line.account_id == payment.payment_method_line_id.payment_account_id)
+ wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=bank_statement_lines[1].id).new({})
+ wizard._action_add_new_amls(payment_line, allow_partial=False)
+ wizard._action_validate()
+
+ options = self._generate_options(report, '2019-01-01', '2019-01-12')
+ options['all_entries'] = True
+ options['unfold_all'] = True
+
+ lines = report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Date Amount
+ [0, 1, 3],
+ [
+ ('Balance of \'101401 Bank\'', '', 1100.0),
+ ('Last statement balance', '', 0.0),
+ ('Including Unreconciled Receipts', '', 0.0),
+ ('Including Unreconciled Payments', '', 0.0),
+ ('Transactions without statement', '', 1100.0),
+ ('Including Unreconciled Receipts', '', 1000.0),
+ ('BNK1/2019/00001', '01/01/2019', 1000.0),
+ ('Including Unreconciled Payments', '', 0.0),
+ ('Misc. operations', '', 0.0),
+ ('Outstanding Receipts/Payments', '', 0.0),
+ ('(+) Outstanding Receipts', '', 0.0),
+ ('(-) Outstanding Payments', '', 0.0),
+ ],
+ options,
+ currency_map={3: {'currency': bank_journal.currency_id}},
+ ignore_folded=False,
+ )
+
+ def test_reconciliation_report_exchange_entry(self):
+ """ This test will check that misc entries reported in the exchange journal
+ do not figure in the report
+ """
+
+ bank_journal = self.company_data['default_journal_bank']
+ exchange_journal = self.env.company.currency_exchange_journal_id
+
+ move_a = self.env['account.move'].create({
+ 'journal_id': exchange_journal.id,
+ 'move_type': 'entry',
+ 'date': '2019-01-01',
+ 'line_ids': [
+ Command.create({
+ 'name': 'line_a_1',
+ 'account_id': bank_journal.default_account_id.id,
+ 'debit': 1000.0,
+ 'credit': 0.0,
+ }),
+ Command.create({
+ 'name': 'line_a_2',
+ 'account_id': self.company_data['default_account_expense'].id,
+ 'debit': 0.0,
+ 'credit': 1000.0,
+ }),
+ ]
+ })
+ move_a.action_post()
+ report = self.env.ref('odex30_account_reports.bank_reconciliation_report').with_context(
+ active_id=bank_journal.id,
+ active_model='account.journal'
+ )
+
+ options = self._generate_options(report, fields.Date.from_string('2019-01-01'), fields.Date.from_string('2019-01-12'))
+
+ lines = report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Date Amount
+ [0, 1, 3],
+ [
+ ('Balance of \'101401 Bank\'', '', 0.0),
+ ('Last statement balance', '', 0.0),
+ ('Including Unreconciled Receipts', '', 0.0),
+ ('Including Unreconciled Payments', '', 0.0),
+ ('Transactions without statement', '', 0.0),
+ ('Including Unreconciled Receipts', '', 0.0),
+ ('Including Unreconciled Payments', '', 0.0),
+ ('Misc. operations', '', 0.0),
+ ('Outstanding Receipts/Payments', '', 0.0),
+ ('(+) Outstanding Receipts', '', 0.0),
+ ('(-) Outstanding Payments', '', 0.0),
+ ],
+ options,
+ currency_map={3: {'currency': bank_journal.currency_id}},
+ ignore_folded=False,
+ )
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_report_engines.py b/dev_odex30_accounting/odex30_account_reports/tests/test_report_engines.py
new file mode 100644
index 0000000..534b5bf
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_report_engines.py
@@ -0,0 +1,1963 @@
+from .common import TestAccountReportsCommon
+
+from odoo import fields, Command
+from odoo.tests import tagged
+from odoo.tools import frozendict
+
+from unittest.mock import patch
+
+
+@tagged('post_install', '-at_install')
+class TestReportEngines(TestAccountReportsCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.company_data['company'].totals_below_sections = False
+
+ cls.garbage_account = cls.env['account.account'].create({
+ 'code': "turlututu",
+ 'name': "turlututu",
+ 'account_type': "asset_current",
+ })
+
+ cls.fake_country = cls.env['res.country'].create({
+ 'name': "L'Île de la Mouche",
+ 'code': 'YY',
+ })
+
+ # -------------------------------------------------------------------------
+ # HELPERS
+ # -------------------------------------------------------------------------
+
+ def _prepare_test_account_move_line(self, balance, account_code=None, tax_tags=None, date='2020-01-01', **kwargs):
+ if tax_tags:
+ tags = self.env['account.account.tag'].search([
+ ('applicability', '=', 'taxes'),
+ ('country_id', '=', self.fake_country.id),
+ ('name', 'in', tax_tags),
+ ])
+ else:
+ tags = self.env['account.account.tag']
+
+ return {
+ 'account_move_line_values': {
+ 'name': "turlututu",
+ 'account_id': self.garbage_account.id,
+ **kwargs,
+ 'debit': balance if balance > 0.0 else 0.0,
+ 'credit': -balance if balance < 0.0 else 0.0,
+ 'tax_tag_ids': [Command.set(tags.ids)],
+ },
+ 'account_move_values': {'date': date},
+ 'account_code': account_code,
+ }
+
+ def _create_test_account_moves(self, test_account_move_line_values_list):
+ # Create the missing account on-the-fly.
+ accounts_to_create_by_code = set()
+ for test_account_move_line_values in test_account_move_line_values_list:
+ if test_account_move_line_values.get('account_code'):
+ accounts_to_create_by_code.add(test_account_move_line_values['account_code'])
+
+ if accounts_to_create_by_code:
+ accounts = self.env['account.account'].create([
+ {
+ 'code': account_code,
+ 'name': account_code,
+ 'account_type': "asset_current",
+ }
+ for account_code in accounts_to_create_by_code
+ ])
+ account_by_code = {x.code: x for x in accounts}
+
+ for test_account_move_line_values in test_account_move_line_values_list:
+ account = account_by_code.get(test_account_move_line_values.get('account_code'))
+ if account:
+ test_account_move_line_values['account_move_line_values']['account_id'] = account.id
+
+ # Create the journal entries.
+ to_create = {}
+ for test_account_move_line_values in test_account_move_line_values_list:
+ account_move_key = frozendict(test_account_move_line_values['account_move_values'])
+ account_move_line_values = test_account_move_line_values['account_move_line_values']
+ account_move_to_create = to_create.setdefault(account_move_key, {
+ 'account_move_values': {'line_ids': []},
+ 'balance': 0.0,
+ })
+ account_move_to_create['account_move_values']['line_ids'].append(Command.create(account_move_line_values))
+ account_move_to_create['balance'] += account_move_line_values['debit'] - account_move_line_values['credit']
+
+ account_move_create_list = []
+ for account_move_dict, account_move_to_create in to_create.items():
+ open_balance = account_move_to_create['balance']
+ account_move_values = account_move_to_create['account_move_values']
+ if not self.env.company.currency_id.is_zero(open_balance):
+ account_move_values['line_ids'].append(Command.create({
+ 'name': 'open balance',
+ 'account_id': self.garbage_account.id,
+ 'debit': -open_balance if open_balance < 0.0 else 0.0,
+ 'credit': open_balance if open_balance > 0.0 else 0.0,
+ }))
+ account_move_create_list.append({
+ **account_move_dict,
+ **account_move_values,
+ })
+
+ moves = self.env['account.move'].create(account_move_create_list)
+ moves.action_post()
+ return moves
+
+ def _prepare_test_external_values(self, value, date, figure_type=False):
+ field_name = 'text_value' if figure_type == 'string' else 'value'
+ return {
+ 'name': date,
+ field_name: value,
+ 'date': date,
+ }
+
+ def _prepare_test_expression(self, formula, label='balance', **kwargs):
+ return {
+ 'expression_values': {
+ 'label': label,
+ 'formula': formula,
+ **kwargs,
+ },
+ }
+
+ def _prepare_test_expression_tax_tags(self, formula, **kwargs):
+ return self._prepare_test_expression(engine='tax_tags', formula=formula, **kwargs)
+
+ def _prepare_test_expression_domain(self, formula, subformula, **kwargs):
+ return self._prepare_test_expression(engine='domain', formula=formula, subformula=subformula, **kwargs)
+
+ def _prepare_test_expression_account_codes(self, formula, **kwargs):
+ return self._prepare_test_expression(engine='account_codes', formula=formula, **kwargs)
+
+ def _prepare_test_expression_external(self, formula, external_value_generators, **kwargs):
+ return {
+ **self._prepare_test_expression(engine='external', formula=formula, **kwargs),
+ 'external_value_generators': external_value_generators,
+ }
+
+ def _prepare_test_expression_custom(self, formula, **kwargs):
+ return self._prepare_test_expression(engine='custom', formula=formula, **kwargs)
+
+ def _prepare_test_expression_aggregation(self, formula, subformula=None, column='balance', date_scope=None):
+ expression_values = {
+ 'label': column,
+ 'engine': 'aggregation',
+ 'formula': formula,
+ 'subformula': subformula,
+ }
+
+ if date_scope:
+ expression_values['date_scope'] = date_scope
+
+ return {
+ 'expression_values': expression_values,
+ }
+
+ def _prepare_test_report_line(self, *expression_generators, **kwargs):
+ return {
+ 'report_line_values': {
+ **kwargs,
+ 'expression_ids': [
+ Command.create({
+ 'date_scope': 'strict_range',
+ **expression_values['expression_values'],
+ })
+ for expression_values in expression_generators
+ ],
+ },
+ 'expression_generators': expression_generators,
+ }
+
+ def _create_report(self, test_report_line_values_list, columns=None, **kwargs):
+ if not columns:
+ columns = ['balance']
+
+ # Create a new report
+ report = self.env['account.report'].create({
+ 'name': "_run_report",
+ 'filter_date_range': True,
+ 'filter_unfold_all': True,
+ **kwargs,
+ 'column_ids': [
+ Command.create({
+ 'name': column,
+ 'expression_label': column,
+ 'sequence': i,
+ })
+ for i, column in enumerate(columns)
+ ],
+ 'line_ids': [
+ Command.create({
+ 'name': f"test_line_{i}",
+ **test_report_line_values['report_line_values'],
+ 'sequence': i,
+ })
+ for i, test_report_line_values in enumerate(test_report_line_values_list, start=1)
+ ],
+ })
+
+ # Create the external values
+ external_values_create_list = []
+ for report_line, test_report_line_values in zip(report.line_ids, test_report_line_values_list):
+ for expression, expression_values in zip(report_line.expression_ids, test_report_line_values['expression_generators']):
+ for external_values in expression_values.get('external_value_generators', []):
+ external_values_create_list.append({
+ **external_values,
+ 'target_report_expression_id': expression.id,
+ })
+ self.env['account.report.external.value'].create(external_values_create_list)
+
+ return report
+
+ # -------------------------------------------------------------------------
+ # TESTS
+ # -------------------------------------------------------------------------
+
+ def test_engine_tax_tags(self):
+ self.env.company.account_fiscal_country_id = self.fake_country
+
+ # Create the report.
+ test_line_1 = self._prepare_test_report_line(
+ self._prepare_test_expression_tax_tags('11'),
+ groupby='account_id,move_type',
+ )
+ test_line_2 = self._prepare_test_report_line(
+ self._prepare_test_expression_tax_tags('222T'),
+ groupby='parent_state,account_id',
+ )
+ test_line_3 = self._prepare_test_report_line(
+ self._prepare_test_expression_tax_tags('3333'),
+ groupby='account_id',
+ )
+ report = self._create_report(
+ [test_line_1, test_line_2, test_line_3],
+ country_id=self.fake_country.id,
+ )
+
+ # Create the journal entries.
+ move = self._create_test_account_moves([
+ self._prepare_test_account_move_line(2000.0, account_code='101001', tax_tags=['+11', '-222T']),
+ self._prepare_test_account_move_line(1000.0, account_code='101001', tax_tags=['+11', '-222T']),
+ self._prepare_test_account_move_line(3600.0, account_code='101001', tax_tags=['+222T']),
+ self._prepare_test_account_move_line(-600.0, account_code='101001', tax_tags=['+222T', '-3333']),
+ self._prepare_test_account_move_line(-900.0, account_code='101002', tax_tags=['-11']),
+ self._prepare_test_account_move_line(1500.0, account_code='101002', tax_tags=['+11']),
+ ])
+
+ options = self._generate_options(report, '2020-01-01', '2020-01-01', default_options={'unfold_all': True})
+ report_lines = report._get_lines(options)
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report_lines,
+ [ 0, 1],
+ [
+ ('test_line_1', 5400.0),
+ ('101001 101001', 3000.0),
+ ('Journal Entry', 3000.0),
+ ('101002 101002', 2400.0),
+ ('Journal Entry', 2400.0),
+ ('test_line_2', 0.0),
+ ('Posted', 0.0),
+ ('101001 101001', 0.0),
+ ('test_line_3', 600.0),
+ ('101001 101001', 600.0),
+ ],
+ options,
+ )
+
+ # Check redirection.
+ expected_redirection_values_list = [
+ move.line_ids[:2] + move.line_ids[4:6],
+ move.line_ids[:4],
+ move.line_ids[3],
+ ]
+ for report_line, expected_amls in zip(report.line_ids, expected_redirection_values_list):
+ report_line_dict = [x for x in report_lines if x['name'] == report_line.name][0]
+ with self.subTest(report_line=report_line.name):
+ action_dict = report.action_audit_cell(options, self._get_audit_params_from_report_line(options, report_line, report_line_dict))
+ self.assertEqual(move.line_ids.filtered_domain(action_dict['domain']), expected_amls)
+
+ def test_engine_domain(self):
+ domain = [('account_id.code', '=like', '1%'), ('balance', '<', 0.0)]
+
+ # Create the report.
+ test_line_1 = self._prepare_test_report_line(
+ self._prepare_test_expression_domain(domain, 'sum'),
+ groupby='account_id,move_type',
+ )
+ test_line_2 = self._prepare_test_report_line(
+ self._prepare_test_expression_domain(domain, '-sum'),
+ groupby='parent_state,account_id',
+ )
+ test_line_3 = self._prepare_test_report_line(
+ self._prepare_test_expression_domain(domain, 'sum_if_neg'),
+ groupby='account_id',
+ )
+ test_line_4 = self._prepare_test_report_line(
+ self._prepare_test_expression_domain(domain, '-sum_if_neg'),
+ groupby='account_id',
+ )
+ test_line_5 = self._prepare_test_report_line(
+ self._prepare_test_expression_domain(domain, 'sum_if_pos'),
+ groupby='account_id',
+ )
+ test_line_6 = self._prepare_test_report_line(
+ self._prepare_test_expression_domain(domain, '-sum_if_pos'),
+ groupby='account_id',
+ )
+ test_line_7 = self._prepare_test_report_line(
+ self._prepare_test_expression_domain(domain, 'count_rows'),
+ groupby='account_id',
+ )
+ report = self._create_report([test_line_1, test_line_2, test_line_3, test_line_4, test_line_5, test_line_6, test_line_7])
+
+ # Create the journal entries.
+ move = self._create_test_account_moves([
+ self._prepare_test_account_move_line(2000.0, account_code='101001'),
+ self._prepare_test_account_move_line(-300.0, account_code='101002'),
+ self._prepare_test_account_move_line(-600.0, account_code='101003'),
+ self._prepare_test_account_move_line(-900.0, account_code='101004'),
+ ])
+
+ # Check the values.
+ options = self._generate_options(report, '2020-01-01', '2020-01-01', default_options={'unfold_all': True})
+ report_lines = report._get_lines(options)
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report_lines,
+ [ 0, 1],
+ [
+ ('test_line_1', -1800.0),
+ ('101002 101002', -300.0),
+ ('Journal Entry', -300.0),
+ ('101003 101003', -600.0),
+ ('Journal Entry', -600.0),
+ ('101004 101004', -900.0),
+ ('Journal Entry', -900.0),
+ ('test_line_2', 1800.0),
+ ('Posted', 1800.0),
+ ('101002 101002', 300.0),
+ ('101003 101003', 600.0),
+ ('101004 101004', 900.0),
+ ('test_line_3', -1800.0),
+ ('101002 101002', -300.0),
+ ('101003 101003', -600.0),
+ ('101004 101004', -900.0),
+ ('test_line_4', 1800.0),
+ ('101002 101002', 300.0),
+ ('101003 101003', 600.0),
+ ('101004 101004', 900.0),
+ ('test_line_5', 0.0),
+ ('test_line_6', 0.0),
+ ('test_line_7', 3),
+ ('101002 101002', 1),
+ ('101003 101003', 1),
+ ('101004 101004', 1),
+ ],
+ options,
+ )
+
+ # Check redirection.
+ expected_amls = move.line_ids.search(domain)
+ for report_line in report.line_ids:
+ report_line_dict = [x for x in report_lines if x['name'] == report_line.name][0]
+ with self.subTest(report_line=report_line.name):
+ action_dict = report.action_audit_cell(options, self._get_audit_params_from_report_line(options, report_line, report_line_dict))
+ self.assertEqual(move.line_ids.filtered_domain(action_dict['domain']), expected_amls)
+
+ def test_engine_account_codes(self):
+ # Create test account tags
+ account_tags = self.env['account.account.tag']._load_records([
+ {
+ 'xml_id': 'odex30_account_reports.account_codes_engine_test_tag1',
+ 'noupdate': True,
+ 'values': {
+ 'name': "account_codes test tag 1",
+ 'applicability': 'accounts',
+ },
+ },
+
+ {
+ 'xml_id': 'odex30_account_reports.account_codes_engine_test_tag2',
+ 'noupdate': True,
+ 'values': {
+ 'name': "account_codes test tag 2",
+ 'applicability': 'accounts',
+ },
+ },
+ ])
+
+ # Create the report.
+ test_line_1 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes('1'),
+ groupby='account_id',
+ )
+ test_line_2 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes('1C'),
+ groupby='parent_state,account_id',
+ )
+ test_line_3 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes('1D'),
+ groupby='account_id',
+ )
+ test_line_4 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes(r'-101\(101003)'),
+ groupby='account_id',
+ )
+ test_line_5 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes(r'-101\(101003)C'),
+ groupby='account_id',
+ )
+ test_line_6 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes(r'-101\(101002,101003)'),
+ groupby='account_id,move_type',
+ )
+ test_line_7 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes('10.'),
+ groupby='account_id',
+ )
+ test_line_8 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes('10.20'),
+ groupby='account_id',
+ )
+ test_line_9 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes('10.20 - 101 + 101002'),
+ groupby='account_id',
+ )
+ test_line_10 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes(r'10.20 - 101\(101002)'),
+ groupby='account_id',
+ )
+ test_line_11 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes(r'345D\()D'),
+ groupby='account_id',
+ )
+ test_line_12 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes(r'345D\()C'),
+ groupby='account_id',
+ )
+ test_line_13 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes(rf'tag(odex30_account_reports.account_codes_engine_test_tag1) + tag({account_tags[1].id})'),
+ groupby='account_id',
+ )
+ test_line_14 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes(r'tag(odex30_account_reports.account_codes_engine_test_tag1)D'),
+ groupby='account_id',
+ )
+ test_line_15 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes(r'tag(odex30_account_reports.account_codes_engine_test_tag1)C'),
+ groupby='account_id',
+ )
+ test_line_16 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes(rf'tag(odex30_account_reports.account_codes_engine_test_tag1)\(101)D + 101003 + tag({account_tags[1].id})\(101)C'),
+ groupby='account_id',
+ )
+
+ report = self._create_report([
+ test_line_1, test_line_2, test_line_3, test_line_4, test_line_5, test_line_6, test_line_7, test_line_8,
+ test_line_9, test_line_10, test_line_11, test_line_12, test_line_13, test_line_14, test_line_15, test_line_16
+ ])
+
+ # Create the journal entries.
+ move = self._create_test_account_moves([
+ self._prepare_test_account_move_line(1000.0, account_code='100001'),
+ self._prepare_test_account_move_line(2000.0, account_code='101001'),
+ self._prepare_test_account_move_line(-300.0, account_code='101002'),
+ self._prepare_test_account_move_line(-600.0, account_code='101003'),
+ self._prepare_test_account_move_line(10000.0, account_code='10.20.0'),
+ self._prepare_test_account_move_line(10.0, account_code='345D'),
+ ])
+
+ # Setup tags on accounts
+ self.env['account.account'].search([('code', 'in', ('100001', '101001'))]).tag_ids = account_tags[0]
+ self.env['account.account'].search([('code', 'in', ('10.20.0', '101002'))]).tag_ids = account_tags[1]
+
+ # Check the values.
+ options = self._generate_options(report, '2020-01-01', '2020-01-01', default_options={'unfold_all': True})
+ report_lines = report._get_lines(options)
+
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report_lines,
+ [ 0, 1],
+ [
+ ('test_line_1', 12100.0),
+ ('10.20.0 10.20.0', 10000.0),
+ ('100001 100001', 1000.0),
+ ('101001 101001', 2000.0),
+ ('101002 101002', -300.0),
+ ('101003 101003', -600.0),
+ ('test_line_2', -900.0),
+ ('Posted', -900.0),
+ ('101002 101002', -300.0),
+ ('101003 101003', -600.0),
+ ('test_line_3', 13000.0),
+ ('10.20.0 10.20.0', 10000.0),
+ ('100001 100001', 1000.0),
+ ('101001 101001', 2000.0),
+ ('test_line_4', -1700.0),
+ ('101001 101001', -2000.0),
+ ('101002 101002', 300.0),
+ ('test_line_5', 300.0),
+ ('101002 101002', 300.0),
+ ('test_line_6', -2000.0),
+ ('101001 101001', -2000.0),
+ ('Journal Entry', -2000.0),
+ ('test_line_7', 10000.0),
+ ('10.20.0 10.20.0', 10000.0),
+ ('test_line_8', 10000.0),
+ ('10.20.0 10.20.0', 10000.0),
+ ('test_line_9', 8600.0),
+ ('10.20.0 10.20.0', 10000.0),
+ ('101001 101001', -2000.0),
+ ('101002 101002', 0.0),
+ ('101003 101003', 600.0),
+ ('test_line_10', 8600.0),
+ ('10.20.0 10.20.0', 10000.0),
+ ('101001 101001', -2000.0),
+ ('101003 101003', 600.0),
+ ('test_line_11', 10.0),
+ ('345D 345D', 10.0),
+ ('test_line_12', 0.0),
+ ('test_line_13', 12700.0),
+ ('10.20.0 10.20.0', 10000.0),
+ ('100001 100001', 1000.0),
+ ('101001 101001', 2000.0),
+ ('101002 101002', -300.0),
+ ('test_line_14', 3000.0),
+ ('100001 100001', 1000.0),
+ ('101001 101001', 2000.0),
+ ('test_line_15', 0.0),
+ ('test_line_16', 400.0),
+ ('100001 100001', 1000.0),
+ ('101003 101003', -600.0),
+ ],
+ options,
+ )
+
+ # Check redirection.
+ expected_redirection_values_list = [
+ move.line_ids[:5],
+ move.line_ids[:5],
+ move.line_ids[:5],
+ move.line_ids[1:3],
+ move.line_ids[1:3],
+ move.line_ids[1],
+ move.line_ids[4],
+ move.line_ids[4],
+ move.line_ids[1:5],
+ move.line_ids[1] + move.line_ids[3:5],
+ move.line_ids[5],
+ move.line_ids[5],
+ ]
+ for report_line, expected_amls in zip(report.line_ids, expected_redirection_values_list):
+ report_line_dict = [x for x in report_lines if x['name'] == report_line.name][0]
+ with self.subTest(report_line=report_line.name):
+ action_dict = report.action_audit_cell(options, self._get_audit_params_from_report_line(options, report_line, report_line_dict))
+ self.assertEqual(move.line_ids.filtered_domain(action_dict['domain']), expected_amls)
+
+ def test_engine_external(self):
+ # Create the report.
+ test_line_1 = self._prepare_test_report_line(
+ self._prepare_test_expression_external('sum', [
+ self._prepare_test_external_values(100.0, '2020-01-02'),
+ self._prepare_test_external_values(200.0, '2020-01-03'),
+ self._prepare_test_external_values(300.0, '2020-01-03'),
+ self._prepare_test_external_values(299.999999999, '2020-01-05'),
+ ])
+ )
+ test_line_2 = self._prepare_test_report_line(
+ self._prepare_test_expression_external('most_recent', [
+ self._prepare_test_external_values(100.0, '2020-01-02'),
+ self._prepare_test_external_values(200.0, '2020-01-03'),
+ self._prepare_test_external_values(300.0, '2020-01-03'),
+ self._prepare_test_external_values(299.999999999, '2020-01-05'),
+ ])
+ )
+ report = self._create_report([test_line_1, test_line_2])
+
+ # Check the values at multiple dates.
+ options = self._generate_options(report, '2020-01-01', '2020-01-01')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [ 0, 1],
+ [
+ ('test_line_1', 0.0),
+ ('test_line_2', 0.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(report, '2020-01-02', '2020-01-02')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [ 0, 1],
+ [
+ ('test_line_1', 100.0),
+ ('test_line_2', 100.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(report, '2020-01-03', '2020-01-03')
+ report_lines = report._get_lines(options)
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report_lines,
+ [ 0, 1],
+ [
+ ('test_line_1', 500.0),
+ ('test_line_2', 500.0),
+ ],
+ options,
+ )
+
+ # Check redirection.
+ for report_line, report_line_dict in zip(report.line_ids, report_lines):
+ with self.subTest(report_line=report_line.name):
+ action_dict = report.action_audit_cell(options, self._get_audit_params_from_report_line(options, report_line, report_line_dict))
+ self.assertRecordValues(
+ self.env['account.report.external.value'].search(action_dict['domain']),
+ [
+ {'date': fields.Date.from_string('2020-01-03')},
+ {'date': fields.Date.from_string('2020-01-03')},
+ ],
+ )
+
+ options = self._generate_options(report, '2020-01-04', '2020-01-04')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [ 0, 1],
+ [
+ ('test_line_1', 0.0),
+ ('test_line_2', 0.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(report, '2020-01-02', '2020-01-04')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [ 0, 1],
+ [
+ ('test_line_1', 600.0),
+ ('test_line_2', 500.0),
+ ],
+ options,
+ )
+
+ # Check redirection.
+ expected_redirection_values_list = [
+ [
+ {'date': fields.Date.from_string('2020-01-02')},
+ {'date': fields.Date.from_string('2020-01-03')},
+ {'date': fields.Date.from_string('2020-01-03')},
+ ],
+ [
+ {'date': fields.Date.from_string('2020-01-03')},
+ {'date': fields.Date.from_string('2020-01-03')},
+ ],
+ ]
+ for report_line, report_line_dict, expected_values in zip(report.line_ids, report_lines, expected_redirection_values_list):
+ with self.subTest(report_line=report_line.name):
+ action_dict = report.action_audit_cell(options, self._get_audit_params_from_report_line(options, report_line, report_line_dict))
+ self.assertRecordValues(
+ self.env['account.report.external.value'].search(action_dict['domain']),
+ expected_values,
+ )
+
+ options = self._generate_options(report, '2020-01-03', '2020-01-05')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [ 0, 1],
+ [
+ ('test_line_1', 800.0),
+ ('test_line_2', 300.0),
+ ],
+ options,
+ )
+
+ def test_engine_external_editable_percentage(self):
+ # Create the report.
+ test_rounding_4 = self._prepare_test_report_line(
+ self._prepare_test_expression_external(
+ 'most_recent', [
+ self._prepare_test_external_values(10.1254, '2020-01-01'),
+ self._prepare_test_external_values(5, '2020-01-02'),
+ ], figure_type='percentage', subformula='editable;rounding=4',
+ ),
+ code='TEST_PERCENTAGE'
+ )
+ test_rounding_2 = self._prepare_test_report_line(
+ self._prepare_test_expression_external(
+ 'most_recent', [
+ self._prepare_test_external_values(10.12, '2020-01-01'),
+ self._prepare_test_external_values(5, '2020-01-02'),
+ ], figure_type='percentage', subformula='editable;rounding=2',
+ )
+ )
+ test_percentage_aggregate = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('10000 * TEST_PERCENTAGE.balance'),
+ )
+ test_float = self._prepare_test_report_line(
+ self._prepare_test_expression_external(
+ 'most_recent', [
+ self._prepare_test_external_values(10.12, '2020-01-01'),
+ self._prepare_test_external_values(5, '2020-01-02'),
+ ], figure_type='float', subformula='editable;rounding=2',
+ )
+ )
+
+ report = self._create_report([test_rounding_4, test_rounding_2, test_percentage_aggregate, test_float])
+ # Check the values at multiple dates.
+ options = self._generate_options(report, '2020-01-01', '2020-01-01')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [0, 1],
+ [
+ ('test_line_1', '10.1254%'),
+ ('test_line_2', '10.12%'),
+ ('test_line_3', 101254),
+ ('test_line_4', '10.12'),
+ ],
+ options,
+ )
+
+ options = self._generate_options(report, '2020-01-02', '2020-01-02')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [0, 1],
+ [
+ ('test_line_1', '5.0000%'),
+ ('test_line_2', '5.00%'),
+ ('test_line_3', 50000),
+ ('test_line_4', '5.00'),
+ ],
+ options,
+ )
+
+ def test_engine_custom(self):
+ # Create the report.
+ test_line_1 = self._prepare_test_report_line(
+ self._prepare_test_expression_custom('_custom_engine_test', subformula='sum'),
+ groupby='account_id',
+ )
+ report = self._create_report([test_line_1])
+
+ # Create the journal entries.
+ self._create_test_account_moves([
+ self._prepare_test_account_move_line(2000.0, account_code='101001'),
+ self._prepare_test_account_move_line(-300.0, account_code='101002'),
+ ])
+
+ # Check the values.
+
+ def _custom_engine_test(expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
+ domain = [('account_id.code', '=', '101002')]
+ domain_key = str(domain)
+ formulas_dict = {domain_key: expressions}
+ domain_result = report._compute_formula_batch_with_engine_domain(
+ options, date_scope, formulas_dict, current_groupby, next_groupby,
+ offset=offset, limit=limit,
+ )
+ return list(domain_result.values())[0]
+
+ orig_get_custom_report_function = report._get_custom_report_function
+
+ def get_custom_report_function(_report, function_name, prefix):
+ if function_name == '_custom_engine_test':
+ return _custom_engine_test
+ return orig_get_custom_report_function(function_name, prefix)
+
+ with patch.object(type(report), '_get_custom_report_function', get_custom_report_function):
+ options = self._generate_options(report, '2020-01-01', '2020-01-01')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [ 0, 1],
+ [
+ ('test_line_1', -300.0),
+ ('101002 101002', -300.0),
+ ],
+ options,
+ )
+
+ def test_engine_aggregation(self):
+ self.env.company.account_fiscal_country_id = self.fake_country
+
+ # Test division by zero.
+ test1 = self._prepare_test_report_line(
+ self._prepare_test_expression_tax_tags('11', label='tax_tags'),
+ self._prepare_test_expression_domain([('account_id.code', '=', '101002')], 'sum', label='domain'),
+ self._prepare_test_expression_external('sum', [self._prepare_test_external_values(100.0, '2020-01-01')], label='external'),
+ self._prepare_test_expression_aggregation('test1.tax_tags + test1.domain', column='aggregation'),
+ self._prepare_test_expression_aggregation('test1.tax_tags / 0', subformula='ignore_zero_division'),
+ self._prepare_test_expression_external('sum', [self._prepare_test_external_values(100.47, '2020-01-01')], label='external_decimal'),
+ name='test1', code='test1',
+ )
+
+ # Test if_(above|below|between) operators.
+ test2_1 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.tax_tags', subformula='if_above(USD(0))'),
+ name='test2_1', code='test2_1',
+ )
+ test2_2 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.tax_tags', subformula='if_above(USD(1999.9999999))'),
+ name='test2_2', code='test2_2',
+ )
+ test2_3 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.tax_tags', subformula='if_above(USD(2500.0))'),
+ name='test2_3', code='test2_3',
+ )
+ test2_4 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.tax_tags', subformula='if_above(CAD(3600.0))'),
+ name='test2_4', code='test2_4',
+ )
+ test3_1 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.domain', subformula='if_below(USD(0))'),
+ name='test3_1', code='test3_1',
+ )
+ test3_2 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.domain', subformula='if_below(USD(-300.00001))'),
+ name='test3_2', code='test3_2',
+ )
+ test3_3 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.domain', subformula='if_below(USD(- 350))'),
+ name='test3_3', code='test3_3',
+ )
+ test4_1 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.tax_tags + test1.domain', subformula='if_between(USD(0), USD(2000))'),
+ name='test4_1', code='test4_1',
+ )
+ test4_2 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.tax_tags + test1.domain', subformula='if_between(CAD(0), CAD(3000))'),
+ name='test4_2', code='test4_2',
+ )
+
+ # Test line code recognition.
+ test5 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes('101003', label='account_codes'),
+ self._prepare_test_expression_aggregation('test1.tax_tags + 9999.account_codes'),
+ name='9999', code='9999',
+ )
+
+ # Test mathematical operators.
+ test6 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation(
+ '(test1.tax_tags + (2 * test1.domain) + 100.0) / (9999.account_codes)'
+ ),
+ name='test6', code='test6',
+ )
+
+ # Test other date scope
+ test7 = self._prepare_test_report_line(
+ self._prepare_test_expression_domain(
+ [('account_id.code', '=', '101002')],
+ 'sum',
+ label='domain',
+ date_scope='to_beginning_of_period',
+ ),
+ self._prepare_test_expression_aggregation('test7.domain'),
+ name='test7', code='test7',
+ )
+
+ # Test exponential notation
+ test9 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation(
+ '(test1.tax_tags + (2 * test1.domain) + 100.0 + 1.752e-17) / (9999.account_codes)'
+ ),
+ name='test9', code='test9',
+ )
+
+ # Test 'round' subformula
+ test10_1 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.external_decimal', subformula='round(0)'),
+ name='test10_1', code='test10_1',
+ )
+ test10_2 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.external_decimal', subformula='round(1)'),
+ name='test10_2', code='test10_2',
+ )
+ test10_3 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.external_decimal', subformula='round(3)'),
+ name='test10_3', code='test10_3',
+ )
+
+ # Test if_other_expr_above / if_other_expr_below
+ test11_1 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.external', subformula='if_other_expr_above(test1.tax_tags, USD(3000.0))'),
+ name='test11_1', code='test11_1',
+ )
+ test11_2 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.external', subformula='if_other_expr_below(test1.tax_tags, USD(3000.0))'),
+ name='test11_2', code='test11_2',
+ )
+ test11_3 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.external', subformula='if_other_expr_above(test1.tax_tags, USD(1000.0))'),
+ name='test11_3', code='test11_3',
+ )
+ test11_4 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.external', subformula='if_other_expr_below(test1.tax_tags, USD(1000.0))'),
+ name='test11_4', code='test11_4',
+ )
+ # Test with an aggregation in the condition
+ test11_5 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.external', subformula='if_other_expr_above(test1.aggregation, USD(1000.0))'),
+ name='test11_5', code='test11_5',
+ )
+ test11_6 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.external', subformula='if_other_expr_below(test1.aggregation, USD(1000.0))'),
+ name='test11_6', code='test11_6',
+ )
+
+ # Test sum_children formula (parent_id relationship is populated below)
+ test_12_1 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('sum_children'),
+ name='test12_1', code='test12_1',
+ )
+ test_12_2 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.tax_tags'),
+ name='test12_2', code='test12_2',
+ )
+ test_12_3 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test1.domain'),
+ name='test12_3', code='test12_3',
+ )
+ test_12_4 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('sum_children'),
+ name='test12_4', code='test12_4',
+ )
+ test_12_5 = self._prepare_test_report_line(
+ self._prepare_test_expression_domain([('account_id.code', '=', '101003')], 'sum'),
+ name='test12_5', # No code on purpose to check a different case of sum_children
+ )
+
+ report = self._create_report(
+ [
+ test1, test2_1, test2_2, test2_3, test2_4, test3_1, test3_2, test3_3, test4_1, test4_2,
+ test5, test6, test7, test9, test10_1, test10_2, test10_3, test11_1, test11_2, test11_3, test11_4,
+ test11_5, test11_6, test_12_1, test_12_2, test_12_3, test_12_4, test_12_5,
+ ],
+ country_id=self.fake_country.id,
+ )
+
+ # Set parent link properly for sum_children test, now that all lines are created:
+ line_12_1 = self.env['account.report.line'].search([('code', '=', 'test12_1')])
+ self.env['account.report.line'].search([('code', 'in', ('test12_2', 'test12_3', 'test12_4'))]).parent_id = line_12_1
+ line_12_4 = self.env['account.report.line'].search([('code', '=', 'test12_4')])
+ self.env['account.report.line'].search([('name', '=', 'test12_5')]).parent_id = line_12_4
+
+ # Create the journal entries.
+ moves = self._create_test_account_moves([
+ self._prepare_test_account_move_line(100000.0, account_code='101002', date='2019-01-01'),
+ self._prepare_test_account_move_line(2000.0, account_code='101001', tax_tags=['+11']),
+ self._prepare_test_account_move_line(-300.0, account_code='101002'),
+ self._prepare_test_account_move_line(1500.0, account_code='101003'),
+ ])
+
+ # Check the values.
+ options = self._generate_options(report, '2020-01-01', '2020-01-01')
+ report_lines = report._get_lines(options)
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report_lines,
+ [ 0, 1],
+ [
+ ('test1', 0.0),
+ ('test2_1', 2000.0),
+ ('test2_2', 0.0),
+ ('test2_3', 0.0),
+ ('test2_4', 2000.0),
+ ('test3_1', -300.0),
+ ('test3_2', 0.0),
+ ('test3_3', 0.0),
+ ('test4_1', 1700.0),
+ ('test4_2', 0.0),
+ ('9999', 3500.0),
+ ('test6', 1.0),
+ ('test7', 100000.0),
+ ('test9', 1.0),
+ ('test10_1', 100.0),
+ ('test10_2', 100.5),
+ ('test10_3', 100.47),
+ ('test11_1', 0.0),
+ ('test11_2', 100.0),
+ ('test11_3', 100.0),
+ ('test11_4', 0.0),
+ ('test11_5', 100.0),
+ ('test11_6', 0.0),
+ ('test12_1', 3200.0),
+ ('test12_2', 2000.0),
+ ('test12_3', -300.0),
+ ('test12_4', 1500.0),
+ ('test12_5', 1500.0),
+ ],
+ options,
+ )
+
+ # Check redirection.
+ expected_amls_to_test = [
+ ('9999', moves[1].line_ids[0] + moves[1].line_ids[2]),
+ ('test7', moves[0].line_ids[0]),
+ ('test12_1', moves[1].line_ids[:3]),
+ ]
+ for report_line_name, expected_amls in expected_amls_to_test:
+ report_line = report.line_ids.filtered(lambda x: x.name == report_line_name)
+ report_line_dict = [x for x in report_lines if x['name'] == report_line.name][0]
+ with self.subTest(report_line=report_line.name):
+ action_dict = report.action_audit_cell(options, self._get_audit_params_from_report_line(options, report_line, report_line_dict))
+ self.assertEqual(moves.line_ids.filtered_domain(action_dict['domain']), expected_amls)
+
+ def test_engine_aggregation_cross_report(self):
+ moves = self._create_test_account_moves([
+ self._prepare_test_account_move_line(1.0, account_code='100000', date='2020-01-01'),
+ self._prepare_test_account_move_line(2.0, account_code='100000', date='2021-01-01'),
+ self._prepare_test_account_move_line(3.0, account_code='200000', date='2020-01-01'),
+ self._prepare_test_account_move_line(4.0, account_code='200000', date='2021-01-01'),
+ self._prepare_test_account_move_line(5.0, account_code='300000', date='2021-01-01'),
+ ])
+
+ # Other report
+ other_report_line_1 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes('1'),
+ name='other_report_line_1', code='other_report_line_1',
+ )
+
+ other_report_line_2 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes('2'),
+ name='other_report_line_2', code='other_report_line_2',
+ )
+
+ other_report_line_3 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('other_report_line_1.balance + other_report_line_2.balance'),
+ name='other_report_line_3', code='other_report_line_3',
+ )
+
+ other_report_line_4 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes('3'),
+ name='other_report_line_4', code='other_report_line_4',
+ )
+
+ other_report = self._create_report([other_report_line_1, other_report_line_2, other_report_line_3, other_report_line_4])
+ other_report_options = self._generate_options(other_report, '2021-01-01', '2021-01-01')
+
+ # Main report
+ main_report_line_1 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('other_report_line_2.balance', subformula='cross_report', date_scope='strict_range'),
+ name='main_report_line_1', code='main_report_line_1',
+ )
+
+ main_report_line_2 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('other_report_line_2.balance', subformula='cross_report', date_scope='from_beginning'),
+ name='main_report_line_2', code='main_report_line_2',
+ )
+
+ main_report_line_3 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('other_report_line_3.balance', subformula='cross_report', date_scope='strict_range'),
+ name='main_report_line_3', code='main_report_line_3',
+ )
+
+ main_report_line_4 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('other_report_line_3.balance', subformula='cross_report', date_scope='from_beginning'),
+ name='main_report_line_4', code='main_report_line_4',
+ )
+
+ main_report_line_5 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation(
+ 'main_report_line_1.balance + main_report_line_2.balance + main_report_line_3.balance + main_report_line_4.balance',
+ ),
+ name='main_report_line_5', code='main_report_line_5',
+ )
+
+ main_report_line_6 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation(
+ 'main_report_line_1.balance + main_report_line_2.balance + main_report_line_3.balance + main_report_line_4.balance',
+ ),
+ name='main_report_line_6', code='main_report_line_6',
+ )
+
+ main_report = self._create_report([main_report_line_1, main_report_line_2, main_report_line_3, main_report_line_4, main_report_line_5, main_report_line_6])
+ main_report_options = self._generate_options(main_report, '2021-01-01', '2021-01-01')
+
+ # First check other_report
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ other_report._get_lines(other_report_options),
+ [ 0, 1],
+ [
+ ('other_report_line_1', 2.0),
+ ('other_report_line_2', 4.0),
+ ('other_report_line_3', 6.0),
+ ('other_report_line_4', 5.0),
+ ],
+ other_report_options,
+ )
+
+ # Check main_report
+ main_report_options = self._generate_options(main_report, '2021-01-01', '2021-01-01')
+ main_report_lines = main_report._get_lines(main_report_options)
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ main_report_lines,
+ [ 0, 1],
+ [
+ ('main_report_line_1', 4.0),
+ ('main_report_line_2', 7.0),
+ ('main_report_line_3', 6.0),
+ ('main_report_line_4', 10.0),
+ ('main_report_line_5', 27.0),
+ ('main_report_line_6', 27.0),
+ ],
+ main_report_options,
+ )
+
+ # Check redirection.
+ expected_amls_to_test = [
+ ('main_report_line_1', moves[1].line_ids[1]),
+ ('main_report_line_2', moves[1].line_ids[1] + moves[0].line_ids[1]),
+ ]
+
+ for report_line_name, expected_amls in expected_amls_to_test:
+ report_line = main_report.line_ids.filtered(lambda x: x.name == report_line_name)
+ report_line_dict = [x for x in main_report_lines if x['name'] == report_line.name][0]
+ with self.subTest(report_line=report_line.name):
+ action_dict = main_report.action_audit_cell(main_report_options, self._get_audit_params_from_report_line(main_report_options, report_line, report_line_dict))
+ self.assertEqual(moves.line_ids.filtered_domain(action_dict['domain']), expected_amls)
+
+ def test_engine_aggregation_expansion(self):
+ report = self._create_report([
+ self._prepare_test_report_line(
+ self._prepare_test_expression_tax_tags('42'),
+ code='TAG_1',
+ ),
+ self._prepare_test_report_line(
+ self._prepare_test_expression_tax_tags('221292'),
+ code='TAG_2',
+ ),
+ self._prepare_test_report_line(
+ self._prepare_test_expression_tax_tags('777'),
+ code='TAG_3',
+ ),
+ self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('TAG_1.balance + TAG_2.balance'),
+ code='SIMPLE_AGG',
+ ),
+ self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('SIMPLE_AGG.balance + TAG_3.balance'),
+ code='COMPLEX_AGG',
+ ),
+ self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('TAG_1.balance + TAG_2.balance', subformula='if_other_expr_above(TAG_3.balance, EUR(13))'),
+ code='BOUNDED_AGG',
+ ),
+ self._prepare_test_report_line(
+ self._prepare_test_expression_tax_tags('3333'),
+ ),
+ ])
+
+ other_report = self._create_report([
+ self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('SIMPLE_AGG.balance + BOUNDED_AGG.balance', subformula='cross_report'),
+ code='CROSS_REPORT_AGG',
+ ),
+ ])
+
+ expr_map = {expression.report_line_id.code: expression for expression in (report + other_report).line_ids.expression_ids}
+
+ self.assertEqual(
+ expr_map['SIMPLE_AGG']._expand_aggregations(),
+ expr_map['SIMPLE_AGG'] + expr_map['TAG_1'] + expr_map['TAG_2'],
+ )
+
+ self.assertEqual(
+ expr_map['COMPLEX_AGG']._expand_aggregations(),
+ expr_map['COMPLEX_AGG'] + expr_map['SIMPLE_AGG'] + expr_map['TAG_1'] + expr_map['TAG_2'] + expr_map['TAG_3'],
+ )
+
+ self.assertEqual(
+ expr_map['BOUNDED_AGG']._expand_aggregations(),
+ expr_map['BOUNDED_AGG'] + expr_map['TAG_1'] + expr_map['TAG_2'] + expr_map['TAG_3'],
+ )
+
+ self.assertEqual(
+ expr_map['CROSS_REPORT_AGG']._expand_aggregations(),
+ expr_map['CROSS_REPORT_AGG'] + expr_map['SIMPLE_AGG'] + expr_map['BOUNDED_AGG'] + expr_map['TAG_1'] + expr_map['TAG_2'] + expr_map['TAG_3'],
+ )
+
+ def test_load_more(self):
+ partner_a, partner_b, partner_c = self.env['res.partner'].create([
+ {'name': 'Partner A'},
+ {'name': 'Partner B'},
+ {'name': 'Partner C'},
+ ])
+
+ self._create_test_account_moves([
+ self._prepare_test_account_move_line(1000.0, partner_id=partner_a.id, date='2020-01-01'),
+ self._prepare_test_account_move_line(2000.0, partner_id=partner_b.id, date='2020-01-01'),
+ self._prepare_test_account_move_line(3000.0, partner_id=partner_c.id, date='2020-01-01'),
+ ])
+
+ report = self._create_report(
+ test_report_line_values_list=[self._prepare_test_report_line(
+ self._prepare_test_expression_domain([('partner_id', '!=', False)], 'sum'),
+ groupby='partner_id',
+ )],
+ load_more_limit=2,
+ )
+
+ options = self._generate_options(report, '2020-01-01', '2020-01-31')
+ lines = report._get_lines(options)
+
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ lines,
+ [ 0, 1],
+ [
+ ('test_line_1', '6,000.00'),
+ ('Partner A', '1,000.00'),
+ ('Partner B', '2,000.00'),
+ ('Load more...', ''),
+ ],
+ options,
+ )
+
+ load_more_line = lines[-1]
+ load_more_res = report.get_expanded_lines(
+ options,
+ load_more_line['id'],
+ load_more_line['groupby'],
+ load_more_line['expand_function'],
+ load_more_line['progress'],
+ load_more_line['offset'],
+ None,
+ )
+
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ load_more_res,
+ [ 0, 1],
+ [
+ ('Partner C', '3,000.00'),
+ ],
+ options,
+ )
+
+ def test_engine_external_boolean(self):
+ # Create the report.
+ test_line = self._prepare_test_report_line(
+ self._prepare_test_expression_external('most_recent', [
+ self._prepare_test_external_values('1', '2020-01-02'),
+ self._prepare_test_external_values('0', '2020-01-03'),
+ self._prepare_test_external_values('1', '2020-01-03'),
+ self._prepare_test_external_values('0', '2020-01-05'),
+ ], figure_type='boolean')
+ )
+
+ report = self._create_report([test_line])
+ # Check the values at multiple dates.
+ options = self._generate_options(report, '2020-01-01', '2020-01-01')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [ 0, 1],
+ [
+ ('test_line_1', 'No'),
+ ],
+ options,
+ )
+
+ options = self._generate_options(report, '2020-01-02', '2020-01-02')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [ 0, 1],
+ [
+ ('test_line_1', 'Yes'),
+ ],
+ options,
+ )
+
+ options = self._generate_options(report, '2020-01-03', '2020-01-03')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [ 0, 1],
+ [
+ ('test_line_1', 'Yes'),
+ ],
+ options,
+ )
+
+ options = self._generate_options(report, '2020-01-05', '2020-01-05')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [ 0, 1],
+ [
+ ('test_line_1', 'No'),
+ ],
+ options,
+ )
+
+ options = self._generate_options(report, '2020-01-02', '2020-01-05')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [ 0, 1],
+ [
+ ('test_line_1', 'No'),
+ ],
+ options,
+ )
+
+ def test_engine_external_string(self):
+ # Create the report.
+ test_line = self._prepare_test_report_line(
+ self._prepare_test_expression_external('most_recent', [
+ self._prepare_test_external_values('TARDIS', '2020-01-02', figure_type='string'),
+ self._prepare_test_external_values('Kris Kelvin', '2020-01-03', figure_type='string'),
+ self._prepare_test_external_values('Trisolaris', '2020-01-03', figure_type='string'),
+ self._prepare_test_external_values("5-ounce bird carrying a 1-pound coconut", '2020-01-05', figure_type='string'),
+ ], figure_type='string')
+ )
+
+ report = self._create_report([test_line])
+ # Check the values at multiple dates.
+ options = self._generate_options(report, '2020-01-01', '2020-01-01')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [ 0, 1],
+ [
+ ('test_line_1', ''),
+ ],
+ options,
+ )
+
+ options = self._generate_options(report, '2020-01-02', '2020-01-02')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [ 0, 1],
+ [
+ ('test_line_1', 'TARDIS'),
+ ],
+ options,
+ )
+
+ options = self._generate_options(report, '2020-01-03', '2020-01-03')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [ 0, 1],
+ [
+ ('test_line_1', 'Trisolaris'),
+ ],
+ options,
+ )
+
+ options = self._generate_options(report, '2020-01-05', '2020-01-05')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [ 0, 1],
+ [
+ ('test_line_1', "5-ounce bird carrying a 1-pound coconut"),
+ ],
+ options,
+ )
+
+ options = self._generate_options(report, '2020-01-02', '2020-01-05')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [ 0, 1],
+ [
+ ('test_line_1', "5-ounce bird carrying a 1-pound coconut"),
+ ],
+ options,
+ )
+
+ def test_engine_external_default_value_tax_closing_fiscalyear_lock_date(self):
+ def lock_via_fiscalyear_lock_date(non_tax_report, tax_report, report_options_map):
+ lock_date_wizard = self.env['account.change.lock.date'].create({
+ 'fiscalyear_lock_date': fields.Date.from_string('2020-01-02'),
+ })
+ lock_date_wizard.change_lock_date()
+
+ self._run_external_engine_default_test_case(False, True, lock_via_fiscalyear_lock_date)
+
+ def test_engine_external_default_value_tax_closing_tax_lock_date(self):
+ def lock_via_tax_lock_date(non_tax_report, tax_report, report_options_map):
+ lock_date_wizard = self.env['account.change.lock.date'].create({
+ 'tax_lock_date': fields.Date.from_string('2020-01-02'),
+ })
+ lock_date_wizard.change_lock_date()
+
+ self._run_external_engine_default_test_case(True, False, lock_via_tax_lock_date)
+
+ def test_engine_external_default_value_tax_closing(self):
+ def lock_via_tax_closing(non_tax_report, tax_report, report_options_map):
+ tax_closing_action = self.env['account.tax.report.handler'].with_context({'override_tax_closing_warning': True}).action_periodic_vat_entries(report_options_map[tax_report])
+ closing_move_id = tax_closing_action['res_id']
+ with self.enter_test_mode():
+ self.env['account.move'].browse(closing_move_id).action_post()
+
+ self._run_external_engine_default_test_case(True, False, lock_via_tax_closing)
+
+ def _run_external_engine_default_test_case(self, impact_tax_report, impact_non_tax_report, lock_operation_function):
+ """ Common helper to run the tests of _default expressions
+ """
+ test_line_1 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes('10'),
+ groupby='account_id',
+ )
+ test_line_2 = self._prepare_test_report_line(
+ self._prepare_test_expression_external('sum', {}),
+ self._prepare_test_expression_account_codes('10', label='_default_balance'),
+ )
+
+ non_tax_report = self._create_report([test_line_1, test_line_2], name="non_tax_report")
+ tax_report = self._create_report([test_line_1, test_line_2], root_report_id=self.env.ref('account.generic_tax_report').id, name="tax_report")
+
+ # Create the journal entries.
+ self._create_test_account_moves([
+ self._prepare_test_account_move_line(1000.0, account_code='100001'),
+ self._prepare_test_account_move_line(-300.0, account_code='101002'),
+ self._prepare_test_account_move_line(-600.0, account_code='314159'),
+ ])
+
+ report_options_map = {
+ report: self._generate_options(
+ report,
+ '2020-01-01', '2020-01-31',
+ default_options={
+ 'unfold_all': True,
+ }
+ )
+ for report in [non_tax_report, tax_report]
+ }
+
+ # Check the values before locking
+ for report in [non_tax_report, tax_report]:
+ with self.subTest(report=report.name):
+ options = report_options_map[report]
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [0, 1],
+ [
+ ('test_line_1', 700.0),
+ ('100001 100001', 1000.0),
+ ('101002 101002', -300.0),
+ ('test_line_2', 0.0),
+ ],
+ options,
+ )
+
+ # Run the lock operation
+ lock_operation_function(non_tax_report, tax_report, report_options_map)
+
+ # Check the values after locking
+ for report, impacted in [(non_tax_report, impact_non_tax_report), (tax_report, impact_tax_report)]:
+ with self.subTest(report=report.name):
+ options = report_options_map[report]
+ if impacted:
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [0, 1],
+ [
+ ('test_line_1', 700.0),
+ ('100001 100001', 1000.0),
+ ('101002 101002', -300.0),
+ ('test_line_2', 700.0),
+ ],
+ options,
+ )
+ else:
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [0, 1],
+ [
+ ('test_line_1', 700.0),
+ ('100001 100001', 1000.0),
+ ('101002 101002', -300.0),
+ ('test_line_2', 0.0),
+ ],
+ options,
+ )
+
+ def test_engine_aggregation_cross_bound(self):
+ report_1 = self._create_report([
+ self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('line_2_1.balance', subformula='cross_report'),
+ name='Line 1-1',
+ code='line_1_1',
+ ),
+ ])
+
+ self._create_report([
+ self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('14.0', subformula='if_other_expr_above(line_2_1.dudu, EUR(0))'),
+ self._prepare_test_expression_account_codes('101', label='dudu'),
+ name='Line 2-1',
+ code='line_2_1',
+ ),
+ ])
+
+ options = self._generate_options(report_1, '2020-01-01', '2020-01-01')
+
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report_1._get_lines(options),
+ [ 0, 1],
+ [
+ ('Line 1-1', 0.0),
+ ],
+ options
+ )
+
+ self._create_test_account_moves([
+ self._prepare_test_account_move_line(10, account_code='101001'),
+ self._prepare_test_account_move_line(-10, account_code='100001'),
+ ])
+
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report_1._get_lines(options),
+ [ 0, 1],
+ [
+ ('Line 1-1', 14.0),
+ ],
+ options
+ )
+
+ def test_change_expression_engine_to_tax_tags(self):
+ """
+ Ensure that tax tags are created when switching the expression engine to tax tags if formula is unchanged.
+ """
+ formula = 'dudu'
+ test_line_1 = self._prepare_test_report_line(
+ self._prepare_test_expression_external(formula, [self._prepare_test_external_values(100.0, '2020-01-01')], label='external'),
+ )
+ report = self._create_report([test_line_1], country_id=self.fake_country.id)
+ tags = self.env['account.account.tag']._get_tax_tags(formula, self.fake_country.id)
+ self.assertEqual(len(tags), 0)
+ report.line_ids[0].expression_ids[0].engine = 'tax_tags'
+ tags = self.env['account.account.tag']._get_tax_tags(formula, self.fake_country.id)
+ self.assertEqual(tags.mapped('name'), ['-' + formula, '+' + formula])
+
+ def test_integer_rounding(self):
+ line_1 = self._prepare_test_report_line(
+ self._prepare_test_expression_domain([('account_id.code', '=', '101001')], 'sum'),
+ code='test_1',
+ )
+ line_2 = self._prepare_test_report_line(
+ self._prepare_test_expression_tax_tags('42'),
+ code='test_2',
+ )
+ line_3 = self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes('1'),
+ code='test_3',
+ )
+ line_4 = self._prepare_test_report_line(
+ self._prepare_test_expression_external('sum', [
+ self._prepare_test_external_values(3.5, '2023-01-01'),
+ ]),
+ code='test_4',
+ )
+ line_5 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test_1.balance + test_2.balance + test_3.balance + test_4.balance'),
+ code='test_5',
+ )
+ line_6 = self._prepare_test_report_line(
+ self._prepare_test_expression_aggregation('test_5.balance / 10'),
+ )
+
+ report = self._create_report([line_1, line_2, line_3, line_4, line_5, line_6], country_id=self.fake_country.id,)
+
+ self._create_test_account_moves([
+ self._prepare_test_account_move_line(5.4, account_code='101001', tax_tags=['+42'], date='2023-01-01'),
+ ])
+
+ # Test with a first rounding
+ report.integer_rounding = 'HALF-UP'
+ half_up_options = self._generate_options(report, '2023-01-01', '2023-01-01')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(half_up_options),
+ [ 0, 1],
+ [
+ ('test_line_1', 5.0),
+ ('test_line_2', 5.0),
+ ('test_line_3', 5.0),
+ ('test_line_4', 4.0),
+ ('test_line_5', 19.0),
+ ('test_line_6', 2.0),
+ ],
+ half_up_options,
+ )
+
+ # Test with another rounding method
+ report.integer_rounding = 'UP'
+ up_options = self._generate_options(report, '2023-01-01', '2023-01-01')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(up_options),
+ [ 0, 1],
+ [
+ ('test_line_1', 6.0),
+ ('test_line_2', 6.0),
+ ('test_line_3', 6.0),
+ ('test_line_4', 4.0),
+ ('test_line_5', 22.0),
+ ('test_line_6', 3.0),
+ ],
+ up_options
+ )
+
+ # In file export mode, the rounding should always be applied, even if it was previously disabled
+ print_mode_options = self._generate_options(report, '2023-01-01', '2023-01-01', default_options={'integer_rounding_enabled': False, 'export_mode': 'file'})
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(print_mode_options),
+ [ 0, 1],
+ [
+ ('test_line_1', 6.0),
+ ('test_line_2', 6.0),
+ ('test_line_3', 6.0),
+ ('test_line_4', 4.0),
+ ('test_line_5', 22.0),
+ ('test_line_6', 3.0),
+ ],
+ print_mode_options,
+ )
+
+ # Rounding available, but disabled
+ no_rounding_options = self._generate_options(report, '2023-01-01', '2023-01-01', default_options={'integer_rounding_enabled': False})
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(no_rounding_options),
+ [ 0, 1],
+ [
+ ('test_line_1', 5.40),
+ ('test_line_2', 5.40),
+ ('test_line_3', 5.40),
+ ('test_line_4', 3.50),
+ ('test_line_5', 19.70),
+ ('test_line_6', 1.97),
+ ],
+ no_rounding_options,
+ )
+
+ def test_print_hide_0_lines(self):
+ # Create the report.
+ test_line_1 = self._prepare_test_report_line(
+ self._prepare_test_expression_tax_tags('11'),
+ groupby='account_id',
+ )
+ test_line_2 = self._prepare_test_report_line(
+ self._prepare_test_expression_tax_tags('222T'),
+ groupby='account_id',
+ )
+ report = self._create_report([test_line_1, test_line_2], country_id=self.fake_country.id)
+
+ # Create the journal entries.
+ self._create_test_account_moves([
+ self._prepare_test_account_move_line(3000.0, account_code='101001', tax_tags=['+11', '-222T']),
+ self._prepare_test_account_move_line(3600.0, account_code='101001', tax_tags=['+222T']),
+ self._prepare_test_account_move_line(-600.0, account_code='101001', tax_tags=['+222T', '-11']),
+ ])
+
+ # To ensure that the lines are shown when hide_0_lines isn't toggled and vice versa, we test both scenarios.
+ options_not_hide = self._generate_options(
+ report,
+ '2020-01-01', '2020-01-01',
+ default_options={'unfold_all': True, 'export_mode': 'print'}
+ )
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options_not_hide),
+ [ 0, 1],
+ [
+ ('test_line_1', 3600.0),
+ ('101001 101001', 3600.0),
+ ('test_line_2', 0.0),
+ ('101001 101001', 0.0),
+ ],
+ options_not_hide,
+ )
+
+ options_hide = self._generate_options(
+ report,
+ '2020-01-01', '2020-01-01',
+ default_options={'unfold_all': True, 'export_mode': 'print', 'hide_0_lines': True}
+ )
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options_hide),
+ [ 0, 1],
+ [
+ ('test_line_1', 3600.0),
+ ('101001 101001', 3600.0),
+ ],
+ options_hide,
+ )
+
+ def test_column_blank_if_zero(self):
+ """account.report.column's `blank_if_zero` option should only impacts number figure types"""
+ test_line = self._prepare_test_report_line(
+ self._prepare_test_expression_external('most_recent', [], label='monetary', figure_type='monetary'),
+
+ self._prepare_test_expression_external('most_recent', [], label='percentage', figure_type='percentage'),
+
+ self._prepare_test_expression_external('most_recent', [], label='integer', figure_type='integer'),
+
+ self._prepare_test_expression_external('most_recent', [], label='float', figure_type='float'),
+
+ self._prepare_test_expression_external('most_recent', [
+ self._prepare_test_external_values(False, '2024-01-01', figure_type='boolean'),
+ ], label='boolean', figure_type='boolean'),
+
+ self._prepare_test_expression_external('most_recent', [
+ self._prepare_test_external_values('dudu', '2024-01-01', figure_type='string'),
+ ], label='string', figure_type='string'),
+ )
+
+ report = self._create_report([test_line], columns=['monetary', 'percentage', 'integer', 'float', 'boolean', 'string'])
+ report.column_ids.blank_if_zero = True
+
+ options = self._generate_options(report, '2024-01-01', '2024-01-01')
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [ 0, 1, 2, 3, 4, 5, 6],
+ [
+ ('test_line_1', '', '', '', '', 'No', 'dudu'),
+ ],
+ options,
+ )
+
+ def test_column_groups_audit(self):
+ account = self.company_data['default_account_assets']
+ move = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2024-01-01',
+ 'line_ids': [
+ (0, 0, {'debit': 10.0, 'credit': 0.0, 'account_id': account.id, 'partner_id': self.partner_a.id}),
+ (0, 0, {'debit': 20.0, 'credit': 0.0, 'account_id': account.id, 'partner_id': self.partner_b.id}),
+ (0, 0, {'debit': 0.0, 'credit': 30.0, 'account_id': account.id}),
+ ],
+ })
+ move.action_post()
+
+ report = self._create_report([
+ self._prepare_test_report_line(
+ self._prepare_test_expression_domain([('account_id', '=', account.id)], 'sum'),
+ code='report_line',
+ ),
+ ], country_id=self.fake_country.id)
+
+ horizontal_group = self.env['account.report.horizontal.group'].create({
+ 'name': 'Horizontal Group',
+ 'report_ids': report.ids,
+ 'rule_ids': [
+ Command.create({
+ 'field_name': 'partner_id',
+ 'domain': f"[('id', 'in', {(self.partner_a + self.partner_b).ids})]",
+ }),
+ ],
+ })
+
+ main_options = self._generate_options(report, '2024-01-01', '2024-01-01', default_options={'selected_horizontal_group_id': horizontal_group.id})
+ lines = report._get_lines(main_options)
+ for (col_group_key, col_group_options), expected_partner in zip(report._split_options_per_column_group(main_options).items(), [self.partner_a, self.partner_b]):
+ audit_params = self._get_audit_params_from_report_line(col_group_options, report.line_ids, lines[0], column_group_key=col_group_key)
+ action_dict = report.action_audit_cell(col_group_options, audit_params)
+
+ expected_amls = move.line_ids.filtered(lambda x: x.partner_id == expected_partner)
+ audit_result_amls = move.line_ids.filtered_domain(action_dict['domain'])
+ self.assertEqual(audit_result_amls, expected_amls, f"Wrong audit result for partner {expected_partner.name}: {audit_result_amls}")
+
+ def test_account_codes_load_more_limit_groupby(self):
+ """ The account_codes engine performs an additional groupby in its SQL query. This tests makes sure this does not break the behavior
+ of the load_more_limit when grouping.
+ """
+ partner_a, partner_b, partner_c = self.env['res.partner'].create([
+ {'name': 'Partner A'},
+ {'name': 'Partner B'},
+ {'name': 'Partner C'},
+ ])
+
+ report = self._create_report(
+ [
+ self._prepare_test_report_line(
+ self._prepare_test_expression_account_codes('1'),
+ groupby='partner_id',
+ ),
+ ],
+ load_more_limit=2,
+ )
+
+ move = self._create_test_account_moves([
+ self._prepare_test_account_move_line(10, account_code='11', partner_id=partner_a.id),
+ self._prepare_test_account_move_line(20, account_code='11', partner_id=partner_a.id),
+ self._prepare_test_account_move_line(25, account_code='12', partner_id=partner_a.id),
+ self._prepare_test_account_move_line(30, account_code='11'),
+ self._prepare_test_account_move_line(40, account_code='11'),
+ self._prepare_test_account_move_line(50, account_code='12', partner_id=partner_b.id),
+ self._prepare_test_account_move_line(60, account_code='13', partner_id=partner_c.id),
+ self._prepare_test_account_move_line(-210, account_code='2'),
+ ])
+
+ options = self._generate_options(report, '2020-01-01', '2020-01-01', default_options={'unfold_all': True})
+
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report._get_lines(options),
+ [ 0, 1],
+ [
+ ('test_line_1', 235.0),
+ ('Partner A', 55.0),
+ ('Partner B', 50.0),
+ ('Load more...', ''),
+ ],
+ options,
+ )
+
+ def test_account_name_added_in_account_code_grouping(self):
+ company_a = self.env['res.company'].sudo().create({
+ 'name': "Company A",
+ })
+ company_b = self.env['res.company'].sudo().create({
+ 'name': "Company B",
+ })
+ context = {
+ **self.env.context,
+ 'allowed_company_ids': [company_a.id, company_b.id],
+ }
+ journal_vals = {
+ 'name': "Misc",
+ 'type': 'general',
+ 'code': "MSC",
+ }
+ AccountJournal = self.env['account.journal'].with_context(context)
+ AccountJournal.create(journal_vals | {'company_id': company_a.id})
+ AccountJournal.with_company(company_b).create(journal_vals | {'company_id': company_b.id})
+
+ AccountAccount = self.env['account.account'].with_context(context)
+ account_a = AccountAccount.create({
+ 'name': "Account A",
+ 'code': "100000",
+ 'company_ids': company_a.ids,
+ })
+ counterpart_account_a = AccountAccount.create({
+ 'name': "Some account",
+ 'code': "200000",
+ 'company_ids': company_a.ids,
+ })
+ account_b1 = AccountAccount.create({
+ 'name': "Mapping to Account A code",
+ 'code': "300000",
+ 'company_ids': company_b.ids,
+ })
+ account_b2 = AccountAccount.create({
+ 'name': "Mapping to a code that doesn't exist in company A",
+ 'code': "400000",
+ 'company_ids': company_b.ids,
+ })
+ account_b3 = AccountAccount.create({
+ 'name': "No mapping in company A",
+ 'code': "500000",
+ 'company_ids': company_b.ids,
+ })
+
+ account_b1.code = account_a.code
+ account_b2.code = "600000"
+
+ date = '2025-01-01'
+
+ AccountMove = self.env['account.move'].with_context(context)
+ AccountMove.create({
+ 'move_type': 'entry',
+ 'date': date,
+ 'line_ids': [
+ Command.create({
+ 'account_id': account_a.id,
+ 'balance': 20,
+ }),
+ Command.create({
+ 'account_id': counterpart_account_a.id,
+ 'balance': -20,
+ }),
+ ],
+ 'company_id': company_a.id,
+ }).action_post()
+ AccountMove.create({
+ 'move_type': 'entry',
+ 'date': date,
+ 'line_ids': [
+ Command.create({
+ 'account_id': account_b1.id,
+ 'balance': 30,
+ }),
+ Command.create({
+ 'account_id': account_b2.id,
+ 'balance': 70,
+ }),
+ Command.create({
+ 'account_id': account_b3.id,
+ 'balance': -100,
+ }),
+ ],
+ 'company_id': company_b.id,
+ }).action_post()
+
+ report = self.env['account.report'].with_context(context).create({
+ 'name': "Simple Report",
+ 'filter_multi_company': 'selector',
+ 'column_ids': [Command.create({
+ 'name': "Balance",
+ 'expression_label': 'balance',
+ })],
+ 'line_ids': [Command.create({
+ 'name': "The line",
+ 'groupby': 'account_code',
+ 'expression_ids': [Command.create({
+ 'label': 'balance',
+ 'engine': 'domain',
+ 'formula': [],
+ 'subformula': 'sum',
+ })],
+ })],
+ })
+ options = self._generate_options(report, date, date)
+ lines = report._get_lines(options)
+
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ lines,
+ [ 0, 1],
+ [
+ (report.line_ids.name, 0),
+ (f'{account_a.code} {account_a.name}', 50),
+ (f'{counterpart_account_a.code} {counterpart_account_a.name}', -20),
+ (account_b2.code, 70),
+ ('Undefined', -100),
+ ],
+ options,
+ )
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_report_sections.py b/dev_odex30_accounting/odex30_account_reports/tests/test_report_sections.py
new file mode 100644
index 0000000..74b6064
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_report_sections.py
@@ -0,0 +1,148 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=C0326
+
+from freezegun import freeze_time
+from unittest.mock import patch
+
+from odoo.addons.account.tests.common import AccountTestInvoicingHttpCommon
+
+from odoo import Command
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestReportSections(AccountTestInvoicingHttpCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.section_1 = cls.env['account.report'].create({
+ 'name': "Section 1",
+ 'filter_journals': True,
+ 'column_ids': [
+ Command.create({
+ 'name': "Column 1",
+ 'expression_label': 'col1',
+ }),
+ ],
+ 'line_ids': [
+ Command.create({
+ 'name': 'Section 1 line',
+ 'expression_ids': [
+ Command.create({
+ 'label': 'col1',
+ 'engine': 'tax_tags',
+ 'formula': 'tag1_1',
+ }),
+ ],
+ }),
+ ],
+ })
+
+ cls.section_2 = cls.env['account.report'].create({
+ 'name': "Section 2",
+ 'filter_period_comparison': True,
+ 'column_ids': [
+ Command.create({
+ 'name': "Column 1",
+ 'expression_label': 'col1',
+ }),
+
+ Command.create({
+ 'name': "Column 2",
+ 'expression_label': 'col2',
+ }),
+ ],
+ 'line_ids': [
+ Command.create({
+ 'name': 'Section 2 line',
+ 'expression_ids': [
+ Command.create({
+ 'label': 'col1',
+ 'engine': 'tax_tags',
+ 'formula': 'tag2_1',
+ }),
+
+ Command.create({
+ 'label': 'col2',
+ 'engine': 'tax_tags',
+ 'formula': 'tag2_2',
+ })
+ ],
+ }),
+ ],
+ })
+
+ cls.composite_report = cls.env['account.report'].create({
+ 'name': "Test Sections",
+ 'section_report_ids': [Command.set((cls.section_1 + cls.section_2).ids)],
+ })
+
+ def test_sections_options_report_selection_variant(self):
+ generic_tax_report = self.env.ref('account.generic_tax_report')
+ self.composite_report.root_report_id = generic_tax_report
+
+ # Open root report
+ options = generic_tax_report.get_options({})
+ self.assertEqual(options['variants_source_id'], generic_tax_report.id, "The root report should be the variants source.")
+ self.assertEqual(options['sections_source_id'], generic_tax_report.id, "No variant is selected; the root report should be chosen.")
+ self.assertEqual(options['selected_variant_id'], generic_tax_report.id, "No variant is selected; the root report should be chosen.")
+ self.assertEqual(options['report_id'], generic_tax_report.id, "No variant is selected; the root report should be chosen.")
+
+ # Select the variant
+ options = generic_tax_report.get_options({**options, 'selected_variant_id': self.composite_report.id})
+ self.assertEqual(options['variants_source_id'], generic_tax_report.id, "The root report should be the variants source.")
+ self.assertEqual(options['sections_source_id'], self.composite_report.id, "The selected variant should be the sections source.")
+ self.assertEqual(options['selected_section_id'], self.section_1.id, "Selecting the composite variant should select its first section.")
+ self.assertEqual(options['report_id'], self.section_1.id, "Selecting the composite variant should open its first section.")
+
+ # Select the section
+ options = generic_tax_report.get_options({**options, 'selected_section_id': self.section_2.id})
+ self.assertEqual(options['variants_source_id'], generic_tax_report.id, "The root report should be the variants source.")
+ self.assertEqual(options['sections_source_id'], self.composite_report.id, "The selected variant should be the sections source.")
+ self.assertEqual(options['selected_section_id'], self.section_2.id, "Section 2 should be selected.")
+ self.assertEqual(options['report_id'], self.section_2.id, "Selecting the second section from the first one should open it.")
+
+ def test_sections_options_report_selection_root(self):
+ # Open the report
+ options = self.composite_report.get_options({})
+ self.assertEqual(options['variants_source_id'], self.composite_report.id, "The root report should be the variants source.")
+ self.assertEqual(options['sections_source_id'], self.composite_report.id, "The root report should be the sections source.")
+ self.assertEqual(options['selected_section_id'], self.section_1.id, "Opening the composite report should select its first section.")
+ self.assertEqual(options['report_id'], self.section_1.id, "Opening the composite report should open its first section.")
+
+ # Select the section
+ options = self.composite_report.get_options({**options, 'selected_section_id': self.section_2.id})
+ self.assertEqual(options['variants_source_id'], self.composite_report.id, "The root report should be the variants source.")
+ self.assertEqual(options['sections_source_id'], self.composite_report.id, "The root report should be the sections source.")
+ self.assertEqual(options['selected_section_id'], self.section_2.id, "Section 2 should be selected.")
+ self.assertEqual(options['report_id'], self.section_2.id, "Selecting the second section from the first one should open it.")
+
+ def test_sections_tour(self):
+ def patched_init_options_custom(report, options, previous_options):
+ # Emulates a custom handler modifying the export buttons
+ if report == self.composite_report:
+ options['buttons'][0]['name'] = 'composite_report_custom_button'
+
+ # Setup the reports
+ generic_tax_report = self.env.ref('account.generic_tax_report')
+ self.composite_report.root_report_id = generic_tax_report
+ self.section_1.root_report_id = generic_tax_report # First section is a variant of the root report, to increase test coverage
+ # Rewriting the root report recomputes filter_journal ; re-enable it
+ self.section_1.filter_journals = True
+
+ with patch.object(type(self.env['account.report']), '_init_options_custom', patched_init_options_custom):
+ self.start_tour("/odoo", 'account_reports_sections', login=self.env.user.login)
+
+ def test_exported_xlsx_unique_names(self):
+ composite_report = self.env['account.report'].create({
+ 'name': "Composite",
+ })
+ for i in range(1, 13):
+ self.env['account.report'].create({
+ 'name': "Comprehensive Monthly Analysis Report Q%d" % i,
+ 'section_main_report_ids': [Command.set([composite_report.id])],
+ })
+
+ composite_report.export_to_xlsx(composite_report.get_options({}))
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_tax_report.py b/dev_odex30_accounting/odex30_account_reports/tests/test_tax_report.py
new file mode 100644
index 0000000..909a36d
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_tax_report.py
@@ -0,0 +1,3174 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=bad-whitespace
+from datetime import date
+from unittest.mock import patch
+from freezegun import freeze_time
+
+from .common import TestAccountReportsCommon
+from odoo import fields, Command
+from odoo.tests import Form, tagged
+from odoo.exceptions import UserError
+
+
+@tagged('post_install', '-at_install')
+class TestTaxReport(TestAccountReportsCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ # Create country data
+
+ cls.fiscal_country = cls.env['res.country'].create({
+ 'name': "Discworld",
+ 'code': 'DW',
+ })
+
+ cls.country_state_1 = cls.env['res.country.state'].create({
+ 'name': "Ankh Morpork",
+ 'code': "AM",
+ 'country_id': cls.fiscal_country.id,
+ })
+
+ cls.country_state_2 = cls.env['res.country.state'].create({
+ 'name': "Counterweight Continent",
+ 'code': "CC",
+ 'country_id': cls.fiscal_country.id,
+ })
+
+ cls.foreign_country = cls.env['res.country'].create({
+ 'name': "The Principality of Zeon",
+ 'code': 'PZ',
+ })
+
+ # Setup fiscal data
+ cls.company_data['company'].write({
+ 'state_id': cls. country_state_1.id, # Not necessary at the moment; put there for consistency and robustness with possible future changes
+ 'account_tax_periodicity': 'trimester',
+ })
+ cls.change_company_country(cls.company_data['company'], cls.fiscal_country)
+
+ # Prepare tax groups
+ cls.tax_group_1 = cls._instantiate_basic_test_tax_group()
+ cls.tax_group_2 = cls._instantiate_basic_test_tax_group()
+ cls.tax_group_3 = cls._instantiate_basic_test_tax_group(country=cls.foreign_country)
+
+ # Prepare tax accounts
+ cls.tax_account_1 = cls.env['account.account'].create({
+ 'name': 'Tax Account',
+ 'code': '250000',
+ 'account_type': 'liability_current',
+ })
+
+ cls.tax_account_2 = cls.env['account.account'].create({
+ 'name': 'Tax Account',
+ 'code': '250001',
+ 'account_type': 'liability_current',
+ })
+
+ # ==== Sale taxes: group of two taxes having type_tax_use = 'sale' ====
+ cls.sale_tax_percentage_incl_1 = cls.env['account.tax'].create({
+ 'name': 'sale_tax_percentage_incl_1',
+ 'amount': 20.0,
+ 'amount_type': 'percent',
+ 'type_tax_use': 'sale',
+ 'price_include_override': 'tax_included',
+ 'tax_group_id': cls.tax_group_1.id,
+ })
+
+ cls.sale_tax_percentage_excl = cls.env['account.tax'].create({
+ 'name': 'sale_tax_percentage_excl',
+ 'amount': 10.0,
+ 'amount_type': 'percent',
+ 'type_tax_use': 'sale',
+ 'tax_group_id': cls.tax_group_1.id,
+ })
+
+ cls.sale_tax_group = cls.env['account.tax'].create({
+ 'name': 'sale_tax_group',
+ 'amount_type': 'group',
+ 'type_tax_use': 'sale',
+ 'children_tax_ids': [Command.set((cls.sale_tax_percentage_incl_1 + cls.sale_tax_percentage_excl).ids)],
+ })
+
+ cls.move_sale = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': cls.company_data['default_journal_sale'].id,
+ 'line_ids': [
+ Command.create({
+ 'debit': 1320.0,
+ 'credit': 0.0,
+ 'account_id': cls.company_data['default_account_receivable'].id,
+ }),
+ Command.create({
+ 'debit': 0.0,
+ 'credit': 120.0,
+ 'account_id': cls.tax_account_1.id,
+ 'tax_repartition_line_id': cls.sale_tax_percentage_excl.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id,
+ }),
+ Command.create({
+ 'debit': 0.0,
+ 'credit': 200.0,
+ 'account_id': cls.tax_account_1.id,
+ 'tax_repartition_line_id': cls.sale_tax_percentage_incl_1.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id,
+ 'tax_ids': [Command.set(cls.sale_tax_percentage_excl.ids)]
+ }),
+ Command.create({
+ 'debit': 0.0,
+ 'credit': 1000.0,
+ 'account_id': cls.company_data['default_account_revenue'].id,
+ 'tax_ids': [Command.set(cls.sale_tax_group.ids)]
+ }),
+ ],
+ })
+ cls.move_sale.action_post()
+
+ # ==== Purchase taxes: group of taxes having type_tax_use = 'none' ====
+
+ cls.none_tax_percentage_incl_2 = cls.env['account.tax'].create({
+ 'name': 'none_tax_percentage_incl_2',
+ 'amount': 20.0,
+ 'amount_type': 'percent',
+ 'type_tax_use': 'none',
+ 'price_include_override': 'tax_included',
+ 'tax_group_id': cls.tax_group_2.id,
+ })
+
+ cls.none_tax_percentage_excl = cls.env['account.tax'].create({
+ 'name': 'none_tax_percentage_excl',
+ 'amount': 30.0,
+ 'amount_type': 'percent',
+ 'type_tax_use': 'none',
+ 'tax_group_id': cls.tax_group_2.id,
+ })
+
+ cls.purchase_tax_group = cls.env['account.tax'].create({
+ 'name': 'purchase_tax_group',
+ 'amount_type': 'group',
+ 'type_tax_use': 'purchase',
+ 'children_tax_ids': [Command.set((cls.none_tax_percentage_incl_2 + cls.none_tax_percentage_excl).ids)],
+ })
+
+ cls.move_purchase = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2016-01-01',
+ 'journal_id': cls.company_data['default_journal_purchase'].id,
+ 'line_ids': [
+ Command.create({
+ 'debit': 0.0,
+ 'credit': 3120.0,
+ 'account_id': cls.company_data['default_account_payable'].id,
+ }),
+ Command.create({
+ 'debit': 720.0,
+ 'credit': 0.0,
+ 'account_id': cls.tax_account_1.id,
+ 'tax_repartition_line_id': cls.none_tax_percentage_excl.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id,
+ }),
+ Command.create({
+ 'debit': 400.0,
+ 'credit': 0.0,
+ 'account_id': cls.tax_account_1.id,
+ 'tax_repartition_line_id': cls.none_tax_percentage_incl_2.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id,
+ 'tax_ids': [Command.set(cls.none_tax_percentage_excl.ids)]
+ }),
+ Command.create({
+ 'debit': 2000.0,
+ 'credit': 0.0,
+ 'account_id': cls.company_data['default_account_expense'].id,
+ 'tax_ids': [Command.set(cls.purchase_tax_group.ids)]
+ }),
+ ],
+ })
+ cls.move_purchase.action_post()
+
+ #Instantiate test data for fiscal_position option of the tax report (both for checking the report and VAT closing)
+
+ # Create a foreign partner
+ cls.test_fpos_foreign_partner = cls.env['res.partner'].create({
+ 'name': "Mare Cel",
+ 'country_id': cls.fiscal_country.id,
+ 'state_id': cls.country_state_2.id,
+ })
+
+ # Create a tax report and some taxes for it
+ cls.basic_tax_report = cls.env['account.report'].create({
+ 'name': "The Unseen Tax Report",
+ 'country_id': cls.fiscal_country.id,
+ 'root_report_id': cls.env.ref("account.generic_tax_report").id,
+ 'column_ids': [Command.create({'name': 'balance', 'sequence': 1, 'expression_label': 'balance',})],
+ })
+
+ cls.test_fpos_tax_sale = cls._add_basic_tax_for_report(
+ cls.basic_tax_report, 50, 'sale', cls.tax_group_1,
+ [(30, cls.tax_account_1, False), (70, cls.tax_account_1, True), (-100, cls.tax_account_2, True)]
+ )
+
+ cls.test_fpos_tax_purchase = cls._add_basic_tax_for_report(
+ cls.basic_tax_report, 50, 'purchase', cls.tax_group_2,
+ [(40, cls.tax_account_1, False), (60, cls.tax_account_1, True), (-100, cls.tax_account_2, True)]
+ )
+
+ # Create a fiscal_position to automatically map the default tax for partner "Mare Cel" to our test tax
+ cls.foreign_vat_fpos = cls.env['account.fiscal.position'].create({
+ 'name': "Test fpos",
+ 'auto_apply': True,
+ 'country_id': cls.fiscal_country.id,
+ 'state_ids': cls.country_state_2.ids,
+ 'foreign_vat': '12345',
+ })
+
+ # Create some domestic invoices (not all in the same closing period)
+ cls.init_invoice('out_invoice', partner=cls.partner_a, invoice_date='2020-12-22', post=True, amounts=[28000], taxes=cls.test_fpos_tax_sale)
+ cls.init_invoice('out_invoice', partner=cls.partner_a, invoice_date='2021-01-22', post=True, amounts=[200], taxes=cls.test_fpos_tax_sale)
+ cls.init_invoice('out_refund', partner=cls.partner_a, invoice_date='2021-01-12', post=True, amounts=[20], taxes=cls.test_fpos_tax_sale)
+ cls.init_invoice('in_invoice', partner=cls.partner_a, invoice_date='2021-03-12', post=True, amounts=[400], taxes=cls.test_fpos_tax_purchase)
+ cls.init_invoice('in_refund', partner=cls.partner_a, invoice_date='2021-03-20', post=True, amounts=[60], taxes=cls.test_fpos_tax_purchase)
+ cls.init_invoice('in_invoice', partner=cls.partner_a, invoice_date='2021-04-07', post=True, amounts=[42000], taxes=cls.test_fpos_tax_purchase)
+
+ # Create some foreign invoices (not all in the same closing period)
+ cls.init_invoice('out_invoice', partner=cls.test_fpos_foreign_partner, invoice_date='2020-12-13', post=True, amounts=[26000], taxes=cls.test_fpos_tax_sale)
+ cls.init_invoice('out_invoice', partner=cls.test_fpos_foreign_partner, invoice_date='2021-01-16', post=True, amounts=[800], taxes=cls.test_fpos_tax_sale)
+ cls.init_invoice('out_refund', partner=cls.test_fpos_foreign_partner, invoice_date='2021-01-30', post=True, amounts=[200], taxes=cls.test_fpos_tax_sale)
+ cls.init_invoice('in_invoice', partner=cls.test_fpos_foreign_partner, invoice_date='2021-02-01', post=True, amounts=[1000], taxes=cls.test_fpos_tax_purchase)
+ cls.init_invoice('in_refund', partner=cls.test_fpos_foreign_partner, invoice_date='2021-03-02', post=True, amounts=[600], taxes=cls.test_fpos_tax_purchase)
+ cls.init_invoice('in_refund', partner=cls.test_fpos_foreign_partner, invoice_date='2021-05-02', post=True, amounts=[10000], taxes=cls.test_fpos_tax_purchase)
+
+ @classmethod
+ def _instantiate_basic_test_tax_group(cls, company=None, country=None):
+ company = company or cls.env.company
+ vals = {
+ 'name': 'Test tax group',
+ 'company_id': company.id,
+ 'tax_receivable_account_id': cls.company_data['default_tax_account_receivable'].sudo().copy({'company_ids': company.ids}).id,
+ 'tax_payable_account_id': cls.company_data['default_tax_account_payable'].sudo().copy({'company_ids': company.ids}).id,
+ }
+ if country:
+ vals['country_id'] = country.id
+ return cls.env['account.tax.group'].sudo().create(vals)
+
+ @classmethod
+ def _add_basic_tax_for_report(cls, tax_report, percentage, type_tax_use, tax_group, tax_repartition, company=None):
+ """ Creates a basic test tax, as well as tax report lines and tags, connecting them all together.
+
+ A tax report line will be created within tax report for each of the elements in tax_repartition,
+ for both invoice and refund, so that the resulting repartition lines each reference their corresponding
+ report line. Negative tags will be assign for refund lines; postive tags for invoice ones.
+
+ :param tax_report: The report to create lines for.
+ :param percentage: The created tax has amoun_type='percent'. This parameter contains its amount.
+ :param type_tax_use: type_tax_use of the tax to create
+ :param tax_repartition: List of tuples in the form [(factor_percent, account, use_in_tax_closing)], one tuple
+ for each tax repartition line to create (base lines will be automatically created).
+ """
+ tax = cls.env['account.tax'].create({
+ 'name': f"{type_tax_use} - {percentage} - {tax_report.name}",
+ 'amount': percentage,
+ 'amount_type': 'percent',
+ 'type_tax_use': type_tax_use,
+ 'tax_group_id': tax_group.id,
+ 'country_id': tax_report.country_id.id,
+ 'company_id': company.id if company else cls.env.company.id,
+ })
+
+ to_write = {}
+ for move_type_suffix in ('invoice', 'refund'):
+ sign = "-" if move_type_suffix == 'refund' else "+"
+ report_line_sequence = tax_report.line_ids[-1].sequence + 1 if tax_report.line_ids else 0
+
+
+ # Create a report line for the base
+ base_report_line_name = f"{tax.id}-{move_type_suffix}-base"
+ base_report_line = cls._create_tax_report_line(base_report_line_name, tax_report, tag_name=base_report_line_name, sequence=report_line_sequence)
+ report_line_sequence += 1
+
+ base_tag = base_report_line.expression_ids._get_matching_tags(sign)
+
+ repartition_vals = [
+ Command.clear(),
+ Command.create({'repartition_type': 'base', 'tag_ids': base_tag.ids}),
+ ]
+
+ for (factor_percent, account, use_in_tax_closing) in tax_repartition:
+ # Create a report line for the repartition line
+ tax_report_line_name = f"{tax.id}-{move_type_suffix}-{factor_percent}"
+ tax_report_line = cls._create_tax_report_line(tax_report_line_name, tax_report, tag_name=tax_report_line_name, sequence=report_line_sequence)
+ report_line_sequence += 1
+
+ tax_tag = tax_report_line.expression_ids._get_matching_tags(sign)
+
+ repartition_vals.append(Command.create({
+ 'account_id': account.id if account else None,
+ 'factor_percent': factor_percent,
+ 'use_in_tax_closing': use_in_tax_closing,
+ 'tag_ids': tax_tag.ids,
+ }))
+
+ to_write[f"{move_type_suffix}_repartition_line_ids"] = repartition_vals
+
+ tax.write(to_write)
+
+ return tax
+
+ def _assert_vat_closing(self, report, options, closing_vals_by_fpos):
+ """ Checks the result of the VAT closing
+
+ :param options: the tax report options to make the closing for
+ :param closing_vals_by_fpos: A list of dict(fiscal_position: [dict(line_vals)], where fiscal_position is (possibly empty)
+ account.fiscal.position record, and line_vals, the expected values for each closing move lines.
+ In case the option 'companies' contains more than 1 company, a tuple (company, fiscal_position)
+ replaces the fiscal_position key
+ """
+ with patch.object(type(self.env['account.move']), '_get_vat_report_attachments', autospec=True, side_effect=lambda *args, **kwargs: []):
+ vat_closing_moves = self.env['account.generic.tax.report.handler']._generate_tax_closing_entries(report, options)
+
+ if len(options['companies']) > 1:
+ closing_moves_by_fpos = {(move.company_id, move.fiscal_position_id): move for move in vat_closing_moves}
+ else:
+ closing_moves_by_fpos = {move.fiscal_position_id: move for move in vat_closing_moves}
+
+ for key, closing_vals in closing_vals_by_fpos.items():
+ vat_closing_move = closing_moves_by_fpos[key]
+ self.assertRecordValues(vat_closing_move.line_ids, closing_vals)
+ self.assertEqual(len(closing_vals_by_fpos), len(vat_closing_moves), "Exactly one move should have been generated per fiscal position; nothing else.")
+
+ def test_vat_closing_single_fpos(self):
+ """ Tests the VAT closing when a foreign VAT fiscal position is selected on the tax report
+ """
+ options = self._generate_options(
+ self.basic_tax_report, fields.Date.from_string('2021-01-15'), fields.Date.from_string('2021-02-01'),
+ {'fiscal_position': self.foreign_vat_fpos.id}
+ )
+
+ self._assert_vat_closing(self.basic_tax_report, options, {
+ self.foreign_vat_fpos: [
+ # sales: 800 * 0.5 * 0.7 - 200 * 0.5 * 0.7
+ {'debit': 210, 'credit': 0.0, 'account_id': self.tax_account_1.id},
+ # sales: 800 * 0.5 * -1 - 200 * 0.5 * -1
+ {'debit': 0, 'credit': 300, 'account_id': self.tax_account_2.id},
+ # purchases: 1000 * 0.5 * 0.6 - 600 * 0.5 * 0.6
+ {'debit': 0, 'credit': 120, 'account_id': self.tax_account_1.id},
+ # purchases: 1000 * 0.5 * -1 - 600 * 0.5 * -1
+ {'debit': 200, 'credit': 0, 'account_id': self.tax_account_2.id},
+ # For sales operations
+ {'debit': 90, 'credit': 0, 'account_id': self.tax_group_1.tax_receivable_account_id.id},
+ # For purchase operations
+ {'debit': 0, 'credit': 80, 'account_id': self.tax_group_2.tax_payable_account_id.id},
+ ]
+ })
+
+ def test_vat_closing_moves_with_lock_date(self):
+ """
+ Check that we are still able to create a tax closing event if the lock date is set as the closing move
+ is not dependent of the lock date.
+ This also ensures that if we try to close again this period with a move already posted we get the same
+ """
+ self.env.company.tax_lock_date = fields.Date.from_string('2021-12-31')
+
+ options = self._generate_options(
+ self.basic_tax_report, '2021-01-15', '2021-02-01',
+ {'fiscal_position': 'domestic'}
+ )
+
+ action = self.env['account.tax.report.handler'].with_context({'override_tax_closing_warning': True}).action_periodic_vat_entries(options)
+ move = self.env['account.move'].browse(action['res_id'])
+ with patch.object(self.registry['account.move'], '_get_vat_report_attachments', autospec=True, side_effect=lambda *args, **kwargs: []):
+ move.action_post()
+
+ action = self.env['account.tax.report.handler'].with_context({'override_tax_closing_warning': True}).action_periodic_vat_entries(options)
+ same_move = self.env['account.move'].browse(action['res_id'])
+ self.assertEqual(move.id, same_move.id)
+
+ def test_vat_closing_domestic(self):
+ """ Tests the VAT closing when a foreign VAT fiscal position is selected on the tax report
+ """
+ options = self._generate_options(
+ self.basic_tax_report, fields.Date.from_string('2021-01-15'), fields.Date.from_string('2021-02-01'),
+ {'fiscal_position': 'domestic'}
+ )
+
+ self._assert_vat_closing(self.basic_tax_report, options, {
+ self.env['account.fiscal.position']: [
+ # sales: 200 * 0.5 * 0.7 - 20 * 0.5 * 0.7
+ {'debit': 63, 'credit': 0.0, 'account_id': self.tax_account_1.id},
+ # sales: 200 * 0.5 * -1 - 20 * 0.5 * -1
+ {'debit': 0, 'credit': 90, 'account_id': self.tax_account_2.id},
+ # purchases: 400 * 0.5 * 0.6 - 60 * 0.5 * 0.6
+ {'debit': 0, 'credit': 102, 'account_id': self.tax_account_1.id},
+ # purchases: 400 * 0.5 * - 60 * 0.5
+ {'debit': 170, 'credit': 0, 'account_id': self.tax_account_2.id},
+ # For sales operations
+ {'debit': 27, 'credit': 0, 'account_id': self.tax_group_1.tax_receivable_account_id.id},
+ # For purchase operations
+ {'debit': 0, 'credit': 68, 'account_id': self.tax_group_2.tax_payable_account_id.id},
+ ]
+ })
+
+ def test_vat_closing_everything(self):
+ """ Tests the VAT closing when the option to show all foreign VAT fiscal positions is activated.
+ One closing move should then be generated per fiscal position.
+ """
+ options = self._generate_options(
+ self.basic_tax_report, fields.Date.from_string('2021-01-15'), fields.Date.from_string('2021-02-01'),
+ {'fiscal_position': 'all'}
+ )
+
+ self._assert_vat_closing(self.basic_tax_report, options, {
+ # From test_vat_closing_domestic
+ self.env['account.fiscal.position']: [
+ # sales: 200 * 0.5 * 0.7 - 20 * 0.5 * 0.7
+ {'debit': 63, 'credit': 0.0, 'account_id': self.tax_account_1.id},
+ # sales: 200 * 0.5 * -1 - 20 * 0.5 * -1
+ {'debit': 0, 'credit': 90, 'account_id': self.tax_account_2.id},
+ # purchases: 400 * 0.5 * 0.6 - 60 * 0.5 * 0.6
+ {'debit': 0, 'credit': 102, 'account_id': self.tax_account_1.id},
+ # purchases: 400 * 0.5 * -1 - 60 * 0.5 * -1
+ {'debit': 170, 'credit': 0, 'account_id': self.tax_account_2.id},
+ # For sales operations
+ {'debit': 27, 'credit': 0, 'account_id': self.tax_group_1.tax_receivable_account_id.id},
+ # For purchase operations
+ {'debit': 0, 'credit': 68, 'account_id': self.tax_group_2.tax_payable_account_id.id},
+ ],
+
+ # From test_vat_closing_single_fpos
+ self.foreign_vat_fpos: [
+ # sales: 800 * 0.5 * 0.7 - 200 * 0.5 * 0.7
+ {'debit': 210, 'credit': 0.0, 'account_id': self.tax_account_1.id},
+ # sales: 800 * 0.5 * -1 - 200 * 0.5 * -1
+ {'debit': 0, 'credit': 300, 'account_id': self.tax_account_2.id},
+ # purchases: 1000 * 0.5 * 0.6 - 600 * 0.5 * 0.6
+ {'debit': 0, 'credit': 120, 'account_id': self.tax_account_1.id},
+ # purchases: 1000 * 0.5 * -1 - 600 * 0.5 * -1
+ {'debit': 200, 'credit': 0, 'account_id': self.tax_account_2.id},
+ # For sales operations
+ {'debit': 90, 'credit': 0, 'account_id': self.tax_group_1.tax_receivable_account_id.id},
+ # For purchase operations
+ {'debit': 0, 'credit': 80, 'account_id': self.tax_group_2.tax_payable_account_id.id},
+ ],
+ })
+
+ def test_vat_closing_generic(self):
+ """ VAT closing for the generic report should create one closing move per fiscal position + a domestic one.
+ One closing move should then be generated per fiscal position.
+ """
+ for generic_report_xml_id in ('account.generic_tax_report', 'account.generic_tax_report_account_tax', 'account.generic_tax_report_tax_account'):
+ generic_report = self.env.ref(generic_report_xml_id)
+ options = self._generate_options(generic_report, fields.Date.from_string('2021-01-15'), fields.Date.from_string('2021-02-01'))
+
+ self._assert_vat_closing(generic_report, options, {
+ # From test_vat_closing_domestic
+ self.env['account.fiscal.position']: [
+ # sales: 200 * 0.5 * 0.7 - 20 * 0.5 * 0.7
+ {'debit': 63, 'credit': 0.0, 'account_id': self.tax_account_1.id},
+ # sales: 200 * 0.5 * -1 - 20 * 0.5 * -1
+ {'debit': 0, 'credit': 90, 'account_id': self.tax_account_2.id},
+ # purchases: 400 * 0.5 * 0.6 - 60 * 0.5 * 0.6
+ {'debit': 0, 'credit': 102, 'account_id': self.tax_account_1.id},
+ # purchases: 400 * 0.5 * -1 - 60 * 0.5 * -1
+ {'debit': 170, 'credit': 0, 'account_id': self.tax_account_2.id},
+ # For sales operations
+ {'debit': 27, 'credit': 0, 'account_id': self.tax_group_1.tax_receivable_account_id.id},
+ # For purchase operations
+ {'debit': 0, 'credit': 68, 'account_id': self.tax_group_2.tax_payable_account_id.id},
+ ],
+
+ # From test_vat_closing_single_fpos
+ self.foreign_vat_fpos: [
+ # sales: 800 * 0.5 * 0.7 - 200 * 0.5 * 0.7
+ {'debit': 210, 'credit': 0.0, 'account_id': self.tax_account_1.id},
+ # sales: 800 * 0.5 * -1 - 200 * 0.5 * -1
+ {'debit': 0, 'credit': 300, 'account_id': self.tax_account_2.id},
+ # purchases: 1000 * 0.5 * 0.6 - 600 * 0.5 * 0.6
+ {'debit': 0, 'credit': 120, 'account_id': self.tax_account_1.id},
+ # purchases: 1000 * 0.5 * -1 - 600 * 0.5 * -1
+ {'debit': 200, 'credit': 0, 'account_id': self.tax_account_2.id},
+ # For sales operations
+ {'debit': 90, 'credit': 0, 'account_id': self.tax_group_1.tax_receivable_account_id.id},
+ # For purchase operations
+ {'debit': 0, 'credit': 80, 'account_id': self.tax_group_2.tax_payable_account_id.id},
+ ],
+ })
+
+ def test_vat_closing_button_availability(self):
+ def assertTaxClosingAvailable(is_enabled, active_companies, export_main_company=None):
+ options = tax_report.with_context(allowed_company_ids=active_companies.ids).get_options({})
+ closing_button_dict = next(filter(lambda x: x['action'] == 'action_periodic_vat_entries', options['buttons']))
+ self.assertEqual(closing_button_dict.get('error_action'), None if is_enabled else 'show_error_branch_allowed')
+ if is_enabled:
+ self.assertEqual(tax_report._get_sender_company_for_export(options), export_main_company)
+
+ tax_report = self.env.ref('account.generic_tax_report')
+
+ main_company = self.company_data['company']
+ main_company.vat = '123'
+ branch_1 = self.env['res.company'].create({'name': "Branch 1", 'parent_id': main_company.id})
+ branch_1_1 = self.env['res.company'].create({'name': "Branch 1 sub-branch 1", 'parent_id': branch_1.id})
+ branch_2 = self.env['res.company'].create({'name': "Branch 2", 'parent_id': main_company.id, 'vat': '456'})
+ branch_2_1 = self.env['res.company'].create({'name': "Branch 2 sub-branch 1", 'parent_id': branch_2.id})
+
+ assertTaxClosingAvailable(False, main_company)
+ assertTaxClosingAvailable(True, main_company + branch_1 + branch_1_1 + branch_2 + branch_2_1, export_main_company=main_company)
+ assertTaxClosingAvailable(True, branch_2 + branch_2_1 + main_company + branch_1 + branch_1_1, export_main_company=branch_2)
+ assertTaxClosingAvailable(True, main_company + branch_1 + branch_1_1, export_main_company=main_company)
+ assertTaxClosingAvailable(False, main_company + branch_1)
+ assertTaxClosingAvailable(False, branch_1 + branch_1_1)
+ assertTaxClosingAvailable(True, branch_1 + main_company + branch_1_1, export_main_company=main_company)
+ assertTaxClosingAvailable(True, branch_2 + main_company + branch_2_1, export_main_company=branch_2)
+ assertTaxClosingAvailable(False, branch_2_1)
+
+ def test_tax_report_fpos_domestic(self):
+ """ Test tax report's content for 'domestic' foreign VAT fiscal position option.
+ """
+ options = self._generate_options(
+ self.basic_tax_report, fields.Date.from_string('2021-01-01'), fields.Date.from_string('2021-03-31'),
+ {'fiscal_position': 'domestic'}
+ )
+ self.assertLinesValues(
+ self.basic_tax_report._get_lines(options),
+ # Name Balance
+ [0, 1],
+ [
+ # out_invoice
+ (f'{self.test_fpos_tax_sale.id}-invoice-base', 200),
+ (f'{self.test_fpos_tax_sale.id}-invoice-30', 30),
+ (f'{self.test_fpos_tax_sale.id}-invoice-70', 70),
+ (f'{self.test_fpos_tax_sale.id}-invoice--100', -100),
+
+ # out_refund
+ (f'{self.test_fpos_tax_sale.id}-refund-base', -20),
+ (f'{self.test_fpos_tax_sale.id}-refund-30', -3),
+ (f'{self.test_fpos_tax_sale.id}-refund-70', -7),
+ (f'{self.test_fpos_tax_sale.id}-refund--100', 10),
+
+ # in_invoice
+ (f'{self.test_fpos_tax_purchase.id}-invoice-base', 400),
+ (f'{self.test_fpos_tax_purchase.id}-invoice-40', 80),
+ (f'{self.test_fpos_tax_purchase.id}-invoice-60', 120),
+ (f'{self.test_fpos_tax_purchase.id}-invoice--100', -200),
+
+ # in_refund
+ (f'{self.test_fpos_tax_purchase.id}-refund-base', -60),
+ (f'{self.test_fpos_tax_purchase.id}-refund-40', -12),
+ (f'{self.test_fpos_tax_purchase.id}-refund-60', -18),
+ (f'{self.test_fpos_tax_purchase.id}-refund--100', 30),
+ ],
+ options,
+ )
+
+ def test_tax_report_fpos_foreign(self):
+ """ Test tax report's content with a foreign VAT fiscal position.
+ """
+ options = self._generate_options(
+ self.basic_tax_report, fields.Date.from_string('2021-01-01'), fields.Date.from_string('2021-03-31'),
+ {'fiscal_position': self.foreign_vat_fpos.id}
+ )
+ self.assertLinesValues(
+ self.basic_tax_report._get_lines(options),
+ # Name Balance
+ [0, 1],
+ [
+ # out_invoice
+ (f'{self.test_fpos_tax_sale.id}-invoice-base', 800),
+ (f'{self.test_fpos_tax_sale.id}-invoice-30', 120),
+ (f'{self.test_fpos_tax_sale.id}-invoice-70', 280),
+ (f'{self.test_fpos_tax_sale.id}-invoice--100', -400),
+
+ # out_refund
+ (f'{self.test_fpos_tax_sale.id}-refund-base', -200),
+ (f'{self.test_fpos_tax_sale.id}-refund-30', -30),
+ (f'{self.test_fpos_tax_sale.id}-refund-70', -70),
+ (f'{self.test_fpos_tax_sale.id}-refund--100', 100),
+
+ # in_invoice
+ (f'{self.test_fpos_tax_purchase.id}-invoice-base', 1000),
+ (f'{self.test_fpos_tax_purchase.id}-invoice-40', 200),
+ (f'{self.test_fpos_tax_purchase.id}-invoice-60', 300),
+ (f'{self.test_fpos_tax_purchase.id}-invoice--100', -500),
+
+ # in_refund
+ (f'{self.test_fpos_tax_purchase.id}-refund-base', -600),
+ (f'{self.test_fpos_tax_purchase.id}-refund-40', -120),
+ (f'{self.test_fpos_tax_purchase.id}-refund-60', -180),
+ (f'{self.test_fpos_tax_purchase.id}-refund--100', 300),
+ ],
+ options,
+ )
+
+ def test_tax_report_fpos_everything(self):
+ """ Test tax report's content for 'all' foreign VAT fiscal position option.
+ """
+ options = self._generate_options(
+ self.basic_tax_report, fields.Date.from_string('2021-01-01'), fields.Date.from_string('2021-03-31'),
+ {'fiscal_position': 'all'}
+ )
+ self.assertLinesValues(
+ self.basic_tax_report._get_lines(options),
+ # Name Balance
+ [0, 1],
+ [
+ # out_invoice
+ (f'{self.test_fpos_tax_sale.id}-invoice-base', 1000),
+ (f'{self.test_fpos_tax_sale.id}-invoice-30', 150),
+ (f'{self.test_fpos_tax_sale.id}-invoice-70', 350),
+ (f'{self.test_fpos_tax_sale.id}-invoice--100', -500),
+
+ # out_refund
+ (f'{self.test_fpos_tax_sale.id}-refund-base', -220),
+ (f'{self.test_fpos_tax_sale.id}-refund-30', -33),
+ (f'{self.test_fpos_tax_sale.id}-refund-70', -77),
+ (f'{self.test_fpos_tax_sale.id}-refund--100', 110),
+
+ # in_invoice
+ (f'{self.test_fpos_tax_purchase.id}-invoice-base', 1400),
+ (f'{self.test_fpos_tax_purchase.id}-invoice-40', 280),
+ (f'{self.test_fpos_tax_purchase.id}-invoice-60', 420),
+ (f'{self.test_fpos_tax_purchase.id}-invoice--100', -700),
+
+ # in_refund
+ (f'{self.test_fpos_tax_purchase.id}-refund-base', -660),
+ (f'{self.test_fpos_tax_purchase.id}-refund-40', -132),
+ (f'{self.test_fpos_tax_purchase.id}-refund-60', -198),
+ (f'{self.test_fpos_tax_purchase.id}-refund--100', 330),
+ ],
+ options,
+ )
+
+ def test_tax_report_single_fpos(self):
+ """ When opening the tax report from a foreign country for which there exists only one
+ foreing VAT fiscal position, this fiscal position should be selected by default in the
+ report's options.
+ """
+ new_tax_report = self.env['account.report'].create({
+ 'name': "",
+ 'country_id': self.foreign_country.id,
+ 'root_report_id': self.env.ref("account.generic_tax_report").id,
+ 'column_ids': [Command.create({'name': 'balance', 'sequence': 1, 'expression_label': 'balance'})]
+ })
+ foreign_vat_fpos = self.env['account.fiscal.position'].create({
+ 'name': "Test fpos",
+ 'country_id': self.foreign_country.id,
+ 'foreign_vat': '422211',
+ })
+ options = self._generate_options(new_tax_report, fields.Date.from_string('2021-01-01'), fields.Date.from_string('2021-03-31'))
+ self.assertEqual(options['fiscal_position'], foreign_vat_fpos.id, "When only one VAT fiscal position is available for a non-domestic country, it should be chosen by default")
+
+ def test_tax_report_grid(self):
+ company = self.company_data['company']
+
+ # We generate a tax report with the following layout
+ #/Base
+ # - Base 42%
+ # - Base 11%
+ #/Tax
+ # - Tax 42%
+ # - 10.5%
+ # - 31.5%
+ # - Tax 11%
+ #/Tax difference (42% - 11%)
+
+ tax_report = self.env['account.report'].create({
+ 'name': 'Test',
+ 'country_id': company.account_fiscal_country_id.id,
+ 'root_report_id': self.env.ref("account.generic_tax_report").id,
+ 'column_ids': [Command.create({'name': 'balance', 'sequence': 1, 'expression_label': 'balance'})]
+ })
+
+ # We create the lines in a different order from the one they have in report,
+ # so that we ensure sequence is taken into account properly when rendering the report
+ tax_section = self._create_tax_report_line('Tax', tax_report, sequence=4, formula="tax_42.balance + tax_11.balance + tax_neg_100.balance")
+ base_section = self._create_tax_report_line('Base', tax_report, sequence=1, formula="base_11.balance + base_42.balance")
+ base_42_line = self._create_tax_report_line('Base 42%', tax_report, sequence=2, parent_line=base_section, code='base_42', tag_name='base_42')
+ base_11_line = self._create_tax_report_line('Base 11%', tax_report, sequence=3, parent_line=base_section, code='base_11', tag_name='base_11')
+ tax_42_section = self._create_tax_report_line('Tax 42%', tax_report, sequence=5, parent_line=tax_section, code='tax_42', formula='tax_31_5.balance + tax_10_5.balance')
+ tax_31_5_line = self._create_tax_report_line('Tax 31.5%', tax_report, sequence=7, parent_line=tax_42_section, code='tax_31_5', tag_name='tax_31_5')
+ tax_10_5_line = self._create_tax_report_line('Tax 10.5%', tax_report, sequence=6, parent_line=tax_42_section, code='tax_10_5', tag_name='tax_10_5')
+ tax_11_line = self._create_tax_report_line('Tax 11%', tax_report, sequence=8, parent_line=tax_section, code='tax_11', tag_name='tax_11')
+ tax_neg_100_line = self._create_tax_report_line('Tax -100%', tax_report, sequence=9, parent_line=tax_section, code='tax_neg_100', tag_name='tax_neg_100')
+ self._create_tax_report_line('Tax difference (42%-11%)', tax_report, sequence=10, formula='tax_42.balance - tax_11.balance')
+
+ # Create two taxes linked to report lines
+ tax_11 = self.env['account.tax'].create({
+ 'name': 'Impôt sur les revenus',
+ 'amount': 11,
+ 'amount_type': 'percent',
+ 'type_tax_use': 'sale',
+ 'invoice_repartition_line_ids': [
+ Command.create({
+ 'repartition_type': 'base',
+ 'tag_ids': self._get_tag_ids("+", base_11_line.expression_ids),
+ }),
+ Command.create({
+ 'repartition_type': 'tax',
+ 'tag_ids': self._get_tag_ids("+", tax_11_line.expression_ids),
+ }),
+ ],
+ 'refund_repartition_line_ids': [
+ Command.create({
+ 'repartition_type': 'base',
+ 'tag_ids': self._get_tag_ids("-", base_11_line.expression_ids),
+ }),
+ Command.create({
+ 'repartition_type': 'tax',
+ 'tag_ids': self._get_tag_ids("-", tax_11_line.expression_ids),
+ }),
+ ],
+ })
+
+ tax_42 = self.env['account.tax'].create({
+ 'name': 'Impôt sur les revenants',
+ 'amount': 42,
+ 'amount_type': 'percent',
+ 'type_tax_use': 'sale',
+ 'invoice_repartition_line_ids': [
+ Command.create({
+ 'repartition_type': 'base',
+ 'tag_ids': self._get_tag_ids("+", base_42_line.expression_ids),
+ }),
+
+ Command.create({
+ 'factor_percent': 25,
+ 'repartition_type': 'tax',
+ 'tag_ids': self._get_tag_ids("+", tax_10_5_line.expression_ids),
+ }),
+
+ Command.create({
+ 'factor_percent': 75,
+ 'repartition_type': 'tax',
+ 'tag_ids': self._get_tag_ids("+", tax_31_5_line.expression_ids),
+ }),
+
+ Command.create({
+ 'factor_percent': -100,
+ 'repartition_type': 'tax',
+ 'tag_ids': self._get_tag_ids("-", tax_neg_100_line.expression_ids),
+ }),
+ ],
+ 'refund_repartition_line_ids': [
+ Command.create({
+ 'repartition_type': 'base',
+ 'tag_ids': self._get_tag_ids("-", base_42_line.expression_ids),
+ }),
+
+ Command.create({
+ 'factor_percent': 25,
+ 'repartition_type': 'tax',
+ 'tag_ids': self._get_tag_ids("-", tax_10_5_line.expression_ids),
+ }),
+
+ Command.create({
+ 'factor_percent': 75,
+ 'repartition_type': 'tax',
+ 'tag_ids': self._get_tag_ids("-", tax_31_5_line.expression_ids),
+ }),
+
+ Command.create({
+ 'factor_percent': -100,
+ 'repartition_type': 'tax',
+ 'tag_ids': self._get_tag_ids("+", tax_neg_100_line.expression_ids),
+ }),
+ ],
+ })
+
+ # Create an invoice using the tax we just made
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [Command.create({
+ 'name': 'Turlututu',
+ 'price_unit': 100.0,
+ 'quantity': 1,
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'tax_ids': [Command.set((tax_11 + tax_42).ids)],
+ })],
+ })
+ invoice.action_post()
+
+ # Generate the report and check the results
+ report = tax_report
+ options = self._generate_options(report, invoice.date, invoice.date)
+
+ # Invalidate the cache to ensure the lines will be fetched in the right order.
+ self.env.invalidate_all()
+
+ lines = report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Balance
+ [ 0, 1 ],
+ [
+ ('Base', 200),
+ ('Base 42%', 100),
+ ('Base 11%', 100),
+ ('Total Base', 200),
+
+ ('Tax', 95),
+ ('Tax 42%', 42),
+ ('Tax 10.5%', 10.5),
+ ('Tax 31.5%', 31.5),
+ ('Total Tax 42%', 42),
+
+ ('Tax 11%', 11),
+ ('Tax -100%', 42),
+ ('Total Tax', 95),
+
+ ('Tax difference (42%-11%)', 31),
+ ],
+ options,
+ )
+
+ # We refund the invoice
+ refund_wizard = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=invoice.ids).create({
+ 'reason': 'Test refund tax repartition',
+ 'journal_id': invoice.journal_id.id,
+ 'date': invoice.date,
+ })
+ refund_wizard.modify_moves()
+
+ # We check the taxes on refund have impacted the report properly (everything should be 0)
+ self.assertLinesValues(
+ report._get_lines(options),
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Base', 0.0),
+ ('Base 42%', 0.0),
+ ('Base 11%', 0.0),
+ ('Total Base', 0.0),
+
+ ('Tax', 0.0),
+ ('Tax 42%', 0.0),
+ ('Tax 10.5%', 0.0),
+ ('Tax 31.5%', 0.0),
+ ('Total Tax 42%', 0.0),
+
+ ('Tax 11%', 0.0),
+ ('Tax -100%', 0.0),
+ ('Total Tax', 0.0),
+
+ ('Tax difference (42%-11%)', 0.0),
+ ],
+ options,
+ )
+
+ def _create_caba_taxes_for_report_lines(self, report_lines_dict, company):
+ """ Creates cash basis taxes with a specific test repartition and maps them to
+ the provided tax_report lines.
+
+ :param report_lines_dict: A dictionnary mapping tax_type_use values to
+ tax report lines records
+ :param company: The company to create the test tags for
+
+ :return: The created account.tax objects
+ """
+ return self.env['account.tax'].create([
+ {
+ 'name': 'Impôt sur tout ce qui bouge',
+ 'amount': '20',
+ 'amount_type': 'percent',
+ 'type_tax_use': tax_type,
+ 'tax_exigibility': 'on_payment',
+ 'invoice_repartition_line_ids': [
+ Command.create({
+ 'repartition_type': 'base',
+ 'tag_ids': self._get_tag_ids("+", report_line.expression_ids),
+ }),
+ Command.create({
+ 'factor_percent': 25,
+ 'repartition_type': 'tax',
+ 'tag_ids': self._get_tag_ids("+", report_line.expression_ids),
+ }),
+ Command.create({
+ 'factor_percent': 75,
+ 'repartition_type': 'tax',
+ 'tag_ids': self._get_tag_ids("+", report_line.expression_ids),
+ }),
+ ],
+ 'refund_repartition_line_ids': [
+ Command.create({
+ 'repartition_type': 'base',
+ 'tag_ids': self._get_tag_ids("-", report_line.expression_ids),
+ }),
+ Command.create({
+ 'factor_percent': 25,
+ 'repartition_type': 'tax',
+ 'tag_ids': self._get_tag_ids("-", report_line.expression_ids),
+ }),
+ Command.create({
+ 'factor_percent': 75,
+ 'repartition_type': 'tax',
+ }),
+ ],
+ }
+ for tax_type, report_line in report_lines_dict.items()
+ ])
+
+ def _create_taxes_for_report_lines(self, report_lines_dict, company):
+ return self.env['account.tax'].create([
+ {
+ 'name': 'Impôt sur tout ce qui bouge',
+ 'amount': '20',
+ 'amount_type': 'percent',
+ 'type_tax_use': tax_type,
+ 'invoice_repartition_line_ids': [
+ Command.create({
+ 'repartition_type': 'base',
+ 'tag_ids': self._get_tag_ids("+", report_line[0].expression_ids),
+ }),
+ Command.create({
+ 'repartition_type': 'tax',
+ 'tag_ids': self._get_tag_ids("+", report_line[1].expression_ids),
+ }),
+ ],
+ 'refund_repartition_line_ids': [
+ Command.create({
+ 'repartition_type': 'base',
+ 'tag_ids': self._get_tag_ids("+", report_line[0].expression_ids),
+ }),
+ Command.create({
+ 'repartition_type': 'tax',
+ 'tag_ids': self._get_tag_ids("+", report_line[1].expression_ids),
+ }),
+ ],
+ }
+ for tax_type, report_line in report_lines_dict.items()
+ ])
+
+
+ def _run_caba_generic_test(self, expected_columns, expected_lines, on_invoice_created=None, on_all_invoices_created=None, invoice_generator=None):
+ """ Generic test function called by several cash basis tests.
+
+ This function creates a new sale and purchase tax, each associated with
+ a new tax report line using _create_caba_taxes_for_report_lines.
+ It then creates an invoice AND a refund for each of these tax, and finally
+ compare the tax report to the expected values, passed in parameters.
+
+ Since _create_caba_taxes_for_report_lines creates asymmetric taxes (their 75%
+ repartition line does not impact the report line at refund), we can be sure this
+ function helper gives a complete coverage, and does not shadow any result due, for
+ example, to some undesired swapping between debit and credit.
+
+ :param expected_columns: The columns we want the final tax report to contain
+
+ :param expected_lines: The lines we want the final tax report to contain
+
+ :param on_invoice_created: A function to be called when a single invoice has
+ just been created, taking the invoice as a parameter
+ (This can be used to reconcile the invoice with something, for example)
+
+ :param on_all_invoices_created: A function to be called when all the invoices corresponding
+ to a tax type have been created, taking the
+ recordset of all these invoices as a parameter
+ (Use it to reconcile invoice and credit note together, for example)
+
+ :param invoice_generator: A function used to generate an invoice. A default
+ one is called if none is provided, creating
+ an invoice with a single line amounting to 100,
+ with the provided tax set on it.
+ """
+ def default_invoice_generator(inv_type, partner, account, date, tax):
+ return self.env['account.move'].create({
+ 'move_type': inv_type,
+ 'partner_id': partner.id,
+ 'invoice_date': date,
+ 'invoice_line_ids': [Command.create({
+ 'name': 'test',
+ 'quantity': 1,
+ 'account_id': account.id,
+ 'price_unit': 100,
+ 'tax_ids': [Command.set(tax.ids)],
+ })],
+ })
+
+ today = fields.Date.today()
+
+ company = self.company_data['company']
+ company.tax_exigibility = True
+ partner = self.env['res.partner'].create({'name': 'Char Aznable'})
+
+ # Create a tax report
+ tax_report = self.env['account.report'].create({
+ 'name': 'Test',
+ 'country_id': self.fiscal_country.id,
+ 'root_report_id': self.env.ref("account.generic_tax_report").id,
+ 'column_ids': [Command.create({'name': 'balance', 'sequence': 1, 'expression_label': 'balance'})]
+ })
+
+ # We create some report lines
+ report_lines_dict = {
+ 'sale': self._create_tax_report_line('Sale', tax_report, sequence=1, tag_name='sale'),
+ 'purchase': self._create_tax_report_line('Purchase', tax_report, sequence=2, tag_name='purchase'),
+ }
+
+ # We create a sale and a purchase tax, linked to our report lines' tags
+ taxes = self._create_caba_taxes_for_report_lines(report_lines_dict, company)
+
+
+ # Create invoice and refund using the tax we just made
+ invoice_types = {
+ 'sale': ('out_invoice', 'out_refund'),
+ 'purchase': ('in_invoice', 'in_refund')
+ }
+
+ account_types = {
+ 'sale': 'income',
+ 'purchase': 'expense',
+ }
+ for tax in taxes:
+ invoices = self.env['account.move']
+ account = self.env['account.account'].search([('company_ids', '=', company.id), ('account_type', '=', account_types[tax.type_tax_use])], limit=1)
+ for inv_type in invoice_types[tax.type_tax_use]:
+ invoice = (invoice_generator or default_invoice_generator)(inv_type, partner, account, today, tax)
+ invoice.action_post()
+ invoices += invoice
+
+ if on_invoice_created:
+ on_invoice_created(invoice)
+
+ if on_all_invoices_created:
+ on_all_invoices_created(invoices)
+
+ # Generate the report and check the results
+ # We check the taxes on invoice have impacted the report properly
+ options = self._generate_options(tax_report, date_from=today, date_to=today)
+ inv_report_lines = tax_report._get_lines(options)
+ self.assertLinesValues(inv_report_lines, expected_columns, expected_lines, options)
+
+ def _register_full_payment_for_invoice(self, invoice):
+ """ Fully pay the invoice, so that the cash basis entries are created
+ """
+ self.env['account.payment.register'].with_context(active_ids=invoice.ids, active_model='account.move').create({
+ 'payment_date': invoice.date,
+ })._create_payments()
+
+ @freeze_time('2023-10-05 02:00:00')
+ def test_tax_report_grid_cash_basis(self):
+ """ Cash basis moves create for taxes based on payments are handled differently
+ by the report; we want to ensure their sign is managed properly.
+ """
+ # 100 (base, invoice) - 100 (base, refund) + 20 (tax, invoice) - 5 (25% tax, refund) = 15
+ self._run_caba_generic_test(
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Sale', 15),
+ ('Purchase', 15),
+ ],
+ on_invoice_created=self._register_full_payment_for_invoice
+ )
+
+ @freeze_time('2023-10-05 02:00:00')
+ def test_tax_report_grid_cash_basis_refund(self):
+ """ Cash basis moves create for taxes based on payments are handled differently
+ by the report; we want to ensure their sign is managed properly. This
+ test runs the case where an invoice is reconciled with a refund (created
+ separetely, so not cancelling it).
+ """
+ def reconcile_opposite_types(invoices):
+ """ Reconciles the created invoices with their matching refund.
+ """
+ invoices.mapped('line_ids').filtered(lambda x: x.account_type in ('asset_receivable', 'liability_payable')).reconcile()
+
+ # 100 (base, invoice) - 100 (base, refund) + 20 (tax, invoice) - 5 (25% tax, refund) = 15
+ self._run_caba_generic_test(
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Sale', 15),
+ ('Purchase', 15),
+ ],
+ on_all_invoices_created=reconcile_opposite_types
+ )
+
+ @freeze_time('2023-10-05 02:00:00')
+ def test_tax_report_grid_cash_basis_misc_pmt(self):
+ """ Cash basis moves create for taxes based on payments are handled differently
+ by the report; we want to ensure their sign is managed properly. This
+ test runs the case where the invoice is paid with a misc operation instead
+ of a payment.
+ """
+ def reconcile_with_misc_pmt(invoice):
+ """ Create a misc operation equivalent to a full payment and reconciles
+ the invoice with it.
+ """
+ # Pay the invoice with a misc operation simulating a payment, so that the cash basis entries are created
+ invoice_reconcilable_line = invoice.line_ids.filtered(lambda x: x.account_type in ('liability_payable', 'asset_receivable'))
+ account = (invoice.line_ids - invoice_reconcilable_line).account_id
+ pmt_move = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': invoice.date,
+ 'line_ids': [Command.create({
+ 'account_id': invoice_reconcilable_line.account_id.id,
+ 'debit': invoice_reconcilable_line.credit,
+ 'credit': invoice_reconcilable_line.debit,
+ }),
+ Command.create({
+ 'account_id': account.id,
+ 'credit': invoice_reconcilable_line.credit,
+ 'debit': invoice_reconcilable_line.debit,
+ })],
+ })
+ pmt_move.action_post()
+ payment_reconcilable_line = pmt_move.line_ids.filtered(lambda x: x.account_type in ('liability_payable', 'asset_receivable'))
+ (invoice_reconcilable_line + payment_reconcilable_line).reconcile()
+
+ # 100 (base, invoice) - 100 (base, refund) + 20 (tax, invoice) - 5 (25% tax, refund) = 15
+ self._run_caba_generic_test(
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Sale', 15),
+ ('Purchase', 15),
+ ],
+ on_invoice_created=reconcile_with_misc_pmt
+ )
+
+ @freeze_time('2023-10-05 02:00:00')
+ def test_caba_no_payment(self):
+ """ The cash basis taxes of an unpaid invoice should
+ never impact the report.
+ """
+ self._run_caba_generic_test(
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Sale', 0.0),
+ ('Purchase', 0.0),
+ ]
+ )
+
+ @freeze_time('2023-10-05 02:00:00')
+ def test_caba_half_payment(self):
+ """ Paying half the amount of the invoice should report half the
+ base and tax amounts.
+ """
+ def register_half_payment_for_invoice(invoice):
+ """ Fully pay the invoice, so that the cash basis entries are created
+ """
+ payment_method_id = self.inbound_payment_method_line if invoice.is_inbound() else self.outbound_payment_method_line
+ self.env['account.payment.register'].with_context(active_ids=invoice.ids, active_model='account.move').create({
+ 'amount': invoice.amount_residual / 2,
+ 'payment_date': invoice.date,
+ 'payment_method_line_id': payment_method_id.id,
+ })._create_payments()
+
+ # 50 (base, invoice) - 50 (base, refund) + 10 (tax, invoice) - 2.5 (25% tax, refund) = 7.5
+ self._run_caba_generic_test(
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Sale', 7.5),
+ ('Purchase', 7.5),
+ ],
+ on_invoice_created=register_half_payment_for_invoice
+ )
+
+ def test_caba_mixed_generic_report(self):
+ """ Tests mixing taxes with different tax exigibilities displays correct amounts
+ in the generic tax report.
+ """
+ self.env.company.tax_exigibility = True
+ # Create taxes
+ regular_tax = self.env['account.tax'].create({
+ 'name': 'Regular',
+ 'amount': 42,
+ 'amount_type': 'percent',
+ 'type_tax_use': 'sale',
+ # We use default repartition: 1 base line, 1 100% tax line
+ })
+
+ caba_tax = self.env['account.tax'].create({
+ 'name': 'Cash Basis',
+ 'amount': 10,
+ 'amount_type': 'percent',
+ 'type_tax_use': 'sale',
+ 'tax_exigibility': 'on_payment',
+ # We use default repartition: 1 base line, 1 100% tax line
+ })
+
+ # Create an invoice using them, and post it
+ invoice = self.init_invoice(
+ 'out_invoice',
+ invoice_date='2021-07-01',
+ post=True,
+ amounts=[100],
+ taxes=regular_tax + caba_tax,
+ company=self.company_data['company'],
+ )
+
+ # Check the report only contains non-caba things
+ report = self.env.ref("account.generic_tax_report")
+ options = self._generate_options(report, invoice.date, invoice.date)
+ self.assertLinesValues(
+ report._get_lines(options),
+ # Name Net Tax
+ [ 0, 1, 2],
+ [
+ ("Sales", '', 42),
+ ("Regular (42.0%)", 100, 42),
+ ("Total Sales", '', 42),
+ ],
+ options,
+ )
+
+ # Pay half of the invoice
+ self.env['account.payment.register'].with_context(active_ids=invoice.ids, active_model='account.move').create({
+ 'amount': 76,
+ 'payment_date': invoice.date,
+ 'payment_method_line_id': self.outbound_payment_method_line.id,
+ })._create_payments()
+
+ # Check the report again: half the cash basis should be there
+ self.assertLinesValues(
+ report._get_lines(options),
+ # Name Net Tax
+ [ 0, 1, 2],
+ [
+ ("Sales", '', 47),
+ ("Regular (42.0%)", 100, 42),
+ ("Cash Basis (10.0%)", 50, 5),
+ ("Total Sales", '', 47),
+ ],
+ options,
+ )
+
+ # Pay the rest
+ self.env['account.payment.register'].with_context(active_ids=invoice.ids, active_model='account.move').create({
+ 'amount': 76,
+ 'payment_date': invoice.date,
+ 'payment_method_line_id': self.outbound_payment_method_line.id,
+ })._create_payments()
+
+ # Check everything is in the report
+ self.assertLinesValues(
+ report._get_lines(options),
+ # Name Net Tax
+ [ 0, 1, 2],
+ [
+ ("Sales", '', 52),
+ ("Regular (42.0%)", 100, 42),
+ ("Cash Basis (10.0%)", 100, 10),
+ ("Total Sales", '', 52),
+ ],
+ options,
+ )
+
+ def test_tax_report_mixed_exigibility_affect_base_generic_invoice(self):
+ """ Tests mixing caba and non-caba taxes with one of them affecting the base
+ of the other worcs properly on invoices for generic report.
+ """
+ self.env.company.tax_exigibility = True
+ # Create taxes
+ regular_tax = self.env['account.tax'].create({
+ 'name': 'Regular',
+ 'amount': 42,
+ 'amount_type': 'percent',
+ 'type_tax_use': 'sale',
+ 'include_base_amount': True,
+ 'sequence': 0,
+ # We use default repartition: 1 base line, 1 100% tax line
+ })
+
+ caba_tax = self.env['account.tax'].create({
+ 'name': 'Cash Basis',
+ 'amount': 10,
+ 'amount_type': 'percent',
+ 'type_tax_use': 'sale',
+ 'tax_exigibility': 'on_payment',
+ 'include_base_amount': True,
+ 'sequence': 1,
+ # We use default repartition: 1 base line, 1 100% tax line
+ })
+
+ report = self.env.ref("account.generic_tax_report")
+ # Case 1: on_invoice tax affecting on_payment tax's base
+ self._run_check_suite_mixed_exigibility_affect_base(
+ regular_tax + caba_tax,
+ '2021-07-01',
+ report,
+ # Name, Net, Tax
+ [ 0, 1, 2],
+ # Before payment
+ [
+ ("Sales", '', 42),
+ ("Regular (42.0%)", 100, 42),
+ ("Total Sales", '', 42),
+ ],
+ # After paying 30%
+ [
+ ("Sales", '', 46.26),
+ ("Regular (42.0%)", 100, 42),
+ ("Cash Basis (10.0%)", 42.6, 4.26),
+ ("Total Sales", '', 46.26),
+ ],
+ # After full payment
+ [
+ ("Sales", '', 56.2),
+ ("Regular (42.0%)", 100, 42),
+ ("Cash Basis (10.0%)", 142, 14.2),
+ ("Total Sales", '', 56.2),
+ ]
+ )
+
+ # Change sequence
+ caba_tax.sequence = 0
+ regular_tax.sequence = 1
+
+ # Case 2: on_payment tax affecting on_invoice tax's base
+ self._run_check_suite_mixed_exigibility_affect_base(
+ regular_tax + caba_tax,
+ '2021-07-02',
+ report,
+ # Name Net Tax
+ [ 0, 1, 2],
+ # Before payment
+ [
+ ("Sales", '', 46.2),
+ ("Regular (42.0%)", 110, 46.2),
+ ("Total Sales", '', 46.2),
+ ],
+ # After paying 30%
+ [
+ ("Sales", '', 49.2),
+ ("Cash Basis (10.0%)", 30, 3),
+ ("Regular (42.0%)", 110, 46.2),
+ ("Total Sales", '', 49.2),
+ ],
+ # After full payment
+ [
+ ("Sales", '', 56.2),
+ ("Cash Basis (10.0%)", 100, 10),
+ ("Regular (42.0%)", 110, 46.2),
+ ("Total Sales", '', 56.2),
+ ]
+ )
+
+ def test_tax_report_mixed_exigibility_affect_base_tags(self):
+ """ Tests mixing caba and non-caba taxes with one of them affecting the base
+ of the other worcs properly on invoices for tax report.
+ """
+ self.env.company.tax_exigibility = True
+ # Create taxes
+ tax_report = self.env['account.report'].create({
+ 'name': "Sokovia Accords",
+ 'country_id': self.fiscal_country.id,
+ 'root_report_id': self.env.ref("account.generic_tax_report").id,
+ 'column_ids': [Command.create({'name': 'balance', 'sequence': 1, 'expression_label': 'balance'})],
+ })
+
+ regular_tax = self._add_basic_tax_for_report(tax_report, 42, 'sale', self.tax_group_1, [(100, None, True)])
+ caba_tax = self._add_basic_tax_for_report(tax_report, 10, 'sale', self.tax_group_1, [(100, None, True)])
+
+ regular_tax.write({
+ 'include_base_amount': True,
+ 'sequence': 0,
+ })
+ caba_tax.write({
+ 'include_base_amount': True,
+ 'tax_exigibility': 'on_payment',
+ 'sequence': 1,
+ })
+
+ # Case 1: on_invoice tax affecting on_payment tax's base
+ self._run_check_suite_mixed_exigibility_affect_base(
+ regular_tax + caba_tax,
+ '2021-07-01',
+ tax_report,
+ # Name Balance
+ [ 0, 1],
+ # Before payment
+ [
+ (f'{regular_tax.id}-invoice-base', 100),
+ (f'{regular_tax.id}-invoice-100', 42),
+ (f'{regular_tax.id}-refund-base', 0.0),
+ (f'{regular_tax.id}-refund-100', 0.0),
+
+ (f'{caba_tax.id}-invoice-base', 0.0),
+ (f'{caba_tax.id}-invoice-100', 0.0),
+ (f'{caba_tax.id}-refund-base', 0.0),
+ (f'{caba_tax.id}-refund-100', 0.0),
+ ],
+ # After paying 30%
+ [
+ (f'{regular_tax.id}-invoice-base', 100),
+ (f'{regular_tax.id}-invoice-100', 42),
+ (f'{regular_tax.id}-refund-base', 0.0),
+ (f'{regular_tax.id}-refund-100', 0.0),
+
+ (f'{caba_tax.id}-invoice-base', 42.6),
+ (f'{caba_tax.id}-invoice-100', 4.26),
+ (f'{caba_tax.id}-refund-base', 0.0),
+ (f'{caba_tax.id}-refund-100', 0.0),
+ ],
+ # After full payment
+ [
+ (f'{regular_tax.id}-invoice-base', 100),
+ (f'{regular_tax.id}-invoice-100', 42),
+ (f'{regular_tax.id}-refund-base', 0.0),
+ (f'{regular_tax.id}-refund-100', 0.0),
+
+ (f'{caba_tax.id}-invoice-base', 142),
+ (f'{caba_tax.id}-invoice-100', 14.2),
+ (f'{caba_tax.id}-refund-base', 0.0),
+ (f'{caba_tax.id}-refund-100', 0.0),
+ ],
+ )
+
+ # Change sequence
+ caba_tax.sequence = 0
+ regular_tax.sequence = 1
+
+ # Case 2: on_payment tax affecting on_invoice tax's base
+ self._run_check_suite_mixed_exigibility_affect_base(
+ regular_tax + caba_tax,
+ '2021-07-02',
+ tax_report,
+ # Name Balance
+ [ 0, 1],
+ # Before payment
+ [
+ (f'{regular_tax.id}-invoice-base', 110),
+ (f'{regular_tax.id}-invoice-100', 46.2),
+ (f'{regular_tax.id}-refund-base', 0.0),
+ (f'{regular_tax.id}-refund-100', 0.0),
+
+ (f'{caba_tax.id}-invoice-base', 0.0),
+ (f'{caba_tax.id}-invoice-100', 0.0),
+ (f'{caba_tax.id}-refund-base', 0.0),
+ (f'{caba_tax.id}-refund-100', 0.0),
+ ],
+ # After paying 30%
+ [
+ (f'{regular_tax.id}-invoice-base', 110),
+ (f'{regular_tax.id}-invoice-100', 46.2),
+ (f'{regular_tax.id}-refund-base', 0.0),
+ (f'{regular_tax.id}-refund-100', 0.0),
+
+ (f'{caba_tax.id}-invoice-base', 30),
+ (f'{caba_tax.id}-invoice-100', 3),
+ (f'{caba_tax.id}-refund-base', 0.0),
+ (f'{caba_tax.id}-refund-100', 0.0),
+ ],
+ # After full payment
+ [
+ (f'{regular_tax.id}-invoice-base', 110),
+ (f'{regular_tax.id}-invoice-100', 46.2),
+ (f'{regular_tax.id}-refund-base', 0.0),
+ (f'{regular_tax.id}-refund-100', 0.0),
+
+ (f'{caba_tax.id}-invoice-base', 100),
+ (f'{caba_tax.id}-invoice-100', 10),
+ (f'{caba_tax.id}-refund-base', 0.0),
+ (f'{caba_tax.id}-refund-100', 0.0),
+ ],
+ )
+
+ def _run_check_suite_mixed_exigibility_affect_base(self, taxes, invoice_date, report, report_columns, vals_not_paid, vals_30_percent_paid, vals_fully_paid):
+ # Create an invoice using them
+ invoice = self.init_invoice(
+ 'out_invoice',
+ invoice_date=invoice_date,
+ post=True,
+ amounts=[100],
+ taxes=taxes,
+ company=self.company_data['company'],
+ )
+
+ # Check the report
+ report_options = self._generate_options(report, invoice.date, invoice.date)
+ self.assertLinesValues(report._get_lines(report_options), report_columns, vals_not_paid, report_options)
+
+ # Pay 30% of the invoice
+ self.env['account.payment.register'].with_context(active_ids=invoice.ids, active_model='account.move').create({
+ 'amount': invoice.amount_residual * 0.3,
+ 'payment_date': invoice.date,
+ 'payment_method_line_id': self.outbound_payment_method_line.id,
+ })._create_payments()
+
+ # Check the report again: 30% of the caba amounts should be there
+ self.assertLinesValues(report._get_lines(report_options), report_columns, vals_30_percent_paid, report_options)
+
+ # Pay the rest: total caba amounts should be there
+ self.env['account.payment.register'].with_context(active_ids=invoice.ids, active_model='account.move').create({
+ 'payment_date': invoice.date,
+ 'payment_method_line_id': self.outbound_payment_method_line.id,
+ })._create_payments()
+
+ # Check the report
+ self.assertLinesValues(report._get_lines(report_options), report_columns, vals_fully_paid, report_options)
+
+ def test_caba_always_exigible(self):
+ """ Misc operations without payable nor receivable lines must always be exigible,
+ whatever the tax_exigibility configured on their taxes.
+ """
+ tax_report = self.env['account.report'].create({
+ 'name': "Laplace's Box",
+ 'country_id': self.fiscal_country.id,
+ 'root_report_id': self.env.ref("account.generic_tax_report").id,
+ 'column_ids': [Command.create({'name': 'balance', 'sequence': 1, 'expression_label': 'balance'})],
+ })
+
+ regular_tax = self._add_basic_tax_for_report(tax_report, 42, 'sale', self.tax_group_1, [(100, None, True)])
+ caba_tax = self._add_basic_tax_for_report(tax_report, 10, 'sale', self.tax_group_1, [(100, None, True)])
+
+ regular_tax.write({
+ 'include_base_amount': True,
+ 'sequence': 0,
+ })
+ caba_tax.write({
+ 'tax_exigibility': 'on_payment',
+ 'sequence': 1,
+ })
+
+ # Create a misc operation using various combinations of our taxes
+ move = self.env['account.move'].create({
+ 'date': '2021-08-01',
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ Command.create({
+ 'name': "Test with %s" % ', '.join(taxes.mapped('name')),
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'credit': 100,
+ 'tax_ids': [Command.set(taxes.ids)],
+ })
+ for taxes in (caba_tax, regular_tax, caba_tax + regular_tax)
+ ] + [
+ Command.create({
+ 'name': "Balancing line",
+ 'account_id': self.company_data['default_account_assets'].id,
+ 'debit': 408.2,
+ 'tax_ids': [],
+ })
+ ]
+ })
+
+ move.action_post()
+
+ self.assertTrue(move.always_tax_exigible, "A move without payable/receivable line should always be exigible, whatever its taxes.")
+
+ # Check tax report by grid
+ report_options = self._generate_options(tax_report, move.date, move.date)
+ self.assertLinesValues(
+ tax_report._get_lines(report_options),
+ # Name Balance
+ [ 0, 1],
+ [
+ (f'{regular_tax.id}-invoice-base', 200),
+ (f'{regular_tax.id}-invoice-100', 84),
+ (f'{regular_tax.id}-refund-base', 0.0),
+ (f'{regular_tax.id}-refund-100', 0.0),
+
+ (f'{caba_tax.id}-invoice-base', 242),
+ (f'{caba_tax.id}-invoice-100', 24.2),
+ (f'{caba_tax.id}-refund-base', 0.0),
+ (f'{caba_tax.id}-refund-100', 0.0),
+ ],
+ report_options,
+ )
+
+
+ # Check generic tax report
+ tax_report = self.env.ref("account.generic_tax_report")
+ report_options = self._generate_options(tax_report, move.date, move.date)
+ self.assertLinesValues(
+ tax_report._get_lines(report_options),
+ # Name Net Tax
+ [ 0, 1, 2],
+ [
+ ("Sales", '', 108.2),
+ (f"{regular_tax.name} (42.0%)", 200, 84),
+ (f"{caba_tax.name} (10.0%)", 242, 24.2),
+ ("Total Sales", '', 108.2),
+ ],
+ report_options,
+ )
+
+ @freeze_time('2023-10-05 02:00:00')
+ def test_tax_report_grid_caba_negative_inv_line(self):
+ """ Tests cash basis taxes work properly in case a line of the invoice
+ has been made with a negative quantities and taxes (causing debit and
+ credit to be inverted on the base line).
+ """
+ def neg_line_invoice_generator(inv_type, partner, account, date, tax):
+ """ Invoices created here have a line at 100 with a negative quantity of -1.
+ They also required a second line (here 200), so that the invoice doesn't
+ have a negative total, but we don't put any tax on it.
+ """
+ return self.env['account.move'].create({
+ 'move_type': inv_type,
+ 'partner_id': partner.id,
+ 'invoice_date': date,
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'test',
+ 'quantity': -1,
+ 'account_id': account.id,
+ 'price_unit': 100,
+ 'tax_ids': [Command.set(tax.ids)],
+ }),
+
+ # Second line, so that the invoice doesn't have a negative total
+ Command.create({
+ 'name': 'test',
+ 'quantity': 1,
+ 'account_id': account.id,
+ 'price_unit': 200,
+ 'tax_ids': [],
+ }),
+ ],
+ })
+
+ # -100 (base, invoice) + 100 (base, refund) - 20 (tax, invoice) + 5 (25% tax, refund) = -15
+ self._run_caba_generic_test(
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Sale', -15),
+ ('Purchase', -15),
+ ],
+ on_invoice_created=self._register_full_payment_for_invoice,
+ invoice_generator=neg_line_invoice_generator,
+ )
+
+ def test_fiscal_position_switch_all_option_flow(self):
+ """ 'all' fiscal position option sometimes must be reset or enforced in order to keep
+ the report consistent. We check those cases here.
+ """
+ foreign_tax_report = self.env['account.report'].create({
+ 'name': "",
+ 'country_id': self.foreign_country.id,
+ 'root_report_id': self.env.ref("account.generic_tax_report").id,
+ 'column_ids': [Command.create({'name': 'balance', 'sequence': 1, 'expression_label': 'balance'})],
+ })
+ foreign_vat_fpos = self.env['account.fiscal.position'].create({
+ 'name': "Test fpos",
+ 'country_id': self.foreign_country.id,
+ 'foreign_vat': '422211',
+ })
+
+ # Case 1: 'all' allowed if multiple fpos
+ to_check = self.basic_tax_report.get_options({'fiscal_position': 'all', 'selected_variant_id': self.basic_tax_report.id})
+ self.assertEqual(to_check['fiscal_position'], 'all', "Opening the report with 'all' fiscal_position option should work if there are fiscal positions for different states in that country")
+
+ # Case 2: 'all' not allowed if domestic and no fpos
+ self.foreign_vat_fpos.foreign_vat = None # No unlink because setupClass created some moves with it
+ to_check = self.basic_tax_report.get_options({'fiscal_position': 'all', 'selected_variant_id': self.basic_tax_report.id})
+ self.assertEqual(to_check['fiscal_position'], 'domestic', "Opening the domestic report with 'all' should change to 'domestic' if there's no state-specific fiscal position in the country")
+
+ # Case 3: 'all' not allowed on foreign report with 1 fpos
+ to_check = foreign_tax_report.get_options({'fiscal_position': 'all', 'selected_variant_id': foreign_tax_report.id})
+ self.assertEqual(to_check['fiscal_position'], foreign_vat_fpos.id, "Opening a foreign report with only one single fiscal position with 'all' option should change if to only select this fiscal position")
+
+ # Case 4: always 'all' on generic report
+ generic_tax_report = self.env.ref("account.generic_tax_report")
+ to_check = generic_tax_report.get_options({'fiscal_position': foreign_vat_fpos.id, 'selected_variant_id': generic_tax_report.id})
+ self.assertEqual(to_check['fiscal_position'], 'all', "The generic report should always use 'all' fiscal position option.")
+
+ def test_tax_report_multi_inv_line_no_rep_account(self):
+ """ Tests the behavior of the tax report when using a tax without any
+ repartition account (hence doing its tax lines on the base account),
+ and using the tax on two lines (to make sure grouping is handled
+ properly by the report).
+ We do that for both regular and cash basis taxes.
+ """
+ # Create taxes
+ regular_tax = self.env['account.tax'].create({
+ 'name': 'Regular',
+ 'amount': 42,
+ 'amount_type': 'percent',
+ 'type_tax_use': 'sale',
+ # We use default repartition: 1 base line, 1 100% tax line
+ })
+
+ caba_tax = self.env['account.tax'].create({
+ 'name': 'Cash Basis',
+ 'amount': 42,
+ 'amount_type': 'percent',
+ 'type_tax_use': 'sale',
+ 'tax_exigibility': 'on_payment',
+ # We use default repartition: 1 base line, 1 100% tax line
+ })
+ self.env.company.tax_exigibility = True
+
+ # Make one invoice of 2 lines for each of our taxes
+ invoice_date = fields.Date.from_string('2021-04-01')
+ other_account_revenue = self.company_data['default_account_revenue'].copy()
+
+ regular_invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': invoice_date,
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'line 1',
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'price_unit': 100,
+ 'tax_ids': [Command.set(regular_tax.ids)],
+ }),
+
+ Command.create({
+ 'name': 'line 2',
+ 'account_id': other_account_revenue.id,
+ 'price_unit': 100,
+ 'tax_ids': [Command.set(regular_tax.ids)],
+ })
+ ],
+ })
+
+ caba_invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': invoice_date,
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'line 1',
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'price_unit': 100,
+ 'tax_ids': [Command.set(caba_tax.ids)],
+ }),
+
+ Command.create({
+ 'name': 'line 2',
+ 'account_id': other_account_revenue.id,
+ 'price_unit': 100,
+ 'tax_ids': [Command.set(caba_tax.ids)],
+ })
+ ],
+ })
+
+ # Post the invoices
+ regular_invoice.action_post()
+ caba_invoice.action_post()
+
+ # Pay cash basis invoice
+ self.env['account.payment.register'].with_context(active_ids=caba_invoice.ids, active_model='account.move').create({
+ 'payment_date': invoice_date,
+ })._create_payments()
+
+ # Check the generic report
+ report = self.env.ref("account.generic_tax_report")
+ options = self._generate_options(report, invoice_date, invoice_date)
+ self.assertLinesValues(
+ report._get_lines(options),
+ # Name Net Tax
+ [ 0, 1, 2],
+ [
+ ("Sales", '', 168),
+ ("Regular (42.0%)", 200, 84),
+ ("Cash Basis (42.0%)", 200, 84),
+ ("Total Sales", '', 168),
+ ],
+ options,
+ )
+
+ def test_tax_unit(self):
+ tax_unit_report = self.env['account.report'].create({
+ 'name': "And now for something completely different",
+ 'country_id': self.fiscal_country.id,
+ 'root_report_id': self.env.ref("account.generic_tax_report").id,
+ 'column_ids': [Command.create({'name': 'balance', 'sequence': 1, 'expression_label': 'balance'})],
+ })
+
+ company_1 = self.company_data['company']
+ company_2 = self.company_data_2['company']
+ company_3 = self.setup_other_company(name="Company 3")['company']
+ unit_companies = company_1 + company_2
+ all_companies = unit_companies + company_3
+
+ company_2.currency_id = company_1.currency_id
+
+ tax_unit = self.env['account.tax.unit'].create({
+ 'name': "One unit to rule them all",
+ 'country_id': self.fiscal_country.id,
+ 'vat': "DW1234567890",
+ 'company_ids': [Command.set(unit_companies.ids)],
+ 'main_company_id': company_1.id,
+ })
+ tax_group_2 = self._instantiate_basic_test_tax_group(company_2)
+ self._instantiate_basic_test_tax_group(company_3)
+
+ created_taxes = {}
+ tax_accounts = {}
+ invoice_date = fields.Date.from_string('2018-01-01')
+ for index, company in enumerate(all_companies):
+ # Make sure the fiscal country is what we want
+ self.change_company_country(company, self.fiscal_country)
+
+ # Create a tax for this report
+ tax_account = self.env['account.account'].create({
+ 'name': 'Tax unit test tax account',
+ 'code': 'test.tax.unit',
+ 'account_type': 'asset_current',
+ 'company_ids': [Command.link(company.id)],
+ })
+ tax_group = self.env['account.tax.group'].search([('company_id', '=', company.id), ('name', '=', 'Test tax group')], limit=1)
+
+ test_tax = self._add_basic_tax_for_report(tax_unit_report, 42, 'sale', tax_group, [(100, tax_account, True)], company=company)
+ created_taxes[company] = test_tax
+ tax_accounts[company] = tax_account
+
+ # Create an invoice with this tax
+ self.init_invoice(
+ 'out_invoice',
+ partner=self.partner_a,
+ invoice_date=invoice_date,
+ post=True,
+ amounts=[100 * (index + 1)],
+ taxes=test_tax, company=company
+ )
+
+ # Check report content, with various scenarios of active companies
+ for active_companies in (company_1, company_2, company_3, unit_companies, all_companies, company_2 + company_3):
+
+ # In the regular flow, selected companies are changed from the selector, in the UI.
+ # The tax unit option of the report changes the value of the selector, so it'll
+ # always stay consistent with allowed_company_ids.
+ options = self._generate_options(
+ tax_unit_report.with_context(allowed_company_ids=active_companies.ids),
+ invoice_date,
+ invoice_date,
+ {'fiscal_position': 'domestic'}
+ )
+
+ target_unit = tax_unit if company_3 != active_companies[0] else None
+ self.assertTrue(
+ (not target_unit and not options['available_tax_units']) \
+ or (options['available_tax_units'] and any(available_unit['id'] == target_unit.id for available_unit in options['available_tax_units'])),
+ "The tax unit should always be available when self.env.company is part of it."
+ )
+
+ self.assertEqual(
+ options['tax_unit'] != 'company_only',
+ active_companies == unit_companies,
+ "The tax unit option should only be enabled when all the companies of the unit are selected, and nothing else."
+ )
+
+ self.assertLinesValues(
+ tax_unit_report.with_context(allowed_company_ids=active_companies.ids)._get_lines(options),
+ # Name Balance
+ [ 0, 1],
+ [
+ # Company 1
+ (f'{created_taxes[company_1].id}-invoice-base', 100 if company_1 in active_companies else 0.0),
+ (f'{created_taxes[company_1].id}-invoice-100', 42 if company_1 in active_companies else 0.0),
+ (f'{created_taxes[company_1].id}-refund-base', 0.0),
+ (f'{created_taxes[company_1].id}-refund-100', 0.0),
+
+ # Company 2
+ (f'{created_taxes[company_2].id}-invoice-base', 200 if active_companies == unit_companies or active_companies[0] == company_2 else 0.0),
+ (f'{created_taxes[company_2].id}-invoice-100', 84 if active_companies == unit_companies or active_companies[0] == company_2 else 0.0),
+ (f'{created_taxes[company_2].id}-refund-base', 0.0),
+ (f'{created_taxes[company_2].id}-refund-100', 0.0),
+
+ # Company 3 (not part of the unit, so always 0 in our cases)
+ (f'{created_taxes[company_3].id}-invoice-base', 300 if company_3 == active_companies[0] else 0.0),
+ (f'{created_taxes[company_3].id}-invoice-100', 126 if company_3 == active_companies[0] else 0.0),
+ (f'{created_taxes[company_3].id}-refund-base', 0.0),
+ (f'{created_taxes[company_3].id}-refund-100', 0.0),
+ ],
+ options,
+ )
+
+ # Check closing for the vat unit
+ options = self._generate_options(
+ tax_unit_report.with_context(allowed_company_ids=unit_companies.ids),
+ invoice_date,
+ invoice_date,
+ {'tax_report': tax_unit_report.id, 'fiscal_position': 'all'}
+ )
+
+ self._assert_vat_closing(tax_unit_report, options, {
+ (company_1, self.env['account.fiscal.position']): [
+ {'debit': 42, 'credit': 0, 'account_id': tax_accounts[company_1].id},
+ {'debit': 0, 'credit': 42, 'account_id': self.tax_group_1.tax_payable_account_id.id},
+ ],
+
+ (company_1, self.foreign_vat_fpos): [
+ # Don't check accounts here; they are gotten by searching on taxes, basically we don't care about them as it's 0-balanced.
+ {'debit': 0, 'credit': 0,},
+ {'debit': 0, 'credit': 0,},
+ ],
+
+ (company_2, self.env['account.fiscal.position']): [
+ {'debit': 84, 'credit': 0, 'account_id': tax_accounts[company_2].id},
+ {'debit': 0, 'credit': 84, 'account_id': tax_group_2.tax_payable_account_id.id},
+ ],
+ })
+
+ def test_tax_unit_create_horizontal_group(self):
+ """ This test will try to create two tax units to see if the creation of horizontal group works as expected """
+ company_1 = self.company_data['company']
+ company_2 = self.company_data_2['company']
+ company_2.currency_id = company_1.currency_id
+ unit_companies_1 = company_1 + company_2
+
+ company_3 = self.setup_other_company(name="Company 3")['company']
+ company_4 = self.setup_other_company(name="Company 4")['company']
+ unit_companies_2 = company_3 + company_4
+
+ self.env['account.tax.unit'].create([
+ {
+ 'name': "First Tax Unit",
+ 'country_id': self.fiscal_country.id,
+ 'vat': "DW1234567890",
+ 'company_ids': [Command.set(unit_companies_1.ids)],
+ 'main_company_id': company_1.id,
+ },
+ {
+ 'name': "Second Tax Unit",
+ 'country_id': self.fiscal_country.id,
+ 'vat': "DW1234567890",
+ 'company_ids': [Command.set(unit_companies_2.ids)],
+ 'main_company_id': company_3.id,
+ },
+ ])
+
+ # Check if the two last horizontal_group are the one created from the tax unit
+ horizontal_groups = self.env['account.report.horizontal.group'].search([])[-2:]
+ self.assertEqual(['First Tax Unit', 'Second Tax Unit'], horizontal_groups.mapped('name'))
+
+ # Check if the generic_tax_report has the two groups
+ generic_tax_report = self.env.ref('account.generic_tax_report')
+ self.assertTrue(all(horizontal_group_id in generic_tax_report.horizontal_group_ids.ids for horizontal_group_id in horizontal_groups.ids))
+
+ def test_tax_unit_auto_fiscal_position(self):
+ # setup companies
+ company_1 = self.company_data['company']
+ company_2 = self.company_data_2['company']
+ company_2.currency_id = company_1.currency_id
+ company_3 = self.setup_other_company(name="Company 3")['company']
+ company_4 = self.setup_other_company(name="Company 4")['company']
+ unit_companies = company_1 + company_2 + company_3
+ all_companies = unit_companies + company_4
+
+ # create a tax unit containing 3 companies
+ tax_unit = self.env['account.tax.unit'].create({
+ 'name': "One unit to rule them all",
+ 'country_id': self.fiscal_country.id,
+ 'vat': "DW1234567890",
+ 'company_ids': [Command.set(unit_companies.ids)],
+ 'main_company_id': company_1.id,
+ })
+ self.assertFalse(tax_unit.fpos_synced)
+ tax_unit.action_sync_unit_fiscal_positions()
+ for current_company in unit_companies:
+ # verify that partners for other companies in the unit have a fiscal position that removes taxes
+ created_fp = tax_unit._get_tax_unit_fiscal_positions(companies=current_company)
+ self.assertTrue(created_fp)
+ self.assertEqual(
+ (unit_companies - current_company).partner_id.with_company(current_company).property_account_position_id,
+ created_fp
+ )
+ self.assertTrue(created_fp.tax_ids.tax_src_id)
+ self.assertFalse(created_fp.tax_ids.tax_dest_id)
+ self.assertFalse(current_company.partner_id.with_company(current_company).property_account_position_id)
+ tax_unit._compute_fiscal_position_completion()
+ self.assertTrue(tax_unit.fpos_synced)
+
+ # remove company 3 from the unit and verify that the fiscal positions are removed from the relevant companies
+ tax_unit.write({
+ 'company_ids': [Command.unlink(company_3.id)]
+ })
+ self.assertFalse(tax_unit.fpos_synced)
+ tax_unit.action_sync_unit_fiscal_positions()
+ self.assertFalse(company_3.partner_id.with_company(company_1).property_account_position_id)
+ self.assertFalse(company_1.partner_id.with_company(company_3).property_account_position_id)
+ company_1_fp = tax_unit._get_tax_unit_fiscal_positions(companies=company_1)
+ self.assertEqual(company_2.partner_id.with_company(company_1).property_account_position_id, company_1_fp)
+ self.assertTrue(tax_unit.fpos_synced)
+
+ # add company 3, remove company 2
+ tax_unit.write({
+ 'company_ids': [Command.link(company_3.id), Command.unlink(company_2.id)]
+ })
+ self.assertFalse(tax_unit.fpos_synced)
+ tax_unit.action_sync_unit_fiscal_positions()
+ company_1_fp = tax_unit._get_tax_unit_fiscal_positions(companies=company_1)
+ self.assertEqual(company_3.partner_id.with_company(company_1).property_account_position_id, company_1_fp)
+ self.assertFalse(company_2.partner_id.with_company(company_1).property_account_position_id)
+ self.assertTrue(company_1.partner_id.with_company(company_3).property_account_position_id)
+
+ # remove the fiscal position from the partner of company 1
+ company_1.partner_id.with_company(company_3).property_account_position_id = False
+ self.assertFalse(tax_unit.fpos_synced)
+ tax_unit.action_sync_unit_fiscal_positions()
+ self.assertTrue(tax_unit.fpos_synced)
+
+ #replace all companies
+ tax_unit.write({
+ 'company_ids': [Command.set([company_2.id, company_4.id])],
+ 'main_company_id': company_2.id,
+ })
+ self.assertFalse(tax_unit.fpos_synced)
+ tax_unit.action_sync_unit_fiscal_positions()
+ self.assertTrue(tax_unit.fpos_synced)
+
+ # no fiscal positions should exist after deleting the unit
+ tax_unit.unlink()
+ for company in all_companies:
+ self.assertFalse(all_companies.partner_id.with_company(company).property_account_position_id)
+
+ def test_vat_unit_with_foreign_vat_fpos(self):
+ # Company 1 has the test country as domestic country, and a foreign VAT fpos in a different province
+ company_1 = self.company_data['company']
+
+ # Company 2 belongs to a different country, and has a foreign VAT fpos to the test country, with just one
+ # move adding 1000 in the first line of the report.
+ company_2 = self.company_data_2['company']
+ company_2.currency_id = company_1.currency_id
+
+ foreign_vat_fpos = self.env['account.fiscal.position'].create({
+ 'name': 'fpos',
+ 'foreign_vat': 'tagada tsoin tsoin',
+ 'country_id': self.fiscal_country.id,
+ 'company_id': company_2.id,
+ })
+
+ report_line = self.env['account.report.line'].search([
+ ('report_id', '=', self.basic_tax_report.id),
+ ('name', '=', f'{self.test_fpos_tax_sale.id}-invoice-base'),
+ ])
+
+ plus_tag = report_line.expression_ids._get_matching_tags("+")
+
+ comp2_move = self.env['account.move'].create({
+ 'journal_id': self.company_data_2['default_journal_misc'].id,
+ 'date': '2021-02-02',
+ 'fiscal_position_id': foreign_vat_fpos.id,
+ 'line_ids': [
+ Command.create({
+ 'account_id': self.company_data_2['default_account_assets'].id,
+ 'credit': 1000,
+ }),
+
+ Command.create({
+ 'account_id': self.company_data_2['default_account_expense'].id,
+ 'debit': 1000,
+ 'tax_tag_ids': [Command.set(plus_tag.ids)],
+ }),
+ ]
+ })
+
+ comp2_move.action_post()
+
+ # Both companies belong to a tax unit in test country
+ tax_unit = self.env['account.tax.unit'].create({
+ 'name': "Taxvengers, assemble!",
+ 'country_id': self.fiscal_country.id,
+ 'vat': "dudu",
+ 'company_ids': [Command.set((company_1 + company_2).ids)],
+ 'main_company_id': company_1.id,
+ })
+
+ # Opening the tax report for test country, we should see the same as in test_tax_report_fpos_everything + the 1000 of company 2, whatever the main company
+
+ # Varying the order of the two companies (and hence changing the "main" active one) should make no difference.
+ for unit_companies in ((company_1 + company_2), (company_2 + company_1)):
+ options = self._generate_options(
+ self.basic_tax_report.with_context(allowed_company_ids=unit_companies.ids),
+ fields.Date.from_string('2021-01-01'),
+ fields.Date.from_string('2021-03-31'),
+ {'fiscal_position': 'all'}
+ )
+
+ self.assertEqual(options['tax_unit'], tax_unit.id, "The tax unit should have been auto-detected.")
+
+ self.assertLinesValues(
+ self.basic_tax_report._get_lines(options),
+ # Name Balance
+ [ 0, 1],
+ [
+ # out_invoice + 1000 from company_2 on the first line
+ (f'{self.test_fpos_tax_sale.id}-invoice-base', 2000),
+ (f'{self.test_fpos_tax_sale.id}-invoice-30', 150),
+ (f'{self.test_fpos_tax_sale.id}-invoice-70', 350),
+ (f'{self.test_fpos_tax_sale.id}-invoice--100', -500),
+
+ #out_refund
+ (f'{self.test_fpos_tax_sale.id}-refund-base', -220),
+ (f'{self.test_fpos_tax_sale.id}-refund-30', -33),
+ (f'{self.test_fpos_tax_sale.id}-refund-70', -77),
+ (f'{self.test_fpos_tax_sale.id}-refund--100', 110),
+
+ #in_invoice
+ (f'{self.test_fpos_tax_purchase.id}-invoice-base', 1400),
+ (f'{self.test_fpos_tax_purchase.id}-invoice-40', 280),
+ (f'{self.test_fpos_tax_purchase.id}-invoice-60', 420),
+ (f'{self.test_fpos_tax_purchase.id}-invoice--100', -700),
+
+ #in_refund
+ (f'{self.test_fpos_tax_purchase.id}-refund-base', -660),
+ (f'{self.test_fpos_tax_purchase.id}-refund-40', -132),
+ (f'{self.test_fpos_tax_purchase.id}-refund-60', -198),
+ (f'{self.test_fpos_tax_purchase.id}-refund--100', 330),
+ ],
+ options,
+ )
+
+ @freeze_time('2023-10-05 02:00:00')
+ def test_tax_report_with_entries_with_sale_and_purchase_taxes(self):
+ """ Ensure signs are managed properly for entry moves.
+ This test runs the case where invoice/bill like entries are created and reverted.
+ """
+ today = fields.Date.today()
+ company = self.env.user.company_id
+ tax_report = self.env['account.report'].create({
+ 'name': 'Test',
+ 'country_id': self.fiscal_country.id,
+ 'root_report_id': self.env.ref("account.generic_tax_report").id,
+ 'column_ids': [Command.create({'name': 'balance', 'sequence': 1, 'expression_label': 'balance'})],
+ })
+
+ # We create some report lines
+ report_lines_dict = {
+ 'sale': [
+ self._create_tax_report_line('Sale base', tax_report, sequence=1, tag_name='sale_b'),
+ self._create_tax_report_line('Sale tax', tax_report, sequence=1, tag_name='sale_t'),
+ ],
+ 'purchase': [
+ self._create_tax_report_line('Purchase base', tax_report, sequence=2, tag_name='purchase_b'),
+ self._create_tax_report_line('Purchase tax', tax_report, sequence=2, tag_name='purchase_t'),
+ ],
+ }
+
+ # We create a sale and a purchase tax, linked to our report line tags
+ taxes = self._create_taxes_for_report_lines(report_lines_dict, company)
+
+ account_types = {
+ 'sale': 'income',
+ 'purchase': 'expense',
+ }
+ for tax in taxes:
+ account = self.env['account.account'].search([('company_ids', '=', company.id), ('account_type', '=', account_types[tax.type_tax_use])], limit=1)
+ # create one entry and it's reverse
+ move_form = Form(self.env['account.move'].with_context(default_move_type='entry'))
+ with move_form.line_ids.new() as line:
+ line.account_id = account
+ if tax.type_tax_use == 'sale':
+ line.credit = 1000
+ else:
+ line.debit = 1000
+ line.tax_ids.clear()
+ line.tax_ids.add(tax)
+
+ # Create a third account.move.line for balance.
+ with move_form.line_ids.new() as line:
+ line.account_id = account
+ if tax.type_tax_use == 'sale':
+ line.debit = 1200
+ else:
+ line.credit = 1200
+ move = move_form.save()
+ move.action_post()
+ refund_wizard = self.env['account.move.reversal'].with_context(active_model="account.move", active_ids=move.ids).create({
+ 'reason': 'reasons',
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ })
+ refund_wizard.modify_moves()
+
+ self.assertEqual(
+ move.line_ids.tax_repartition_line_id,
+ move.reversal_move_ids.line_ids.tax_repartition_line_id,
+ "The same repartition line should be used when reverting a misc operation, to ensure they sum up to 0 in all cases."
+ )
+
+ options = self._generate_options(tax_report, today, today)
+
+ # We check the taxes on entries have impacted the report properly
+ inv_report_lines = tax_report._get_lines(options)
+
+ self.assertLinesValues(
+ inv_report_lines,
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Sale base', 0.0),
+ ('Sale tax', 0.0),
+ ('Purchase base', 0.0),
+ ('Purchase tax', 0.0),
+ ],
+ options,
+ )
+
+ @freeze_time('2023-10-05 02:00:00')
+ def test_invoice_like_entry_reverse_caba_report(self):
+ """ Cancelling the reconciliation of an invoice using cash basis taxes should reverse the cash basis move
+ in such a way that the original cash basis move lines' impact falls down to 0.
+ """
+ self.env.company.tax_exigibility = True
+
+ tax_report = self.env['account.report'].create({
+ 'name': 'CABA test',
+ 'country_id': self.fiscal_country.id,
+ 'root_report_id': self.env.ref("account.generic_tax_report").id,
+ 'column_ids': [Command.create({'name': 'balance', 'sequence': 1, 'expression_label': 'balance'})],
+ })
+ report_line_invoice_base = self._create_tax_report_line('Invoice base', tax_report, sequence=1, tag_name='caba_invoice_base')
+ report_line_invoice_tax = self._create_tax_report_line('Invoice tax', tax_report, sequence=2, tag_name='caba_invoice_tax')
+ report_line_refund_base = self._create_tax_report_line('Refund base', tax_report, sequence=3, tag_name='caba_refund_base')
+ report_line_refund_tax = self._create_tax_report_line('Refund tax', tax_report, sequence=4, tag_name='caba_refund_tax')
+
+ tax = self.env['account.tax'].create({
+ 'name': 'The Tax Who Says Ni',
+ 'type_tax_use': 'sale',
+ 'amount': 42,
+ 'tax_exigibility': 'on_payment',
+ 'invoice_repartition_line_ids': [
+ Command.create({
+ 'repartition_type': 'base',
+ 'tag_ids': [Command.set(report_line_invoice_base.expression_ids._get_matching_tags("+").ids)],
+ }),
+ Command.create({
+ 'repartition_type': 'tax',
+ 'tag_ids': [Command.set(report_line_invoice_tax.expression_ids._get_matching_tags("+").ids)],
+ }),
+ ],
+ 'refund_repartition_line_ids': [
+ Command.create({
+ 'repartition_type': 'base',
+ 'tag_ids': [Command.set(report_line_refund_base.expression_ids._get_matching_tags("+").ids)],
+ }),
+ Command.create({
+ 'repartition_type': 'tax',
+ 'tag_ids': [Command.set(report_line_refund_tax.expression_ids._get_matching_tags("+").ids)],
+ }),
+ ],
+ })
+
+ move_form = Form(self.env['account.move'] \
+ .with_company(self.company_data['company']) \
+ .with_context(default_move_type='entry'))
+ move_form.date = fields.Date.today()
+ with move_form.line_ids.new() as base_line_form:
+ base_line_form.name = "Base line"
+ base_line_form.account_id = self.company_data['default_account_revenue']
+ base_line_form.credit = 100
+ base_line_form.tax_ids.clear()
+ base_line_form.tax_ids.add(tax)
+
+ with move_form.line_ids.new() as receivable_line_form:
+ receivable_line_form.name = "Receivable line"
+ receivable_line_form.account_id = self.company_data['default_account_receivable']
+ receivable_line_form.debit = 142
+ move = move_form.save()
+ move.action_post()
+ # make payment
+ payment = self.env['account.payment'].create({
+ 'payment_type': 'inbound',
+ 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id,
+ 'partner_type': 'customer',
+ 'partner_id': self.partner_a.id,
+ 'amount': 142,
+ 'date': move.date,
+ 'journal_id': self.company_data['default_journal_bank'].id,
+ })
+ payment.action_post()
+
+ report_options = self._generate_options(tax_report, move.date, move.date)
+ self.assertLinesValues(
+ tax_report._get_lines(report_options),
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Invoice base', 0.0),
+ ('Invoice tax', 0.0),
+ ('Refund base', 0.0),
+ ('Refund tax', 0.0),
+ ],
+ report_options,
+ )
+
+ # Reconcile the move with a payment
+ (payment.move_id + move).line_ids.filtered(lambda x: x.account_id == self.company_data['default_account_receivable']).reconcile()
+ self.assertLinesValues(
+ tax_report._get_lines(report_options),
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Invoice base', 100),
+ ('Invoice tax', 42),
+ ('Refund base', 0.0),
+ ('Refund tax', 0.0),
+ ],
+ report_options,
+ )
+
+ # Unreconcile the moves
+ move.line_ids.remove_move_reconcile()
+ self.assertLinesValues(
+ tax_report._get_lines(report_options),
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Invoice base', 0.0),
+ ('Invoice tax', 0.0),
+ ('Refund base', 0.0),
+ ('Refund tax', 0.0),
+ ],
+ report_options,
+ )
+
+ def test_tax_report_get_past_closing_entry(self):
+ options = self._generate_options(self.basic_tax_report, '2021-01-01', '2021-12-31')
+
+ with patch.object(type(self.env['account.move']), '_get_vat_report_attachments', autospec=True, side_effect=lambda *args, **kwargs: []):
+ # Generate the tax closing entry and close the period without posting it, so that we can assert on the exception
+ vat_closing_move = self.env['account.generic.tax.report.handler']._generate_tax_closing_entries(self.basic_tax_report, options)
+ vat_closing_move.action_post()
+
+ # Calling the action_periodic_vat_entries method should return the existing tax closing entry.
+ vat_closing_action = self.env['account.generic.tax.report.handler'].with_context({'override_tax_closing_warning': True}).action_periodic_vat_entries(options)
+ self.assertEqual(vat_closing_move.id, vat_closing_action['res_id'])
+
+ def setup_multi_vat_context(self):
+ """Setup 2 tax reports, taxes and partner to represent a multiVat context in which both taxes affect both tax report"""
+
+ def get_positive_tag(report_line):
+ return report_line.expression_ids._get_matching_tags().filtered(lambda x: not x.tax_negate)
+
+ self.env['account.fiscal.position'].create({
+ 'name': "FP With foreign VAT number",
+ 'country_id': self.foreign_country.id,
+ 'foreign_vat': '422211',
+ 'auto_apply': True,
+ })
+
+ local_tax_report, foreign_tax_report = self.env['account.report'].create([
+ {
+ 'name': "The Local Tax Report",
+ 'country_id': self.company_data['company'].account_fiscal_country_id.id,
+ 'root_report_id': self.env.ref('account.generic_tax_report').id,
+ 'column_ids': [Command.create({'name': 'balance', 'sequence': 1, 'expression_label': 'balance'})],
+ },
+ {
+ 'name': "The Foreign Tax Report",
+ 'country_id': self.foreign_country.id,
+ 'root_report_id': self.env.ref('account.generic_tax_report').id,
+ 'column_ids': [Command.create({'name': 'balance', 'sequence': 1, 'expression_label': 'balance', })],
+ },
+ ])
+ local_tax_report_base_line = self._create_tax_report_line("base_local", local_tax_report, sequence=1, code="base_local", tag_name="base_local")
+ local_tax_report_tax_line = self._create_tax_report_line("tax_local", local_tax_report, sequence=2, code="tax_local", tag_name="tax_local")
+ foreign_tax_report_base_line = self._create_tax_report_line("base_foreign", foreign_tax_report, sequence=1, code="base_foreign", tag_name="base_foreign")
+ foreign_tax_report_tax_line = self._create_tax_report_line("tax_foreign", foreign_tax_report, sequence=2, code="tax_foreign", tag_name="tax_foreign")
+
+ local_tax_affecting_foreign_tax_report = self.env['account.tax'].create({'name': "The local tax affecting the foreign report", 'amount': 20})
+ foreign_tax_affecting_local_tax_report = self.env['account.tax'].create({
+ 'name': "The foreign tax affecting the local tax report",
+ 'amount': 20,
+ 'country_id': self.foreign_country.id,
+ })
+ for tax in (local_tax_affecting_foreign_tax_report, foreign_tax_affecting_local_tax_report):
+ base_line, tax_line = tax.invoice_repartition_line_ids
+ base_line.tag_ids = get_positive_tag(local_tax_report_base_line) + get_positive_tag(foreign_tax_report_base_line)
+ tax_line.tag_ids = get_positive_tag(local_tax_report_tax_line) + get_positive_tag(foreign_tax_report_tax_line)
+
+ local_partner = self.partner_a
+ foreign_partner = self.partner_a.copy()
+ foreign_partner.country_id = self.foreign_country
+
+ return {
+ 'tax_report': (local_tax_report, foreign_tax_report,),
+ 'taxes': (local_tax_affecting_foreign_tax_report, foreign_tax_affecting_local_tax_report,),
+ 'partners': (local_partner, foreign_partner),
+ }
+
+ def test_local_tax_can_affect_foreign_tax_report(self):
+ setup_data = self.setup_multi_vat_context()
+ local_tax_report, foreign_tax_report = setup_data['tax_report']
+ local_tax_affecting_foreign_tax_report, _ = setup_data['taxes']
+ local_partner, _ = setup_data['partners']
+
+ invoice = self.init_invoice('out_invoice', partner=local_partner, invoice_date='2022-12-01', post=True, amounts=[100], taxes=local_tax_affecting_foreign_tax_report)
+ options = self._generate_options(local_tax_report, invoice.date, invoice.date)
+ self.assertLinesValues(
+ local_tax_report._get_lines(options),
+ # Name Balance
+ [ 0, 1],
+ [
+ ("base_local", 100.0),
+ ("tax_local", 20.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(foreign_tax_report, invoice.date, invoice.date)
+ self.assertLinesValues(
+ foreign_tax_report._get_lines(options),
+ # Name Balance
+ [ 0, 1],
+ [
+ ("base_foreign", 100.0),
+ ("tax_foreign", 20.0),
+ ],
+ options,
+ )
+
+ def test_foreign_tax_can_affect_local_tax_report(self):
+ setup_data = self.setup_multi_vat_context()
+ local_tax_report, foreign_tax_report = setup_data['tax_report']
+ _, foreign_tax_affecting_local_tax_report = setup_data['taxes']
+ _, foreign_partner = setup_data['partners']
+
+ invoice = self.init_invoice('out_invoice', partner=foreign_partner, invoice_date='2022-12-01', post=True, amounts=[100], taxes=foreign_tax_affecting_local_tax_report)
+ options = self._generate_options(local_tax_report, invoice.date, invoice.date)
+ self.assertLinesValues(
+ local_tax_report._get_lines(options),
+ # Name Balance
+ [ 0, 1],
+ [
+ ("base_local", 100.0),
+ ("tax_local", 20.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(foreign_tax_report, invoice.date, invoice.date)
+ self.assertLinesValues(
+ foreign_tax_report._get_lines(options),
+ # Name Balance
+ [ 0, 1],
+ [
+ ("base_foreign", 100.0),
+ ("tax_foreign", 20.0),
+ ],
+ options,
+ )
+ def test_engine_external_many_fiscal_positions(self):
+ # Create a tax report that contains default manual expressions
+ self.basic_tax_report_2 = self.env['account.report'].create({
+ 'name': "The Other Tax Report",
+ 'country_id': self.fiscal_country.id,
+ 'root_report_id': self.env.ref("account.generic_tax_report").id,
+ 'column_ids': [Command.create({'name': 'balance', 'sequence': 1, 'expression_label': 'balance'})],
+ 'line_ids': [
+ Command.create({
+ 'name': "test_line_1",
+ 'code': "test_line_1",
+ 'sequence': 1,
+ 'expression_ids': [
+ Command.create({
+ 'date_scope': 'strict_range',
+ 'engine': 'external',
+ 'formula': 'sum',
+ 'label': 'balance',
+ }),
+ Command.create({
+ 'date_scope': 'strict_range',
+ 'engine': 'account_codes',
+ 'formula': '101',
+ 'label': '_default_balance',
+ })
+ ]
+ }),
+ Command.create({
+ 'name': "test_line_2",
+ 'code': "test_line_2",
+ 'sequence': 2,
+ 'expression_ids': [
+ Command.create({
+ 'date_scope': 'strict_range',
+ 'engine': 'account_codes',
+ 'formula': '101',
+ 'label': 'balance',
+ })
+ ],
+ })
+ ]
+ })
+
+ company_2 = self.company_data_2['company']
+ company_2.country_id = self.fiscal_country
+ company_2.currency_id = self.company_data['company'].currency_id
+
+ # create two foreign fiscal positions (FPs), so we could create moves for each of them
+ foreign_vat_fpos = self.env['account.fiscal.position'].create([
+ {
+ 'name': 'fpos 1',
+ 'foreign_vat': 'A Swallow from Africa',
+ 'country_id': self.fiscal_country.id,
+ 'company_id': company_2.id,
+ 'state_ids': self.country_state_1,
+ },
+ {
+ 'name': 'fpos 2',
+ 'foreign_vat': 'A Swallow from Europe',
+ 'country_id': self.fiscal_country.id,
+ 'company_id': company_2.id,
+ 'state_ids': self.country_state_2,
+ },
+ ])
+
+ test_account_1 = self.env['account.account'].create({
+ 'code': "101007",
+ 'name': "test account",
+ 'account_type': "asset_current",
+ 'company_ids': [Command.link(company_2.id)],
+ })
+
+ test_account_2 = self.env['account.account'].create({
+ 'code': "test",
+ 'name': "test",
+ 'account_type': "asset_current",
+ 'company_ids': [Command.link(company_2.id)],
+ })
+
+ move_vals = [{
+ 'date': fields.Date.from_string('2020-01-01'),
+ 'fiscal_position_id': fp.id,
+ 'company_id': company_2.id,
+ 'line_ids': [
+ Command.create({
+ 'name': 'line 1',
+ 'account_id': test_account_1.id,
+ 'debit': 1000 * (i + 1),
+ 'credit': 0.0,
+ }),
+ Command.create({
+ 'name': 'line 2',
+ 'account_id': test_account_2.id,
+ 'debit': 0.0,
+ 'credit': 1000 * (i + 1),
+ }),
+ ]
+ } for i, fp in enumerate(foreign_vat_fpos)]
+
+ # create a move that includes an account starting with '101'
+ # to make sure its amount does not appear in the tax report for company_2
+ other_company_move_vals = {
+ 'date': fields.Date.from_string('2020-01-01'),
+ 'company_id': self.company_data['company'].id,
+ 'line_ids': [
+ Command.create({
+ 'name': 'line 1',
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'debit': 1200,
+ 'credit': 0.0,
+ }),
+ Command.create({
+ 'name': 'line 2',
+ 'account_id': self.company_data['default_account_assets'].id,
+ 'debit': 0.0,
+ 'credit': 1200,
+ }),
+ ]
+ }
+
+ moves = self.env['account.move'].create(move_vals)
+ moves.action_post()
+ other_company_move = self.env['account.move'].create(other_company_move_vals)
+ other_company_move.action_post()
+
+ # we need to create different options per FP
+ fiscal_positions = ['all'] + foreign_vat_fpos.ids
+ report_options = {}
+ for fp in fiscal_positions:
+ fp_options = self._generate_options(
+ self.basic_tax_report_2.with_context(allowed_company_ids=[company_2.id]),
+ '2020-01-01', '2020-01-04',
+ default_options={
+ 'fiscal_position': fp,
+ }
+ )
+ report_options[fp] = fp_options
+
+ # when we filter by all FPs, the result on the second line
+ # should be the sum of the moves
+ # the first line contains a default expression and remains empty
+ # until we set a lock date
+ total_amount = sum([1000 * (i + 1) for i in range(len(foreign_vat_fpos.ids))])
+ report_lines = self.basic_tax_report_2\
+ .with_company(company_2)._get_lines(report_options['all'])
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report_lines,
+ [0, 1],
+ [
+ ('test_line_1', 0.0),
+ ('test_line_2', total_amount),
+ ],
+ report_options['all'],
+ )
+
+ # subsequently, line 2 should only contain the amount for the selected FP
+ for i, fp in enumerate(foreign_vat_fpos.ids):
+ report_lines = self.basic_tax_report_2\
+ .with_company(company_2)._get_lines(report_options[fp])
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report_lines,
+ [0, 1],
+ [
+ ('test_line_1', 0.0),
+ ('test_line_2', 1000 * (i + 1)),
+ ],
+ report_options[fp],
+ )
+
+ # the default values shouldn't be created if the general lock date is set
+ lock_date_wizard = self.env['account.change.lock.date']\
+ .with_company(company_2).create({
+ 'fiscalyear_lock_date': fields.Date.from_string('2020-01-04'),
+ })
+ lock_date_wizard.change_lock_date()
+
+ report_lines = self.basic_tax_report_2\
+ .with_company(company_2)._get_lines(report_options['all'])
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report_lines,
+ [0, 1],
+ [
+ ('test_line_1', 0.0),
+ ('test_line_2', total_amount),
+ ],
+ report_options['all'],
+ )
+
+ # We need to reset the fiscalyear_lock_date or it will raise an exception
+ company_2.fiscalyear_lock_date = False
+
+ # if we change the tax_lock_date, the default values should be created
+ lock_date_wizard = self.env['account.change.lock.date']\
+ .with_company(company_2).create({
+ 'tax_lock_date': fields.Date.from_string('2020-01-04'),
+ })
+ lock_date_wizard.change_lock_date()
+
+ report_lines = self.basic_tax_report_2\
+ .with_company(company_2)._get_lines(report_options['all'])
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report_lines,
+ [0, 1],
+ [
+ ('test_line_1', total_amount),
+ ('test_line_2', total_amount),
+ ],
+ report_options['all'],
+ )
+
+ for i, fp in enumerate(foreign_vat_fpos.ids):
+ report_lines = self.basic_tax_report_2\
+ .with_company(company_2)._get_lines(report_options[fp])
+ self.assertLinesValues(
+ # pylint: disable=bad-whitespace
+ report_lines,
+ [0, 1],
+ [
+ ('test_line_1', 1000 * (i + 1)),
+ ('test_line_2', 1000 * (i + 1)),
+ ],
+ report_options[fp],
+ )
+
+ def test_tax_report_w_rounding_line(self):
+ """Check that the tax report is correct when a rounding line is added to an invoice."""
+ self.env['res.config.settings'].create({
+ 'company_id': self.company_data['company'].id,
+ 'group_cash_rounding': True
+ })
+
+ rounding = self.env['account.cash.rounding'].create({
+ 'name': 'Test rounding',
+ 'rounding': 0.05,
+ 'strategy': 'biggest_tax',
+ 'rounding_method': 'HALF-UP',
+ })
+
+ tax = self.sale_tax_percentage_incl_1.copy({
+ 'name': 'The Tax Who Says Ni',
+ 'amount': 21,
+ })
+
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'The Holy Grail',
+ 'quantity': 1,
+ 'price_unit': 1.26,
+ 'tax_ids': [Command.set(self.sale_tax_percentage_incl_1.ids)],
+ }),
+ Command.create({
+ 'name': 'What is your favourite colour?',
+ 'quantity': 1,
+ 'price_unit': 2.32,
+ 'tax_ids': [Command.set(tax.ids)],
+ })
+ ],
+ 'invoice_cash_rounding_id': rounding.id,
+ })
+
+ invoice.action_post()
+
+ self.assertRecordValues(invoice.line_ids, [
+ {
+ 'name': 'The Holy Grail',
+ 'debit': 0.00,
+ 'credit': 1.05,
+ },
+ {
+ 'name': 'What is your favourite colour?',
+ 'debit': 0.00,
+ 'credit': 1.92,
+ },
+ {
+ 'name': self.sale_tax_percentage_incl_1.name,
+ 'debit': 0.00,
+ 'credit': 0.21,
+ },
+ {
+ 'name': tax.name,
+ 'debit': 0.00,
+ 'credit': 0.40,
+ },
+ {
+ 'name': f'{tax.name} (rounding)',
+ 'debit': 0.00,
+ 'credit': 0.02,
+ },
+ {
+ 'name': invoice.name,
+ 'debit': 3.60,
+ 'credit': 0.00,
+ }
+ ])
+
+ report = self.env.ref('account.generic_tax_report')
+ options = self._generate_options(report, invoice.date, invoice.date)
+
+ self.assertLinesValues(
+ report._get_lines(options),
+ # Name Base Tax
+ [ 0, 1, 2],
+ [
+ ('Sales', "", 0.63),
+ (f'{self.sale_tax_percentage_incl_1.name} ({self.sale_tax_percentage_incl_1.amount}%)', 1.05, 0.21),
+ (f'{tax.name} ({tax.amount}%)', 1.92, 0.42),
+ ('Total Sales', "", 0.63),
+ ],
+ options
+ )
+
+ report = self.env.ref("account.generic_tax_report_account_tax")
+ options['report_id'] = report.id
+
+ self.assertLinesValues(
+ report._get_lines(options),
+ # Name Base Tax
+ [ 0, 1, 2],
+ [
+ ('Sales', "", 0.63),
+ (self.company_data['default_account_revenue'].display_name, "", 0.63),
+ (f'{self.sale_tax_percentage_incl_1.name} ({self.sale_tax_percentage_incl_1.amount}%)', 1.05, 0.21),
+ (f'{tax.name} ({tax.amount}%)', 1.92, 0.42),
+ (f'Total {self.company_data["default_account_revenue"].display_name}', "", 0.63),
+ ('Total Sales', "", 0.63),
+ ],
+ options
+ )
+
+ report = self.env.ref("account.generic_tax_report_tax_account")
+ options['report_id'] = report.id
+
+ self.assertLinesValues(
+ report._get_lines(options),
+ # Name Base Tax
+ [ 0, 1, 2],
+ [
+ ('Sales', "", 0.63),
+ (f'{self.sale_tax_percentage_incl_1.name} ({self.sale_tax_percentage_incl_1.amount}%)', "", 0.21),
+ (self.company_data['default_account_revenue'].display_name, 1.05, 0.21),
+ (f'Total {self.sale_tax_percentage_incl_1.name} ({self.sale_tax_percentage_incl_1.amount}%)', "", 0.21),
+ (f'{tax.name} ({tax.amount}%)', "", 0.42),
+ (self.company_data['default_account_revenue'].display_name, 1.92, 0.42),
+ (f'Total {tax.name} ({tax.amount}%)', "", 0.42),
+ ('Total Sales', "", 0.63),
+ ],
+ options
+ )
+
+ def test_tax_report_closing_entry_reset_to_draft(self):
+ """
+ Test the reset to draft functionality to ensure no duplicate closing entry is created.
+
+ This test checks that when a tax report closing entry is posted and then reset to draft,
+ creating a subsequent closing entry will not result in a duplicate. Instead, the same
+ initial closing entry will be reused.
+ """
+ options = self._generate_options(self.basic_tax_report, '2021-03-01', '2021-03-31')
+ vat_closing_action = self.env['account.generic.tax.report.handler'].with_context({'override_tax_closing_warning': True}).action_periodic_vat_entries(options)
+ initial_closing_entry = self.env['account.move'].browse(vat_closing_action['res_id'])
+ with self.enter_test_mode():
+ initial_closing_entry.action_post()
+ initial_closing_entry.button_draft()
+ vat_closing_action = self.env['account.generic.tax.report.handler'].with_context({'override_tax_closing_warning': True}).action_periodic_vat_entries(options)
+ subsequent_closing_entry = self.env['account.move'].browse(vat_closing_action['res_id'])
+ self.assertEqual(initial_closing_entry, subsequent_closing_entry)
+
+ def test_tax_report_closing_entry_draft_with_new_entries(self):
+ """
+ Test whether the tax closing entry gets untouched when reset to draft and the VAT closing button is clicked again.
+ """
+ options = self._generate_options(self.basic_tax_report, '2023-01-01', '2023-03-31')
+ self.init_invoice('out_invoice', partner=self.partner_a, invoice_date='2023-03-22', post=True, amounts=[200], taxes=self.tax_sale_a)
+ initial_vat_closing_action = self.env['account.generic.tax.report.handler'].with_context({'override_tax_closing_warning': True}).action_periodic_vat_entries(options)
+ initial_closing_entry = self.env['account.move'].browse(initial_vat_closing_action['res_id'])
+ initial_values = []
+ for aml in initial_closing_entry.line_ids:
+ self.assertEqual(aml.balance, 30 if aml.balance > 0 else -30)
+ initial_values.append({'account_id': aml.account_id.id, 'balance': aml.balance})
+ self.init_invoice('out_invoice', partner=self.partner_a, invoice_date='2023-03-22', post=True, amounts=[1000], taxes=self.tax_sale_a)
+ subsequent_vat_closing_action = self.env['account.generic.tax.report.handler'].with_context({'override_tax_closing_warning': True}).action_periodic_vat_entries(options)
+ subsequent_closing_entry = self.env['account.move'].browse(subsequent_vat_closing_action['res_id'])
+ self.assertRecordValues(subsequent_closing_entry.line_ids, [
+ {'account_id': initial_values[0]['account_id'], 'balance': initial_values[0]['balance']},
+ {'account_id': initial_values[1]['account_id'], 'balance': initial_values[1]['balance']},
+ ])
+
+ def test_tax_report_multi_company_post_closing(self):
+ # Branches
+ root_company = self.setup_other_company(name="Root Company")['company']
+ branch_1 = self.env['res.company'].create({'name': "Branch 1", 'parent_id': root_company.id})
+ branch_1_1 = self.env['res.company'].create({'name': "Branch 1.1", 'parent_id': branch_1.id})
+ branch_2 = self.env['res.company'].create({'name': "Branch 2", 'parent_id': root_company.id})
+ branch_companies = root_company + branch_1 + branch_1_1 + branch_2
+ branch_companies.account_tax_periodicity_journal_id = root_company.account_tax_periodicity_journal_id.id
+
+ # Tax unit
+ unit_part_1 = self.setup_other_company(name="Unit part 1")['company']
+ unit_part_2 = self.setup_other_company(name="Unit part 2")['company']
+
+ tax_unit = self.env['account.tax.unit'].create({
+ 'name': "One unit to rule them all",
+ 'country_id': unit_part_1.account_fiscal_country_id.id,
+ 'vat': "123",
+ 'company_ids': (unit_part_1 + unit_part_2).ids,
+ 'main_company_id': unit_part_1.id,
+ })
+
+ tax_report = self.env['account.report'].create({
+ 'name': "My Onw Particular Tax Report",
+ 'country_id': unit_part_1.account_fiscal_country_id.id,
+ 'root_report_id': self.env.ref("account.generic_tax_report").id,
+ 'column_ids': [Command.create({'name': 'balance', 'sequence': 1, 'expression_label': 'balance',})],
+ })
+
+ for test_type, main_company, active_companies in [('branches', root_company, branch_companies), ('tax units', tax_unit.main_company_id, tax_unit.company_ids)]:
+ with self.subTest(f"Post multicompany closing - {test_type}"):
+ tax_report_with_companies = tax_report.with_context(allowed_company_ids=active_companies.ids)
+ options = self._generate_options(tax_report_with_companies, '2023-01-01', '2023-01-01')
+ closing_moves = self.env['account.generic.tax.report.handler'].with_context(allowed_company_ids=active_companies.ids)._generate_tax_closing_entries(tax_report_with_companies, options)
+
+ self.assertEqual(len(closing_moves), len(active_companies), "One closing move should have been created per company")
+ self.assertTrue(all(move.state == 'draft' for move in closing_moves), "All generated closing moves should be in draft")
+ main_closing_move = closing_moves.filtered(lambda x: x.company_id == main_company)
+ self.assertEqual(len(main_closing_move), 1)
+
+ with self.enter_test_mode():
+ action = main_closing_move.action_post()
+ self.assertTrue(action['params']['depending_action'])
+ # When posting the main closing move a component will open to propose you to post the depending moves.
+ # So while the depending moves are not posted the main closing will not be posted.
+ self.assertTrue(main_closing_move.state == 'draft')
+ (closing_moves - main_closing_move).action_post()
+ self.assertTrue(all(move.state == 'posted' for move in (closing_moves - main_closing_move)))
+ main_closing_move.action_post()
+ self.assertTrue(main_closing_move.state == 'posted')
+ def test_tax_report_prevent_draft_if_subsequent_posted(self):
+ """
+ Test the reset to draft functionality to ensure it is not possible to reset to draft a closing entry
+ if subsequent closing entries are already posted.
+ """
+ options = self._generate_options(self.basic_tax_report, '2023-01-01', '2023-03-31')
+ vat_closing_action = self.env['account.generic.tax.report.handler'].with_context({'override_tax_closing_warning': True}).action_periodic_vat_entries(options)
+ Q1_closing_entry = self.env['account.move'].browse(vat_closing_action['res_id'])
+ with self.enter_test_mode():
+ Q1_closing_entry.action_post()
+
+ options = self._generate_options(self.basic_tax_report, '2023-04-01', '2023-06-30')
+ vat_closing_action = self.env['account.generic.tax.report.handler'].with_context({'override_tax_closing_warning': True}).action_periodic_vat_entries(options)
+ Q2_closing_entry = self.env['account.move'].browse(vat_closing_action['res_id'])
+
+ # We need to force recompute the entry as it is already generated from posting the Q1 entry.
+ Q2_closing_entry = self.env['account.generic.tax.report.handler']._generate_tax_closing_entries(self.basic_tax_report, options, closing_moves=Q2_closing_entry)
+ with self.enter_test_mode():
+ Q2_closing_entry.action_post()
+
+ with self.assertRaises(UserError):
+ Q1_closing_entry.button_draft()
+
+ def assert_period(self, input_date, expected_start, expected_end):
+ period_start, period_end = self.env.company._get_tax_closing_period_boundaries(input_date, self.basic_tax_report)
+ self.assertEqual(period_start, expected_start, f"Period start date ({fields.Date.to_string(period_start)}) doesn't match the expected period start date: ({fields.Date.to_string(expected_start)})")
+ self.assertEqual(period_end, expected_end, f"Period end date ({fields.Date.to_string(period_end)}) doesn't match the expected period end date: ({fields.Date.to_string(expected_end)})")
+
+ @freeze_time('2024-09-01')
+ def test_tax_report_start_date(self):
+ # Periodicity only with default start_date
+ self.env.company.account_tax_periodicity = 'monthly'
+ self.assert_period(date(2024, 1, 1), expected_start=date(2024, 1, 1), expected_end=date(2024, 1, 31))
+ self.assert_period(date(2024, 9, 30), expected_start=date(2024, 9, 1), expected_end=date(2024, 9, 30))
+ self.assert_period(date(2024, 10, 1), expected_start=date(2024, 10, 1), expected_end=date(2024, 10, 31))
+
+ self.env.company.account_tax_periodicity = 'trimester'
+ self.assert_period(date(2024, 1, 1), expected_start=date(2024, 1, 1), expected_end=date(2024, 3, 31))
+ self.assert_period(date(2024, 5, 1), expected_start=date(2024, 4, 1), expected_end=date(2024, 6, 30))
+ self.assert_period(date(2024, 9, 30), expected_start=date(2024, 7, 1), expected_end=date(2024, 9, 30))
+ self.assert_period(date(2024, 10, 1), expected_start=date(2024, 10, 1), expected_end=date(2024, 12, 31))
+
+ self.env.company.account_tax_periodicity = 'year'
+ self.assert_period(date(2024, 1, 1), expected_start=date(2024, 1, 1), expected_end=date(2024, 12, 31))
+ self.assert_period(date(2023, 12, 31), expected_start=date(2023, 1, 1), expected_end=date(2023, 12, 31))
+
+ # Basic start dates
+ self.env.company.account_tax_periodicity = 'trimester'
+ self.basic_tax_report.tax_closing_start_date = '2024-01-01'
+ self.assert_period(date(2024, 1, 1), expected_start=date(2024, 1, 1), expected_end=date(2024, 3, 31))
+ self.assert_period(date(2024, 4, 1), expected_start=date(2024, 4, 1), expected_end=date(2024, 6, 30))
+ self.assert_period(date(2024, 5, 1), expected_start=date(2024, 4, 1), expected_end=date(2024, 6, 30))
+ self.assert_period(date(2024, 9, 30), expected_start=date(2024, 7, 1), expected_end=date(2024, 9, 30))
+ self.assert_period(date(2024, 10, 1), expected_start=date(2024, 10, 1), expected_end=date(2024, 12, 31))
+
+ self.basic_tax_report.tax_closing_start_date = '2024-02-01'
+ self.assert_period(date(2024, 1, 1), expected_start=date(2023, 11, 1), expected_end=date(2024, 1, 31))
+ self.assert_period(date(2024, 1, 31), expected_start=date(2023, 11, 1), expected_end=date(2024, 1, 31))
+ self.assert_period(date(2024, 2, 1), expected_start=date(2024, 2, 1), expected_end=date(2024, 4, 30))
+ self.assert_period(date(2024, 6, 1), expected_start=date(2024, 5, 1), expected_end=date(2024, 7, 31))
+ self.assert_period(date(2024, 10, 31), expected_start=date(2024, 8, 1), expected_end=date(2024, 10, 31))
+ self.assert_period(date(2024, 11, 1), expected_start=date(2024, 11, 1), expected_end=date(2025, 1, 31))
+
+ self.env.company.account_tax_periodicity = 'monthly'
+ self.assert_period(date(2024, 2, 1), expected_start=date(2024, 2, 1), expected_end=date(2024, 2, 29))
+ self.assert_period(date(2024, 1, 31), expected_start=date(2024, 1, 1), expected_end=date(2024, 1, 31))
+ self.assert_period(date(2024, 1, 1), expected_start=date(2024, 1, 1), expected_end=date(2024, 1, 31))
+ self.assert_period(date(2024, 4, 1), expected_start=date(2024, 4, 1), expected_end=date(2024, 4, 30))
+ self.assert_period(date(2024, 12, 31), expected_start=date(2024, 12, 1), expected_end=date(2024, 12, 31))
+ self.assert_period(date(2024, 12, 1), expected_start=date(2024, 12, 1), expected_end=date(2024, 12, 31))
+
+ # Complexe start dates
+ self.env.company.account_tax_periodicity = 'trimester'
+
+ self.basic_tax_report.tax_closing_start_date = '2024-02-06'
+ self.assert_period(date(2024, 2, 5), expected_start=date(2023, 11, 6), expected_end=date(2024, 2, 5))
+ self.assert_period(date(2024, 2, 1), expected_start=date(2023, 11, 6), expected_end=date(2024, 2, 5))
+ self.assert_period(date(2023, 11, 7), expected_start=date(2023, 11, 6), expected_end=date(2024, 2, 5))
+
+ self.assert_period(date(2024, 2, 6), expected_start=date(2024, 2, 6), expected_end=date(2024, 5, 5))
+ self.assert_period(date(2024, 5, 5), expected_start=date(2024, 2, 6), expected_end=date(2024, 5, 5))
+ self.assert_period(date(2024, 4, 5), expected_start=date(2024, 2, 6), expected_end=date(2024, 5, 5))
+
+ self.assert_period(date(2024, 5, 6), expected_start=date(2024, 5, 6), expected_end=date(2024, 8, 5))
+ self.assert_period(date(2024, 11, 5), expected_start=date(2024, 8, 6), expected_end=date(2024, 11, 5))
+ self.assert_period(date(2024, 11, 6), expected_start=date(2024, 11, 6), expected_end=date(2025, 2, 5))
+
+ self.basic_tax_report.tax_closing_start_date = '2024-06-06'
+ self.assert_period(date(2024, 3, 5), expected_start=date(2023, 12, 6), expected_end=date(2024, 3, 5))
+ self.assert_period(date(2024, 6, 5), expected_start=date(2024, 3, 6), expected_end=date(2024, 6, 5))
+ self.assert_period(date(2024, 9, 5), expected_start=date(2024, 6, 6), expected_end=date(2024, 9, 5))
+ self.assert_period(date(2024, 12, 5), expected_start=date(2024, 9, 6), expected_end=date(2024, 12, 5))
+
+ self.env.company.account_tax_periodicity = 'monthly'
+ self.assert_period(date(2024, 3, 5), expected_start=date(2024, 2, 6), expected_end=date(2024, 3, 5))
+ self.assert_period(date(2024, 3, 6), expected_start=date(2024, 3, 6), expected_end=date(2024, 4, 5))
+ self.assert_period(date(2024, 12, 5), expected_start=date(2024, 11, 6), expected_end=date(2024, 12, 5))
+ self.assert_period(date(2024, 12, 6), expected_start=date(2024, 12, 6), expected_end=date(2025, 1, 5))
+ self.assert_period(date(2025, 1, 5), expected_start=date(2024, 12, 6), expected_end=date(2025, 1, 5))
+
+ def test_multiple_same_tax_lines_with_multiple_analytics(self):
+ """ One Invoice line with analytic_distribution and another with another analytic_distribution, both with the same tax"""
+ analytic_plan = self.env['account.analytic.plan'].create({'name': 'Plan with Tax details'})
+ analytic_account_1 = self.env['account.analytic.account'].create({
+ 'name': 'Analytic account with Tax details',
+ 'plan_id': analytic_plan.id,
+ 'company_id': False,
+ })
+ analytic_account_2 = self.env['account.analytic.account'].create({
+ 'name': ' testAnalytic account',
+ 'plan_id': analytic_plan.id,
+ 'company_id': False,
+ })
+ tax_10 = self.env['account.tax'].create({
+ 'name': "tax_10",
+ 'amount_type': 'percent',
+ 'amount': 10.0,
+ })
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': '2019-01-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'line1',
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'price_unit': 200.0,
+ 'tax_ids': [Command.set(tax_10.ids)],
+ 'analytic_distribution': {
+ analytic_account_1.id: 100,
+ },
+ }),
+ Command.create({
+ 'name': 'line2',
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'price_unit': 100.0,
+ 'tax_ids': [Command.set(tax_10.ids)],
+ 'analytic_distribution': {
+ analytic_account_2.id: 100,
+ },
+ }),
+ ]
+ })
+ invoice.action_post()
+ invoice2 = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': '2019-01-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'line1',
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'price_unit': 10.0,
+ 'tax_ids': [Command.set(tax_10.ids)],
+ 'analytic_distribution': {
+ analytic_account_2.id: 100,
+ },
+ }),
+ ]
+ })
+ invoice2.action_post()
+
+ self.assertRecordValues(invoice.line_ids, [
+ {'name': 'line1', 'debit': 0.00, 'credit': 200.0},
+ {'name': 'line2', 'debit': 0.00, 'credit': 100.0},
+ {'name': tax_10.name, 'debit': 0.00, 'credit': 20.0},
+ {'name': tax_10.name, 'debit': 0.00, 'credit': 10.0},
+ {'name': invoice.name, 'debit': 330.0, 'credit': 0.00}
+ ])
+
+ self.assertRecordValues(invoice2.line_ids, [
+ {'name': 'line1', 'debit': 0.00, 'credit': 10.0},
+ {'name': tax_10.name, 'debit': 0.00, 'credit': 1.0},
+ {'name': invoice2.name, 'debit': 11.0, 'credit': 0.00}
+ ])
+
+ report = self.env.ref('account.generic_tax_report_account_tax')
+ options = self._generate_options(report, invoice.date, invoice.date)
+
+ self.assertLinesValues(
+ report._get_lines(options),
+ # Name Base Tax
+ [ 0, 1, 2],
+ [
+ ('Sales', "", 31.0),
+ (self.company_data['default_account_revenue'].display_name, "", 31.0),
+ (f'{tax_10.name} ({tax_10.amount}%)', 310.0, 31.0),
+ (f'Total {self.company_data["default_account_revenue"].display_name}', "", 31.0),
+ ('Total Sales', "", 31.0),
+ ],
+ options
+ )
+
+ report = self.env.ref("account.generic_tax_report_tax_account")
+ options['report_id'] = report.id
+
+ self.assertLinesValues(
+ report._get_lines(options),
+ # Name Base Tax
+ [ 0, 1, 2],
+ [
+ ('Sales', "", 31.0),
+ (f'{tax_10.name} ({tax_10.amount}%)', "", 31.0),
+ (self.company_data['default_account_revenue'].display_name, 310.0, 31.0),
+ (f'Total {tax_10.name} ({tax_10.amount}%)', "", 31.0),
+ ('Total Sales', "", 31.0),
+ ],
+ options
+ )
+
+ def test_only_one_closing_entry_with_generic_reports(self):
+ """ Test that when multiple generic tax reports are used, only one closing entry is created and used by all reports.
+ This is to prevent multiple identical closing entries to be created when multiple tax reports are used.
+ """
+ generic_tax_report = self.env.ref("account.generic_tax_report")
+ options = self._generate_options(generic_tax_report, '2021-03-01', '2021-03-31')
+ vat_closing_action = generic_tax_report.with_context({'override_tax_closing_warning': True}).dispatch_report_action(options, 'action_periodic_vat_entries')
+ generic_tax_report_closing_moves = self.env['account.move'].browse(vat_closing_action['domain'][0][2])
+
+ generic_tax_report_account_tax = self.env.ref('account.generic_tax_report_account_tax')
+ options = self._generate_options(generic_tax_report_account_tax, '2021-03-01', '2021-03-31')
+ vat_closing_action = generic_tax_report_account_tax.with_context({'override_tax_closing_warning': True}).dispatch_report_action(options, 'action_periodic_vat_entries')
+ generic_tax_report_account_tax_closing_moves = self.env['account.move'].browse(vat_closing_action['domain'][0][2])
+
+ generic_tax_report_tax_account = self.env.ref('account.generic_tax_report_tax_account')
+ options = self._generate_options(generic_tax_report_tax_account, '2021-03-01', '2021-03-31')
+ vat_closing_action = generic_tax_report_tax_account.with_context({'override_tax_closing_warning': True}).dispatch_report_action(options, 'action_periodic_vat_entries')
+ generic_tax_report_tax_account_closing_moves = self.env['account.move'].browse(vat_closing_action['domain'][0][2])
+
+ # We have 2 fiscal positions here, the domestic (no fiscal position) and foreign_vat_fpos.
+ self.assertEqual(len(generic_tax_report_closing_moves), 2, "There should be one closing entry per fiscal position.")
+ self.assertTrue(generic_tax_report_closing_moves.filtered(lambda m: not m.fiscal_position_id), "There should be a closing entry for the domestic fiscal position (no fiscal position).")
+ self.assertTrue(generic_tax_report_closing_moves.filtered(lambda m: m.fiscal_position_id == self.foreign_vat_fpos), "There should be a closing entry for the foreign VAT fiscal position.")
+ self.assertEqual(generic_tax_report_closing_moves, generic_tax_report_account_tax_closing_moves, "The closing entries used for both reports should be the same.")
+ self.assertEqual(generic_tax_report_closing_moves, generic_tax_report_tax_account_closing_moves, "The closing entries used for both reports should be the same.")
+ self.assertEqual(generic_tax_report_closing_moves.mapped('tax_closing_report_id'), self.basic_tax_report, "The closing entries should be linked to the basic tax report.")
+
+ def test_only_one_closing_entry_per_fpos(self):
+ """ Test that when different fiscal positions with foreign VAT numbers are used, only one closing entry per fiscal position is created and used by all reports.
+ This is to prevent multiple identical closing entries to be created when multiple fiscal positions are used.
+ We also test that the closing entry used for a report is the one corresponding to the fiscal position of the report's country.
+ """
+ generic_tax_report = self.env.ref("account.generic_tax_report")
+ other_country = self.env['res.country'].create({'name': 'Other Country', 'code': 'OT'})
+ other_country_vat_fpos = self.env['account.fiscal.position'].create({
+ 'name': "Foreign VAT",
+ 'country_id': other_country.id,
+ 'foreign_vat': 'ZZ0000000',
+ })
+
+ basic_other_country_tax_report = self.env['account.report'].create({
+ 'name': "The Unseen Tax Report",
+ 'country_id': other_country.id,
+ 'root_report_id': generic_tax_report.id,
+ 'column_ids': [Command.create({'name': 'balance', 'sequence': 1, 'expression_label': 'balance'})],
+ })
+
+ # Another report to test that when multiple variants are available for the same country, we use the generic tax report.
+ self.env['account.report'].create({
+ 'name': "The Other Unseen Tax Report",
+ 'country_id': other_country.id,
+ 'root_report_id': generic_tax_report.id,
+ 'column_ids': [Command.create({'name': 'balance', 'sequence': 1, 'expression_label': 'balance'})],
+ })
+
+ other_country_partner = self.env['res.partner'].create({
+ 'name': "Another Country Partner",
+ 'country_id': other_country.id,
+ })
+
+ self.init_invoice('out_invoice', partner=other_country_partner, invoice_date='2021-03-15', post=True, amounts=[23000], taxes=self.test_fpos_tax_sale)
+ self.init_invoice('in_invoice', partner=other_country_partner, invoice_date='2021-03-20', post=True, amounts=[300], taxes=self.test_fpos_tax_purchase)
+ self.init_invoice('out_invoice', partner=other_country_partner, invoice_date='2021-03-25', post=True, amounts=[700], taxes=self.test_fpos_tax_sale)
+ self.init_invoice('in_invoice', partner=other_country_partner, invoice_date='2021-03-28', post=True, amounts=[1300], taxes=self.test_fpos_tax_purchase)
+
+ options = self._generate_options(generic_tax_report, '2021-03-01', '2021-03-31')
+ vat_closing_action = generic_tax_report.with_context({'override_tax_closing_warning': True}).dispatch_report_action(options, 'action_periodic_vat_entries')
+ generic_closing_moves = self.env['account.move'].browse(vat_closing_action['domain'][0][2])
+
+ generic_closing_move_domestic = generic_closing_moves.filtered(lambda m: not m.fiscal_position_id)
+ generic_closing_move_foreign = generic_closing_moves.filtered(lambda m: m.fiscal_position_id == other_country_vat_fpos)
+
+ options = self._generate_options(self.basic_tax_report, '2021-03-01', '2021-03-31')
+ vat_closing_action = self.basic_tax_report.with_context({'override_tax_closing_warning': True}).dispatch_report_action(options, 'action_periodic_vat_entries')
+ domestic_closing_move = self.env['account.move'].browse(vat_closing_action['res_id'])
+
+ options = self._generate_options(basic_other_country_tax_report, '2021-03-01', '2021-03-31')
+ vat_closing_action = basic_other_country_tax_report.with_context({'override_tax_closing_warning': True}).dispatch_report_action(options, 'action_periodic_vat_entries')
+ other_country_closing_move = self.env['account.move'].browse(vat_closing_action['res_id'])
+
+ self.assertNotEqual(generic_closing_move_domestic, generic_closing_move_foreign, "Domestic and foreign closing entries should be different.")
+ self.assertEqual(generic_closing_move_domestic, domestic_closing_move, "The domestic closing entry should be the one used for the basic tax report.")
+ self.assertEqual(generic_closing_move_foreign, other_country_closing_move, "The foreign closing entry should be the one used for the other country tax report.")
+ self.assertEqual(generic_closing_move_domestic.tax_closing_report_id, self.basic_tax_report, "The domestic closing entry should be linked to the domestic tax report.")
+ self.assertEqual(generic_closing_move_foreign.tax_closing_report_id, generic_tax_report, "The foreign closing entry should be linked to the generic tax report.")
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_tax_report_carryover.py b/dev_odex30_accounting/odex30_account_reports/tests/test_tax_report_carryover.py
new file mode 100644
index 0000000..50b6f8b
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_tax_report_carryover.py
@@ -0,0 +1,334 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=C0326
+import json
+from unittest.mock import patch
+
+from .common import TestAccountReportsCommon
+from odoo import fields, Command
+from odoo.tests import tagged
+
+@tagged('post_install', '-at_install')
+class TestTaxReportCarryover(TestAccountReportsCommon):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.company_1 = cls.company_data['company']
+ cls.company_2 = cls.company_data_2['company']
+
+ cls.company_2.currency_id = cls.company_1.currency_id
+ cls.company_1.account_tax_periodicity = cls.company_2.account_tax_periodicity = 'year'
+
+ cls.report = cls.env['account.report'].create({
+ 'name': 'Test report',
+ 'country_id': cls.company_1.account_fiscal_country_id.id,
+ 'root_report_id': cls.env.ref("account.generic_tax_report").id,
+ 'column_ids': [Command.create({'name': 'Balance', 'sequence': 1, 'expression_label': 'balance'})],
+ })
+
+ cls.report_line = cls.env['account.report.line'].create({
+ 'name': 'Test carryover',
+ 'code': 'test_carryover',
+ 'report_id': cls.report.id,
+ 'sequence': 1,
+ 'expression_ids': [
+ Command.create({
+ 'label': 'tag',
+ 'engine': 'domain',
+ 'formula': [('account_id.account_type', '=', 'expense')],
+ 'subformula': 'sum',
+ }),
+ Command.create({
+ 'label': '_applied_carryover_balance',
+ 'engine': 'external',
+ 'formula': 'most_recent',
+ 'date_scope': 'previous_tax_period',
+ }),
+ Command.create({
+ 'label': 'balance_unbound',
+ 'engine': 'aggregation',
+ 'formula': 'test_carryover.tag + test_carryover._applied_carryover_balance',
+ }),
+ Command.create({
+ 'label': '_carryover_balance',
+ 'engine': 'aggregation',
+ 'formula': 'test_carryover.balance_unbound',
+ 'subformula': 'if_below(EUR(0))',
+ }),
+ Command.create({
+ 'label': 'balance',
+ 'engine': 'aggregation',
+ 'formula': 'test_carryover.balance_unbound',
+ 'subformula': 'if_above(EUR(0))',
+ }),
+ ],
+ })
+
+ def test_tax_report_carry_over(self):
+ options = self._generate_options(self.report, '2021-01-01', '2021-12-31')
+
+ move = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2021-03-01',
+ 'line_ids': [
+ Command.create({
+ 'debit': 0.0,
+ 'credit': 1000.0,
+ 'name': '2021_03_01',
+ 'account_id': self.company_data['default_account_expense'].id
+ }),
+ Command.create({
+ 'debit': 1000.0,
+ 'credit': 0.0,
+ 'name': '2021_03_01',
+ 'account_id': self.company_data['default_account_payable'].id
+ }),
+ ],
+ })
+ move.action_post()
+
+ self.env.flush_all()
+
+ with patch.object(type(self.env['account.move']), '_get_vat_report_attachments', autospec=True, side_effect=lambda *args, **kwargs: []):
+ vat_closing_move = self.env['account.generic.tax.report.handler']._generate_tax_closing_entries(self.report, options)
+ vat_closing_move.action_post()
+
+ # There should be an external value of -1000.0
+ external_value = self.env['account.report.external.value'].search([('company_id', '=', self.company_1.id)])
+
+ self.assertEqual(external_value.target_report_expression_label, '_applied_carryover_balance')
+ self.assertEqual(external_value.date, fields.Date.from_string('2021-12-31'))
+ self.assertEqual(external_value.value, -1000.0)
+
+ # There should be no value in the report since there is a carryover
+ lines = self.report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Test carryover', 0.0),
+ ],
+ options,
+ )
+
+ # There should be a carryover pop-up of value -1000.0
+ info_popup_data = json.loads(lines[0]['columns'][0]['info_popup_data'])
+ self.assertEqual(info_popup_data['carryover'], '-1,000.00')
+
+ # The carry over should be applied on the next period
+ options = self._generate_options(self.report, '2022-01-01', '2022-12-31')
+
+ lines = self.report._get_lines(options)
+
+ self.assertLinesValues(
+ lines,
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Test carryover', 0.0),
+ ],
+ options,
+ )
+
+ info_popup_data = json.loads(lines[0]['columns'][0]['info_popup_data'])
+ self.assertEqual(info_popup_data['carryover'], '-1,000.00')
+ self.assertEqual(info_popup_data['applied_carryover'], '-1,000.00')
+
+ def test_tax_report_carry_over_tax_unit(self):
+ self.env['account.tax.unit'].create({
+ 'name': 'Test tax unit',
+ 'country_id': self.company_1.account_fiscal_country_id.id,
+ 'vat': 'DW1234567890',
+ 'company_ids': [Command.set([self.company_1.id, self.company_2.id])],
+ 'main_company_id': self.company_1.id,
+ })
+
+ move_company_1 = self.env['account.move'].with_company(self.company_1).create({
+ 'move_type': 'entry',
+ 'date': '2021-03-01',
+ 'line_ids': [
+ Command.create({
+ 'debit': 0.0,
+ 'credit': 1000.0,
+ 'name': '2021_03_01',
+ 'account_id': self.company_data['default_account_expense'].id
+ }),
+ Command.create({
+ 'debit': 1000.0,
+ 'credit': 0.0,
+ 'name': '2021_03_01',
+ 'account_id': self.company_data['default_account_payable'].id
+ }),
+ ],
+ })
+ move_company_1.action_post()
+
+ move_company_2 = self.env['account.move'].with_company(self.company_2).create({
+ 'move_type': 'entry',
+ 'date': '2021-03-01',
+ 'line_ids': [
+ Command.create({
+ 'debit': 2000.0,
+ 'credit': 0.0,
+ 'name': '2021_03_01',
+ 'account_id': self.company_data_2['default_account_expense'].id
+ }),
+ Command.create({
+ 'debit': 0.0,
+ 'credit': 2000.0,
+ 'name': '2021_03_01',
+ 'account_id': self.company_data_2['default_account_payable'].id
+ }),
+ ],
+ })
+ move_company_2.action_post()
+
+ self.env.flush_all()
+
+ with patch.object(type(self.env['account.move']), '_get_vat_report_attachments', autospec=True, side_effect=lambda *args, **kwargs: []):
+ # There should be no external value for company 1 at this point
+ external_value_company_1 = self.env['account.report.external.value'].search_count([('company_id', '=', self.company_1.id)])
+ self.assertEqual(external_value_company_1, 0)
+
+ # Closes both companies
+ options = self._generate_options(self.report, '2021-01-01', '2021-12-31')
+ vat_closing_moves = self.env['account.generic.tax.report.handler']._generate_tax_closing_entries(self.report, options)
+ main_closing_move = vat_closing_moves.filtered(lambda x: x.company_id == self.company_1)
+ (vat_closing_moves - main_closing_move).action_post()
+ main_closing_move.action_post()
+ self.assertTrue(all(move.state == 'posted' for move in vat_closing_moves))
+
+ self.assertEqual(len(vat_closing_moves), 2, "There should be one closing per company in the tax unit")
+ self.assertTrue(all(closing.state == 'posted' for closing in vat_closing_moves), "Posting the main company's closing should post every other closing of this unit")
+
+ # There should be two external value for company_1: -1000.0 and 1000.0
+ external_value_company_1 = self.env['account.report.external.value'].search([('company_id', '=', self.company_1.id)])
+ external_value_company_1 = sorted(external_value_company_1, key=lambda x: x.value) # To make sure they are always in the same order
+
+ self.assertEqual(external_value_company_1[0].target_report_expression_label, '_applied_carryover_balance')
+ self.assertEqual(external_value_company_1[0].date, fields.Date.from_string('2021-12-31'))
+ self.assertEqual(external_value_company_1[0].value, -1000.0)
+
+ self.assertEqual(external_value_company_1[1].target_report_expression_label, '_applied_carryover_balance')
+ self.assertEqual(external_value_company_1[1].date, fields.Date.from_string('2021-12-31'))
+ self.assertEqual(external_value_company_1[1].value, 1000.0)
+
+ # There should be no external value for company_2
+ external_value_company_2 = self.env['account.report.external.value'].search_count([('company_id', '=', self.company_2.id)])
+ self.assertEqual(external_value_company_2, 0)
+
+ # TAX UNIT REPORT (current period)
+ # ==============================================================================================================
+ # There should be a value of 1000.0 in the report since the sum of the balance of both companies is positive,
+ # there is no carryover
+ options = self._generate_options(self.report, '2021-01-01', '2021-12-31')
+
+ lines_tax_unit = self.report._get_lines(options)
+ self.assertLinesValues(
+ lines_tax_unit,
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Test carryover', 1000.0),
+ ],
+ options,
+ )
+
+ # There should be no carryover pop-up
+ self.assertTrue('info_popup_data' not in lines_tax_unit[0]['columns'][0].keys())
+
+ # COMPANY 1 REPORT (current period)
+ # ==============================================================================================================
+ # There should be no value in the report since there is a carryover
+ report_company_1 = self.report.with_context(allowed_company_ids=self.company_1.ids)
+ options = self._generate_options(report_company_1, '2021-01-01', '2021-12-31')
+
+ lines_company_1 = report_company_1._get_lines(options)
+ self.assertLinesValues(
+ lines_company_1,
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Test carryover', 0.0),
+ ],
+ options,
+ )
+
+ # There should be a carryover pop-up
+ info_popup_data = json.loads(lines_company_1[0]['columns'][0]['info_popup_data'])
+ self.assertEqual(info_popup_data['carryover'], '-1,000.00')
+
+ # COMPANY 2 REPORT (current period)
+ # ==============================================================================================================
+ # There should be a value of 2000.0 in the report since there is no carryover
+ report_company_2 = self.report.with_context(allowed_company_ids=self.company_2.ids)
+ options = self._generate_options(report_company_2, '2021-01-01', '2021-12-31')
+
+ lines_company_2 = report_company_2._get_lines(options)
+ self.assertLinesValues(
+ lines_company_2,
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Test carryover', 2000.0),
+ ],
+ options,
+ )
+
+ # There should be no carryover pop-up
+ self.assertTrue('info_popup_data' not in lines_company_2[0]['columns'][0].keys())
+
+ # TAX UNIT REPORT (next period)
+ # ==============================================================================================================
+ options = self._generate_options(self.report, '2022-01-01', '2022-12-31')
+
+ lines_tax_unit = self.report._get_lines(options)
+ self.assertLinesValues(
+ lines_tax_unit,
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Test carryover', 0.0),
+ ],
+ options,
+ )
+
+ # There should be no carryover pop-up
+ self.assertTrue('info_popup_data' not in lines_tax_unit[0]['columns'][0].keys())
+
+ # COMPANY 1 REPORT (next period)
+ # ==============================================================================================================
+ report_company_1 = self.report.with_context(allowed_company_ids=self.company_1.ids)
+ options = self._generate_options(report_company_1, '2022-01-01', '2022-12-31')
+
+ lines_company_1 = report_company_1._get_lines(options)
+ self.assertLinesValues(
+ lines_company_1,
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Test carryover', 0.0),
+ ],
+ options,
+ )
+
+ self.assertTrue('info_popup_data' not in lines_company_1[0]['columns'][0].keys())
+
+ # COMPANY 2 REPORT (next period)
+ # ==============================================================================================================
+ report_company_2 = self.report.with_context(allowed_company_ids=self.company_2.ids)
+ options = self._generate_options(report_company_2, '2022-01-01', '2022-12-31')
+
+ lines_company_2 = report_company_2._get_lines(options)
+ self.assertLinesValues(lines_company_2,
+ # Name Balance
+ [ 0, 1],
+ [
+ ('Test carryover', 0.0),
+ ],
+ options,
+ )
+
+ # There should be no carryover pop-up
+ self.assertTrue('info_popup_data' not in lines_company_2[0]['columns'][0].keys())
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_tax_report_default_part.py b/dev_odex30_accounting/odex30_account_reports/tests/test_tax_report_default_part.py
new file mode 100644
index 0000000..7f3a077
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_tax_report_default_part.py
@@ -0,0 +1,1138 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=C0326
+from freezegun import freeze_time
+
+from .common import TestAccountReportsCommon
+from odoo import fields, Command
+from odoo.tests import tagged, Form
+
+
+@tagged('post_install', '-at_install')
+class TestTaxReportDefaultPart(TestAccountReportsCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.revenue_1 = cls.company_data['default_account_revenue']
+ cls.revenue_2 = cls.copy_account(cls.revenue_1)
+
+ cls.report_generic = cls.env.ref('account.generic_tax_report')
+ cls.report_grouped_account_tax = cls.env.ref('account.generic_tax_report_account_tax')
+ cls.report_grouped_tax_account = cls.env.ref('account.generic_tax_report_tax_account')
+
+ def checkAmlsRedirection(self, report, options, tax_lines_with_caret_options, expected_amls_based_on_tax_dict):
+ # Check the caret options of the tax lines redirect to the correct amls
+ for tax_line in tax_lines_with_caret_options:
+ expected_amls = expected_amls_based_on_tax_dict.get(tax_line['name'])
+ action = self.env[report.custom_handler_model_name].caret_option_audit_tax(options, {'line_id': tax_line['id']})
+ domain = action['domain']
+ actual_amls = self.env['account.move.line'].search(domain)
+ self.assertEqual(set(actual_amls), set(expected_amls))
+
+ def test_tax_affect_base(self):
+ tax_20_affect_base = self.env['account.tax'].create({
+ 'name': "tax_20_affect_base",
+ 'amount_type': 'percent',
+ 'amount': 20.0,
+ 'include_base_amount': True,
+ 'type_tax_use': 'sale',
+ })
+ tax_10 = self.env['account.tax'].create({
+ 'name': "tax_10",
+ 'amount_type': 'percent',
+ 'amount': 10.0,
+ 'type_tax_use': 'sale',
+ })
+
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': '2019-01-01',
+ 'date': '2019-01-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'base line',
+ 'account_id': self.revenue_1.id,
+ 'price_unit': 1000.0,
+ 'tax_ids': [Command.set((tax_20_affect_base + tax_10).ids)],
+ }),
+ Command.create({
+ 'name': 'base line',
+ 'account_id': self.revenue_2.id,
+ 'price_unit': 1000.0,
+ 'tax_ids': [Command.set((tax_20_affect_base + tax_10).ids)],
+ }),
+ ],
+ })
+ invoice.action_post()
+
+ date_from_str = '2019-01-01'
+ date_to_str = '2019-01-31'
+ options = self._generate_options(self.report_generic, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_generic._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 640.0),
+ ('tax_20_affect_base (20.0%)', 2000.0, 400.0),
+ ('tax_10 (10.0%)', 2400.0, 240.0),
+ ('Total Sales', '', 640.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_account_tax, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_grouped_account_tax._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 640.0),
+ ('400000 Product Sales', '', 320.0),
+ ('tax_20_affect_base (20.0%)', 1000.0, 200.0),
+ ('tax_10 (10.0%)', 1200.0, 120.0),
+ ('Total 400000 Product Sales', '', 320.0),
+ ('400000.2 Product Sales', '', 320.0),
+ ('tax_20_affect_base (20.0%)', 1000.0, 200.0),
+ ('tax_10 (10.0%)', 1200.0, 120.0),
+ ('Total 400000.2 Product Sales', '', 320.0),
+ ('Total Sales', '', 640.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_tax_account, date_from_str, date_to_str)
+ report_lines = self.report_grouped_tax_account._get_lines(options)
+ self.assertLinesValues(
+ report_lines,
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 640.0),
+ ('tax_20_affect_base (20.0%)', '', 400.0),
+ ('400000 Product Sales', 1000.0, 200.0),
+ ('400000.2 Product Sales', 1000.0, 200.0),
+ ('Total tax_20_affect_base (20.0%)', '', 400.0),
+ ('tax_10 (10.0%)', '', 240.0),
+ ('400000 Product Sales', 1200.0, 120.0),
+ ('400000.2 Product Sales', 1200.0, 120.0),
+ ('Total tax_10 (10.0%)', '', 240.0),
+ ('Total Sales', '', 640.0),
+ ],
+ options,
+ )
+
+ tax_lines_with_caret_options = [report_line for report_line in report_lines if report_line.get('caret_options') == 'generic_tax_report']
+ expected_amls_tax_10 = invoice.line_ids.filtered(lambda x: x.tax_line_id == tax_10 or tax_10 in x.tax_ids)
+ expected_amls_tax_20 = invoice.line_ids.filtered(lambda x: x.tax_line_id == tax_20_affect_base or tax_20_affect_base in x.tax_ids)
+ expected_amls_based_on_tax_dict = {
+ 'tax_10 (10.0%)': expected_amls_tax_10,
+ 'tax_20_affect_base (20.0%)': expected_amls_tax_20,
+ }
+ self.checkAmlsRedirection(self.report_grouped_tax_account, options, tax_lines_with_caret_options, expected_amls_based_on_tax_dict)
+
+ def test_tax_group_shared_tax(self):
+ tax_10 = self.env['account.tax'].create({
+ 'name': "tax_10",
+ 'amount_type': 'percent',
+ 'amount': 10.0,
+ 'type_tax_use': 'none',
+ })
+ tax_20 = self.env['account.tax'].create({
+ 'name': "tax_20",
+ 'amount_type': 'percent',
+ 'amount': 20.0,
+ 'type_tax_use': 'none',
+ })
+ tax_30 = self.env['account.tax'].create({
+ 'name': "tax_30",
+ 'amount_type': 'percent',
+ 'amount': 30.0,
+ 'type_tax_use': 'none',
+ })
+ tax_group_10_20 = self.env['account.tax'].create({
+ 'name': "tax_group_10_20",
+ 'amount_type': 'group',
+ 'children_tax_ids': [Command.set((tax_10 + tax_20).ids)],
+ 'type_tax_use': 'sale',
+ })
+ tax_group_10_30 = self.env['account.tax'].create({
+ 'name': "tax_group_10_30",
+ 'amount_type': 'group',
+ 'children_tax_ids': [Command.set((tax_10 + tax_30).ids)],
+ 'type_tax_use': 'sale',
+ })
+
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': '2019-01-01',
+ 'date': '2019-01-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'base line',
+ 'account_id': self.revenue_1.id,
+ 'price_unit': 1000.0,
+ 'tax_ids': [Command.set(tax_group_10_20.ids)],
+ }),
+ Command.create({
+ 'name': 'base line',
+ 'account_id': self.revenue_1.id,
+ 'price_unit': 2000.0,
+ 'tax_ids': [Command.set(tax_group_10_30.ids)],
+ }),
+ ],
+ })
+ invoice.action_post()
+
+ date_from_str = '2019-01-01'
+ date_to_str = '2019-01-31'
+ options = self._generate_options(self.report_generic, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_generic._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 1100.0),
+ ('tax_group_10_20', 1000.0, 300.0),
+ ('tax_group_10_30', 2000.0, 800.0),
+ ('Total Sales', '', 1100.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_account_tax, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_grouped_account_tax._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 1100.0),
+ ('400000 Product Sales', '', 1100.0),
+ ('tax_group_10_20', 1000.0, 300.0),
+ ('tax_group_10_30', 2000.0, 800.0),
+ ('Total 400000 Product Sales', '', 1100.0),
+ ('Total Sales', '', 1100.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_tax_account, date_from_str, date_to_str)
+ report_lines = self.report_grouped_tax_account._get_lines(options)
+ self.assertLinesValues(
+ report_lines,
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 1100.0),
+ ('tax_group_10_20', '', 300.0),
+ ('400000 Product Sales', 1000.0, 300.0),
+ ('Total tax_group_10_20', '', 300.0),
+ ('tax_group_10_30', '', 800.0),
+ ('400000 Product Sales', 2000.0, 800.0),
+ ('Total tax_group_10_30', '', 800.0),
+ ('Total Sales', '', 1100.0),
+ ],
+ options,
+ )
+
+ tax_lines_with_caret_options = [report_line for report_line in report_lines if report_line.get('caret_options') == 'generic_tax_report']
+ expected_amls_tax_group_10_20 = invoice.line_ids.filtered(lambda x: x.group_tax_id == tax_group_10_20 or tax_group_10_20 in x.tax_ids)
+ expected_amls_tax_group_10_30 = invoice.line_ids.filtered(lambda x: x.group_tax_id == tax_group_10_30 or tax_group_10_30 in x.tax_ids)
+ expected_amls_based_on_tax_dict = {
+ 'tax_group_10_20': expected_amls_tax_group_10_20,
+ 'tax_group_10_30': expected_amls_tax_group_10_30,
+ }
+ self.checkAmlsRedirection(self.report_grouped_tax_account, options, tax_lines_with_caret_options, expected_amls_based_on_tax_dict)
+
+ # Same with tax_10 as a sale tax.
+ tax_10.type_tax_use = 'sale'
+
+ options = self._generate_options(self.report_generic, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_generic._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 1100.0),
+ ("tax_10 (10.0%)" , 3000.0, 300.0),
+ ("tax_20 (20.0%)" , 1000.0, 200.0),
+ ("tax_30 (30.0%)" , 2000.0, 600),
+ ('Total Sales', '', 1100.0),
+ ],
+ options,
+ )
+
+ # Same with tax_20 as a sale tax.
+ tax_10.type_tax_use = 'none'
+ tax_20.type_tax_use = 'sale'
+
+ self.assertLinesValues(
+ self.report_generic._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 1100.0),
+ ("tax_10 (10.0%)" , 1000.0, 100.0),
+ ("tax_20 (20.0%)" , 1000.0, 200.0),
+ ('tax_group_10_30', 2000.0, 800.0),
+ ('Total Sales', '', 1100.0),
+ ],
+ options,
+ )
+
+ def test_tax_group_of_taxes_affected_by_other(self):
+ tax_10_affect_base = self.env['account.tax'].create({
+ 'name': "tax_10_affect_base",
+ 'amount_type': 'percent',
+ 'amount': 10.0,
+ 'include_base_amount': True,
+ })
+ tax_20_affect_base = self.env['account.tax'].create({
+ 'name': "tax_20_affect_base",
+ 'amount_type': 'percent',
+ 'amount': 20.0,
+ 'include_base_amount': True,
+ 'type_tax_use': 'none',
+ })
+ tax_10 = self.env['account.tax'].create({
+ 'name': "tax_10",
+ 'amount_type': 'percent',
+ 'amount': 10.0,
+ 'type_tax_use': 'none',
+ })
+ tax_group = self.env['account.tax'].create({
+ 'name': "tax_group",
+ 'amount_type': 'group',
+ 'children_tax_ids': [Command.set((tax_20_affect_base + tax_10).ids)],
+ 'type_tax_use': 'sale',
+ })
+
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': '2019-01-01',
+ 'date': '2019-01-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'base line',
+ 'account_id': self.revenue_1.id,
+ 'price_unit': 1000.0,
+ 'tax_ids': [Command.set((tax_10_affect_base + tax_group).ids)],
+ }),
+ ],
+ })
+ invoice.action_post()
+
+ date_from_str = '2019-01-01'
+ date_to_str = '2019-01-31'
+ options = self._generate_options(self.report_generic, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_generic._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 452.0),
+ ('tax_10_affect_base (10.0%)', 1000.0, 100.0),
+ ('tax_group', 1100.0, 352.0),
+ ('Total Sales', '', 452.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_account_tax, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_grouped_account_tax._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 452.0),
+ ('400000 Product Sales', '', 452.0),
+ ('tax_10_affect_base (10.0%)', 1000.0, 100.0),
+ ('tax_group', 1100.0, 352.0),
+ ('Total 400000 Product Sales', '', 452.0),
+ ('Total Sales', '', 452.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_tax_account, date_from_str, date_to_str)
+ report_lines = self.report_grouped_tax_account._get_lines(options)
+ self.assertLinesValues(
+ report_lines,
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 452.0),
+ ('tax_10_affect_base (10.0%)', '', 100.0),
+ ('400000 Product Sales', 1000.0, 100.0),
+ ('Total tax_10_affect_base (10.0%)', '', 100.0),
+ ('tax_group', '', 352.0),
+ ('400000 Product Sales', 1100.0, 352.0),
+ ('Total tax_group', '', 352.0),
+ ('Total Sales', '', 452.0),
+ ],
+ options,
+ )
+
+ tax_lines_with_caret_options = [report_line for report_line in report_lines if report_line.get('caret_options') == 'generic_tax_report']
+ expected_amls_tax_10_affect_base = invoice.line_ids.filtered(lambda x: x.tax_line_id == tax_10_affect_base or tax_10_affect_base in x.tax_ids)
+ expected_amls_tax_group = invoice.line_ids.filtered(lambda x: x.tax_line_id or x.tax_ids)
+ expected_amls_based_on_tax_dict = {
+ 'tax_10_affect_base (10.0%)': expected_amls_tax_10_affect_base,
+ 'tax_group': expected_amls_tax_group,
+ }
+ self.checkAmlsRedirection(self.report_grouped_tax_account, options, tax_lines_with_caret_options, expected_amls_based_on_tax_dict)
+
+ def test_mixed_all_type_tax_use_same_line(self):
+ tax_10 = self.env['account.tax'].create({
+ 'name': "tax_10",
+ 'amount_type': 'percent',
+ 'amount': 10.0,
+ 'type_tax_use': 'sale',
+ })
+ tax_20 = self.env['account.tax'].create({
+ 'name': "tax_20",
+ 'amount_type': 'percent',
+ 'amount': 20.0,
+ 'type_tax_use': 'purchase',
+ })
+ tax_30 = self.env['account.tax'].create({
+ 'name': "tax_30",
+ 'amount_type': 'percent',
+ 'amount': 30.0,
+ 'type_tax_use': 'none',
+ })
+
+ move_form = Form(self.env['account.move'].with_context(default_move_type='entry'))
+ move_form.date = fields.Date.from_string('2019-01-01')
+ with move_form.line_ids.new() as line_form:
+ line_form.name = 'debit line'
+ line_form.account_id = self.revenue_1
+ line_form.debit = 1000.0
+ line_form.tax_ids.clear()
+ line_form.tax_ids.add(tax_10)
+ line_form.tax_ids.add(tax_20)
+ line_form.tax_ids.add(tax_30)
+ with move_form.line_ids.new() as line_form:
+ line_form.name = 'credit line'
+ line_form.account_id = self.revenue_2
+ line_form.credit = 1600
+ move = move_form.save()
+ move.action_post()
+
+ date_from_str = '2019-01-01'
+ date_to_str = '2019-01-31'
+ options = self._generate_options(self.report_generic, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_generic._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', -100.0),
+ ('tax_10 (10.0%)', -1000.0, -100.0),
+ ('Total Sales', '', -100.0),
+ ('Purchases', '', 200.0),
+ ('tax_20 (20.0%)', 1000.0, 200.0),
+ ('Total Purchases', '', 200.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_account_tax, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_grouped_account_tax._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', -100.0),
+ ('400000 Product Sales', '', -100.0),
+ ('tax_10 (10.0%)', -1000.0, -100.0),
+ ('Total 400000 Product Sales', '', -100.0),
+ ('Total Sales', '', -100.0),
+ ('Purchases', '', 200.0),
+ ('400000 Product Sales', '', 200.0),
+ ('tax_20 (20.0%)', 1000.0, 200.0),
+ ('Total 400000 Product Sales', '', 200.0),
+ ('Total Purchases', '', 200.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_tax_account, date_from_str, date_to_str)
+ report_lines = self.report_grouped_tax_account._get_lines(options)
+ self.assertLinesValues(
+ report_lines,
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', -100.0),
+ ('tax_10 (10.0%)', '', -100.0),
+ ('400000 Product Sales', -1000.0, -100.0),
+ ('Total tax_10 (10.0%)', '', -100.0),
+ ('Total Sales', '', -100.0),
+ ('Purchases', '', 200.0),
+ ('tax_20 (20.0%)', '', 200.0),
+ ('400000 Product Sales', 1000.0, 200.0),
+ ('Total tax_20 (20.0%)', '', 200.0),
+ ('Total Purchases', '', 200.0),
+ ],
+ options,
+ )
+
+ tax_lines_with_caret_options = [report_line for report_line in report_lines if report_line.get('caret_options') == 'generic_tax_report']
+ expected_amls_tax_10 = move.line_ids.filtered(lambda x: x.tax_line_id == tax_10 or x.tax_ids)
+ expected_amls_tax_20 = move.line_ids.filtered(lambda x: x.tax_line_id == tax_20 or x.tax_ids)
+ expected_amls_based_on_tax_dict = {
+ 'tax_10 (10.0%)': expected_amls_tax_10,
+ 'tax_20 (20.0%)': expected_amls_tax_20,
+ }
+ self.checkAmlsRedirection(self.report_grouped_tax_account, options, tax_lines_with_caret_options, expected_amls_based_on_tax_dict)
+
+ def test_mixed_all_type_tax_on_different_line(self):
+ tax_10 = self.env['account.tax'].create({
+ 'name': "tax_10",
+ 'amount_type': 'percent',
+ 'amount': 10.0,
+ 'type_tax_use': 'sale',
+ })
+ tax_20 = self.env['account.tax'].create({
+ 'name': "tax_20",
+ 'amount_type': 'percent',
+ 'amount': 20.0,
+ 'type_tax_use': 'purchase',
+ })
+ tax_30 = self.env['account.tax'].create({
+ 'name': "tax_30",
+ 'amount_type': 'percent',
+ 'amount': 30.0,
+ 'type_tax_use': 'none',
+ })
+
+ move_form = Form(self.env['account.move'].with_context(default_move_type='entry'))
+ move_form.date = fields.Date.from_string('2019-01-01')
+ for dummy in range(2):
+ for tax in tax_10 + tax_20 + tax_30:
+ with move_form.line_ids.new() as line_form:
+ line_form.name = 'debit line'
+ line_form.account_id = self.revenue_1
+ line_form.debit = 500.0
+ line_form.tax_ids.clear()
+ line_form.tax_ids.add(tax)
+ with move_form.line_ids.new() as line_form:
+ line_form.name = 'credit line'
+ line_form.account_id = self.revenue_2
+ line_form.credit = 3600
+ move = move_form.save()
+ move.action_post()
+
+ date_from_str = '2019-01-01'
+ date_to_str = '2019-01-31'
+ options = self._generate_options(self.report_generic, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_generic._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', -100.0),
+ ('tax_10 (10.0%)', -1000.0, -100.0),
+ ('Total Sales', '', -100.0),
+ ('Purchases', '', 200.0),
+ ('tax_20 (20.0%)', 1000.0, 200.0),
+ ('Total Purchases', '', 200.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_account_tax, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_grouped_account_tax._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', -100.0),
+ ('400000 Product Sales', '', -100.0),
+ ('tax_10 (10.0%)', -1000.0, -100.0),
+ ('Total 400000 Product Sales', '', -100.0),
+ ('Total Sales', '', -100.0),
+ ('Purchases', '', 200.0),
+ ('400000 Product Sales', '', 200.0),
+ ('tax_20 (20.0%)', 1000.0, 200.0),
+ ('Total 400000 Product Sales', '', 200.0),
+ ('Total Purchases', '', 200.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_tax_account, date_from_str, date_to_str)
+ report_lines = self.report_grouped_tax_account._get_lines(options)
+ self.assertLinesValues(
+ report_lines,
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', -100.0),
+ ('tax_10 (10.0%)', '', -100.0),
+ ('400000 Product Sales', -1000.0, -100.0),
+ ('Total tax_10 (10.0%)', '', -100.0),
+ ('Total Sales', '', -100.0),
+ ('Purchases', '', 200.0),
+ ('tax_20 (20.0%)', '', 200.0),
+ ('400000 Product Sales', 1000.0, 200.0),
+ ('Total tax_20 (20.0%)', '', 200.0),
+ ('Total Purchases', '', 200.0),
+ ],
+ options,
+ )
+
+ tax_lines_with_caret_options = [report_line for report_line in report_lines if report_line.get('caret_options') == 'generic_tax_report']
+ expected_amls_tax_10 = move.line_ids.filtered(lambda x: x.tax_line_id == tax_10 or tax_10 in x.tax_ids)
+ expected_amls_tax_20 = move.line_ids.filtered(lambda x: x.tax_line_id == tax_20 or tax_20 in x.tax_ids)
+ expected_amls_based_on_tax_dict = {
+ 'tax_10 (10.0%)': expected_amls_tax_10,
+ 'tax_20 (20.0%)': expected_amls_tax_20,
+ }
+ self.checkAmlsRedirection(self.report_grouped_tax_account, options, tax_lines_with_caret_options, expected_amls_based_on_tax_dict)
+
+ def test_tax_report_custom_edition_tax_line(self):
+ ''' When on a journal entry, a tax line is edited manually by the user, it could lead to a broken mapping
+ between the original tax details and the edited tax line. In that case, some extra tax details are generated
+ on the tax line in order to reflect this edition. This test is there to ensure the tax report is well handling
+ such behavior.
+ '''
+ tax_10 = self.env['account.tax'].create({
+ 'name': "tax_10",
+ 'amount_type': 'percent',
+ 'amount': 10.0,
+ 'type_tax_use': 'sale',
+ })
+ tax_20 = self.env['account.tax'].create({
+ 'name': "tax_20",
+ 'amount_type': 'percent',
+ 'amount': 20.0,
+ 'type_tax_use': 'sale',
+ })
+
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': '2019-01-01',
+ 'date': '2019-01-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'base line',
+ 'account_id': self.revenue_1.id,
+ 'price_unit': 1000.0,
+ 'tax_ids': [Command.set((tax_10 + tax_20).ids)],
+ }),
+ ],
+ })
+ tax_10_line = invoice.line_ids.filtered(lambda x: x.tax_repartition_line_id.tax_id == tax_10)
+ tax_20_line = invoice.line_ids.filtered(lambda x: x.tax_repartition_line_id.tax_id == tax_20)
+ receivable_line = invoice.line_ids.filtered(lambda x: x.account_id.account_type == 'asset_receivable')
+ invoice.write({'line_ids': [
+ Command.update(tax_10_line.id, {'account_id': self.revenue_2.id}),
+ Command.update(tax_20_line.id, {'account_id': self.revenue_2.id, 'credit': 201.0}),
+ Command.update(receivable_line.id, {'debit': 1301.0}),
+ ]})
+ invoice.action_post()
+
+ date_from_str = '2019-01-01'
+ date_to_str = '2019-01-31'
+ options = self._generate_options(self.report_generic, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_generic._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 301.0),
+ ('tax_10 (10.0%)', 1000.0, 100.0),
+ ('tax_20 (20.0%)', 1000.0, 201.0),
+ ('Total Sales', '', 301.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_account_tax, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_grouped_account_tax._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 301.0),
+ ('400000 Product Sales', '', 301.0),
+ ('tax_10 (10.0%)', 1000.0, 100.0),
+ ('tax_20 (20.0%)', 1000.0, 201.0),
+ ('Total 400000 Product Sales', '', 301.0),
+ ('Total Sales', '', 301.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_tax_account, date_from_str, date_to_str)
+ report_lines = self.report_grouped_tax_account._get_lines(options)
+ self.assertLinesValues(
+ report_lines,
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 301.0),
+ ('tax_10 (10.0%)', '', 100.0),
+ ('400000 Product Sales', 1000.0, 100.0),
+ ('Total tax_10 (10.0%)', '', 100.0),
+ ('tax_20 (20.0%)', '', 201.0),
+ ('400000 Product Sales', 1000.0, 201.0),
+ ('Total tax_20 (20.0%)', '', 201.0),
+ ('Total Sales', '', 301.0),
+ ],
+ options,
+ )
+
+ tax_lines_with_caret_options = [report_line for report_line in report_lines if report_line.get('caret_options') == 'generic_tax_report']
+ expected_amls_tax_10 = invoice.line_ids.filtered(lambda x: x.tax_line_id == tax_10 or tax_10 in x.tax_ids)
+ expected_amls_tax_20 = invoice.line_ids.filtered(lambda x: x.tax_line_id == tax_20 or tax_20 in x.tax_ids)
+ expected_amls_based_on_tax_dict = {
+ 'tax_10 (10.0%)': expected_amls_tax_10,
+ 'tax_20 (20.0%)': expected_amls_tax_20,
+ }
+ self.checkAmlsRedirection(self.report_grouped_tax_account, options, tax_lines_with_caret_options, expected_amls_based_on_tax_dict)
+
+ def test_tax_report_comparisons(self):
+ tax_10 = self.env['account.tax'].create({
+ 'name': "tax_10",
+ 'amount_type': 'percent',
+ 'amount': 10.0,
+ })
+ tax_20 = self.env['account.tax'].create({
+ 'name': "tax_20",
+ 'amount_type': 'percent',
+ 'amount': 20.0,
+ })
+ tax_30 = self.env['account.tax'].create({
+ 'name': "tax_30",
+ 'amount_type': 'percent',
+ 'amount': 30.0,
+ })
+
+ invoices = self.env['account.move'].create([{
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': inv_date,
+ 'date': inv_date,
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'base line',
+ 'account_id': account.id,
+ 'price_unit': 1000.0,
+ 'tax_ids': [Command.set(taxes.ids)],
+ }),
+ ],
+ } for inv_date, taxes, account in (
+ ('2019-03-01', tax_10, self.revenue_1),
+ ('2019-02-01', tax_20 + tax_30, self.revenue_2),
+ ('2019-01-01', tax_30, self.revenue_1),
+ )])
+ invoices.action_post()
+
+ date_from_str = '2019-03-01'
+ date_to_str = '2019-03-31'
+ options = self._generate_options(self.report_generic, date_from_str, date_to_str)
+ options = self._update_comparison_filter(options, self.report_generic, 'previous_period', 2)
+ self.assertLinesValues(
+ self.report_generic._get_lines(options),
+ # Name NET TAX NET TAX NET TAX
+ [ 0, 1, 2, 3, 4, 5, 6],
+ [
+ ('Sales', '', 100.0, '', 500.0, '', 300.0),
+ ('tax_10 (10.0%)', 1000.0, 100.0, 0.0, 0.0, 0.0, 0.0),
+ ('tax_20 (20.0%)', 0.0, 0.0, 1000.0, 200.0, 0.0, 0.0),
+ ('tax_30 (30.0%)', 0.0, 0.0, 1000.0, 300.0, 1000.0, 300.0),
+ ('Total Sales', '', 100.0, '', 500.0, '', 300.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_account_tax, date_from_str, date_to_str)
+ options = self._update_comparison_filter(options, self.report_grouped_account_tax, 'previous_period', 2)
+ self.assertLinesValues(
+ self.report_grouped_account_tax._get_lines(options),
+ # Name NET TAX NET TAX NET TAX
+ [ 0, 1, 2, 3, 4, 5, 6],
+ [
+ ('Sales', '', 100.0, '', 500.0, '', 300.0),
+ ('400000 Product Sales', '', 100.0, '', 0.0, '', 300.0),
+ ('tax_10 (10.0%)', 1000.0, 100.0, 0.0, 0.0, 0.0, 0.0),
+ ('tax_30 (30.0%)', 0.0, 0.0, 0.0, 0.0, 1000.0, 300.0),
+ ('Total 400000 Product Sales', '', 100.0, '', 0.0, '', 300.0),
+ ('400000.2 Product Sales', '', 0.0, '', 500.0, '', 0.0),
+ ('tax_20 (20.0%)', 0.0, 0.0, 1000.0, 200.0, 0.0, 0.0),
+ ('tax_30 (30.0%)', 0.0, 0.0, 1000.0, 300.0, 0.0, 0.0),
+ ('Total 400000.2 Product Sales', '', 0.0, '', 500.0, '', 0.0),
+ ('Total Sales', '', 100.0, '', 500.0, '', 300.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_tax_account, date_from_str, date_to_str)
+ options = self._update_comparison_filter(options, self.report_grouped_tax_account, 'previous_period', 2)
+ self.assertLinesValues(
+ self.report_grouped_tax_account._get_lines(options),
+ # Name NET TAX NET TAX NET TAX
+ [ 0, 1, 2, 3, 4, 5, 6],
+ [
+ ('Sales', '', 100.0, '', 500.0, '', 300.0),
+ ('tax_10 (10.0%)', '', 100.0, '', 0.0, '', 0.0),
+ ('400000 Product Sales', 1000.0, 100.0, 0.0, 0.0, 0.0, 0.0),
+ ('Total tax_10 (10.0%)', '', 100.0, '', 0.0, '', 0.0),
+ ('tax_20 (20.0%)', '', 0.0, '', 200.0, '', 0.0),
+ ('400000.2 Product Sales', 0.0, 0.0, 1000.0, 200.0, 0.0, 0.0),
+ ('Total tax_20 (20.0%)', '', 0.0, '', 200.0, '', 0.0),
+ ('tax_30 (30.0%)', '', 0.0, '', 300.0, '', 300.0),
+ ('400000 Product Sales', 0.0, 0.0, 0.0, 0.0, 1000.0, 300.0),
+ ('400000.2 Product Sales', 0.0, 0.0, 1000.0, 300.0, 0.0, 0.0),
+ ('Total tax_30 (30.0%)', '', 0.0, '', 300.0, '', 300.0),
+ ('Total Sales', '', 100.0, '', 500.0, '', 300.0),
+ ],
+ options,
+ )
+
+ def test_affect_base_with_repetitions(self):
+ affecting_tax = self.env['account.tax'].create({
+ 'name': 'Affecting',
+ 'amount': 42,
+ 'amount_type': 'percent',
+ 'type_tax_use': 'sale',
+ 'include_base_amount': True,
+ 'sequence': 0,
+ # We use default repartition: 1 base line, 1 100% tax line
+ })
+
+ affected_tax = self.env['account.tax'].create({
+ 'name': 'Affected',
+ 'amount': 10,
+ 'amount_type': 'percent',
+ 'type_tax_use': 'sale',
+ 'sequence': 1
+ # We use default repartition: 1 base line, 1 100% tax line
+ })
+
+ # Create an invoice combining our taxes (1 line with each alone, and 1 line with both)
+ move = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': '2021-08-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': "affecting",
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'quantity': 1.0,
+ 'price_unit': 100.0,
+ 'tax_ids': affecting_tax.ids,
+ }),
+
+ Command.create({
+ 'name': "affected",
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'quantity': 1.0,
+ 'price_unit': 100.0,
+ 'tax_ids': affected_tax.ids,
+ }),
+
+ Command.create({
+ 'name': "affecting + affected",
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'quantity': 1.0,
+ 'price_unit': 100.0,
+ 'tax_ids': (affecting_tax + affected_tax).ids,
+ }),
+ ]
+ })
+
+ move.action_post()
+
+ # Check generic tax report
+ options = self._generate_options(self.report_generic, move.date, move.date)
+ self.assertLinesValues(
+ self.report_generic._get_lines(options),
+ # Name Net Tax
+ [ 0, 1, 2],
+ [
+ ("Sales", '', 108.2),
+ ("%s (42.0%%)" % affecting_tax.name, 200, 84),
+ ("%s (10.0%%)" % affected_tax.name, 242, 24.2),
+ ("Total Sales", '', 108.2),
+ ],
+ options,
+ )
+
+ def test_tax_multiple_repartition_lines(self):
+ tax = self.env['account.tax'].create({
+ 'name': "tax",
+ 'amount_type': 'percent',
+ 'amount': 10.0,
+ 'invoice_repartition_line_ids': [
+ Command.create({'repartition_type': 'base'}),
+
+ Command.create({
+ 'factor_percent': 40,
+ 'repartition_type': 'tax',
+ }),
+ Command.create({
+ 'factor_percent': 60,
+ 'repartition_type': 'tax',
+ }),
+ ],
+ 'refund_repartition_line_ids': [
+ Command.create({'repartition_type': 'base'}),
+
+ Command.create({
+ 'factor_percent': 40,
+ 'repartition_type': 'tax',
+ }),
+ Command.create({
+ 'factor_percent': 60,
+ 'repartition_type': 'tax',
+ }),
+ ],
+ })
+
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': '2019-01-01',
+ 'date': '2019-01-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'base line',
+ 'account_id': self.revenue_1.id,
+ 'price_unit': 1000.0,
+ 'tax_ids': [Command.set(tax.ids)],
+ }),
+ ],
+ })
+ invoice.action_post()
+
+ date_from_str = '2019-01-01'
+ date_to_str = '2019-01-31'
+ options = self._generate_options(self.report_generic, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_generic._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 100.0),
+ ('tax (10.0%)', 1000.0, 100.0),
+ ('Total Sales', '', 100.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_account_tax, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_grouped_account_tax._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 100.0),
+ ('400000 Product Sales', '', 100.0),
+ ('tax (10.0%)', 1000.0, 100.0),
+ ('Total 400000 Product Sales', '', 100.0),
+ ('Total Sales', '', 100.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_tax_account, date_from_str, date_to_str)
+ report_lines = self.report_grouped_tax_account._get_lines(options)
+ self.assertLinesValues(
+ report_lines,
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 100.0),
+ ('tax (10.0%)', '', 100.0),
+ ('400000 Product Sales', 1000.0, 100.0),
+ ('Total tax (10.0%)', '', 100.0),
+ ('Total Sales', '', 100.0),
+ ],
+ options,
+ )
+
+ tax_lines_with_caret_options = [report_line for report_line in report_lines if report_line.get('caret_options') == 'generic_tax_report']
+ expected_amls = invoice.line_ids.filtered(lambda x: x.tax_line_id or x.tax_ids)
+ expected_amls_based_on_tax_dict = {
+ 'tax (10.0%)': expected_amls,
+ }
+ self.checkAmlsRedirection(self.report_grouped_tax_account, options, tax_lines_with_caret_options, expected_amls_based_on_tax_dict)
+
+ @freeze_time('2019-01-01')
+ def test_tax_invoice_completely_refund(self):
+ tax = self.env['account.tax'].create({
+ 'name': "tax",
+ 'amount_type': 'percent',
+ 'amount': 10.0,
+ 'type_tax_use': 'sale',
+ })
+
+ invoice = self.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': self.partner_a.id,
+ 'invoice_date': '2019-01-01',
+ 'date': '2019-01-01',
+ 'invoice_line_ids': [
+ Command.create({
+ 'name': 'base line',
+ 'account_id': self.revenue_1.id,
+ 'price_unit': 1000.0,
+ 'tax_ids': [Command.set(tax.ids)],
+ }),
+ ],
+ })
+ invoice.action_post()
+
+ self.env['account.move.reversal']\
+ .with_context(active_model="account.move", active_ids=invoice.ids)\
+ .create({
+ 'reason': "test_tax_invoice_completely_refund",
+ 'journal_id': invoice.journal_id.id,
+ })\
+ .modify_moves()
+
+ date_from_str = '2019-01-01'
+ date_to_str = '2019-01-31'
+ options = self._generate_options(self.report_generic, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_generic._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 0.0),
+ ('tax (10.0%)', 0.0, 0.0),
+ ('Total Sales', '', 0.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_account_tax, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_grouped_account_tax._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 0.0),
+ ('400000 Product Sales', '', 0.0),
+ ('tax (10.0%)', 0.0, 0.0),
+ ('Total 400000 Product Sales', '', 0.0),
+ ('Total Sales', '', 0.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_tax_account, date_from_str, date_to_str)
+ report_lines = self.report_grouped_tax_account._get_lines(options)
+ self.assertLinesValues(
+ report_lines,
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 0.0),
+ ('tax (10.0%)', '', 0.0),
+ ('400000 Product Sales', 0.0, 0.0),
+ ('Total tax (10.0%)', '', 0.0),
+ ('Total Sales', '', 0.0),
+ ],
+ options,
+ )
+
+ tax_lines_with_caret_options = [report_line for report_line in report_lines if report_line.get('caret_options') == 'generic_tax_report']
+ expected_amls = invoice.line_ids.filtered(lambda x: x.tax_line_id or x.tax_ids) + invoice.reversal_move_ids.line_ids.filtered(lambda x: x.tax_line_id or x.tax_ids)
+ expected_amls_based_on_tax_dict = {
+ 'tax (10.0%)': expected_amls,
+ }
+ self.checkAmlsRedirection(self.report_grouped_tax_account, options, tax_lines_with_caret_options, expected_amls_based_on_tax_dict)
+
+ def test_tax_report_entry_move_2_opposite_invoice_lines(self):
+ tax = self.env['account.tax'].create({
+ 'name': "tax",
+ 'amount_type': 'percent',
+ 'amount': 10.0,
+ 'type_tax_use': 'sale',
+ })
+
+ # Form is used here for the dynamic tax line to get created automatically
+ move_form = Form(self.env['account.move']\
+ .with_context(default_move_type='entry'))
+ # {'invisible': [('move_type', 'not in', ['out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt'])]
+ move_form.date = '2022-02-01'
+
+ for name, account_id, debit, credit, tax_to_apply in (
+ ("invoice line in entry", self.company_data['default_account_revenue'], 0.0, 20.0, tax),
+ ("refund line in entry", self.company_data['default_account_revenue'], 10.0, 0.0, tax),
+ ("Receivable line in entry", self.company_data['default_account_receivable'], 11.0, 0.0, None),
+ ):
+ with move_form.line_ids.new() as line_form:
+ line_form.name = name
+ line_form.account_id = account_id
+ line_form.debit = debit
+ line_form.credit = credit
+ if tax_to_apply:
+ line_form.tax_ids.clear()
+ line_form.tax_ids.add(tax_to_apply)
+
+ move = move_form.save()
+ move.action_post()
+
+ date_from_str = '2022-02-01'
+ date_to_str = '2022-02-01'
+ options = self._generate_options(self.report_generic, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_generic._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 1.0),
+ ('tax (10.0%)', 10.0, 1.0),
+ ('Total Sales', '', 1.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_account_tax, date_from_str, date_to_str)
+ self.assertLinesValues(
+ self.report_grouped_account_tax._get_lines(options),
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 1.0),
+ ('400000 Product Sales', '', 1.0),
+ ('tax (10.0%)', 10.0, 1.0),
+ ('Total 400000 Product Sales', '', 1.0),
+ ('Total Sales', '', 1.0),
+ ],
+ options,
+ )
+
+ options = self._generate_options(self.report_grouped_tax_account, date_from_str, date_to_str)
+ report_lines = self.report_grouped_tax_account._get_lines(options)
+ self.assertLinesValues(
+ report_lines,
+ # Name NET TAX
+ [ 0, 1, 2],
+ [
+ ('Sales', '', 1.0),
+ ('tax (10.0%)', '', 1.0),
+ ('400000 Product Sales', 10.0, 1.0),
+ ('Total tax (10.0%)', '', 1.0),
+ ('Total Sales', '', 1.0),
+ ],
+ options,
+ )
+
+ tax_lines_with_caret_options = [report_line for report_line in report_lines if report_line.get('caret_options') == 'generic_tax_report']
+ expected_amls = move.line_ids.filtered(lambda x: x.tax_line_id or x.tax_ids)
+ expected_amls_based_on_tax_dict = {
+ 'tax (10.0%)': expected_amls,
+ }
+ self.checkAmlsRedirection(self.report_grouped_tax_account, options, tax_lines_with_caret_options, expected_amls_based_on_tax_dict)
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_tour_account_reports.py b/dev_odex30_accounting/odex30_account_reports/tests/test_tour_account_reports.py
new file mode 100644
index 0000000..0c214cc
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_tour_account_reports.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+from odoo import Command, fields
+from odoo.tests import tagged
+from odoo.addons.account.tests.common import AccountTestInvoicingHttpCommon
+
+
+@tagged('post_install', '-at_install')
+class TestTourAccountReports(AccountTestInvoicingHttpCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ today = fields.Date.today()
+ previous_year = fields.Date.from_string('%s-%s-01' % (today.year - 1, today.month))
+
+ cls.out_invoice_current_year = cls.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': cls.partner_a.id,
+ 'invoice_date': today,
+ 'date': today,
+ 'invoice_line_ids': [
+ Command.create({'name': 'line1', 'price_unit': 100.0}),
+ ],
+ })
+ cls.out_invoice_current_year.action_post()
+
+ cls.out_invoice_previous_year = cls.env['account.move'].create({
+ 'move_type': 'out_invoice',
+ 'partner_id': cls.partner_a.id,
+ 'invoice_date': previous_year,
+ 'date': previous_year,
+ 'invoice_line_ids': [
+ Command.create({'name': 'line1', 'price_unit': 500.0}),
+ ],
+ })
+ cls.out_invoice_previous_year.action_post()
+
+ def test_tour_account_reports(self):
+ self.start_tour("/odoo", 'account_reports_widgets', login=self.env.user.login)
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_tour_analytic_filters.py b/dev_odex30_accounting/odex30_account_reports/tests/test_tour_analytic_filters.py
new file mode 100644
index 0000000..0215e52
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_tour_analytic_filters.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+from odoo.tests import tagged
+from odoo.addons.account.tests.common import AccountTestInvoicingHttpCommon
+
+
+@tagged('post_install', '-at_install')
+class TestTourAccountAnalyticFilters(AccountTestInvoicingHttpCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.env.user.groups_id += cls.env.ref(
+ 'analytic.group_analytic_accounting')
+ cls.report = cls.env.ref('odex30_account_reports.profit_and_loss')
+ cls.report.write({'filter_analytic': True})
+ cls.analytic_plan = cls.env['account.analytic.plan'].create({
+ 'name': 'Plan',
+ })
+
+ cls.env['account.analytic.account'].create({
+ 'name': 'Time Off',
+ 'plan_id': cls.analytic_plan.id
+ })
+
+ def test_tour_account_report_analytic_filters(self):
+ self.start_tour("/odoo", 'account_reports_analytic_filters', login=self.env.user.login)
diff --git a/dev_odex30_accounting/odex30_account_reports/tests/test_trial_balance_report.py b/dev_odex30_accounting/odex30_account_reports/tests/test_trial_balance_report.py
new file mode 100644
index 0000000..838ada9
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/tests/test_trial_balance_report.py
@@ -0,0 +1,628 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=C0326
+from .common import TestAccountReportsCommon
+
+from odoo import fields, Command
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestTrialBalanceReport(TestAccountReportsCommon):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ # Give codes in company_1 to the accounts in company_2.
+ context = {'allowed_company_ids': [cls.company_data['company'].id, cls.company_data_2['company'].id]}
+ cls.company_data_2['default_account_payable'].with_context(context).code = '211010'
+ cls.company_data_2['default_account_revenue'].with_context(context).code = '400010'
+ cls.company_data_2['default_account_expense'].with_context(context).code = '600010'
+ cls.env['account.account'].search([
+ ('company_ids', '=', cls.company_data_2['company'].id),
+ ('account_type', '=', 'equity_unaffected')
+ ]).with_context(context).code = '999989'
+
+ # Entries in 2016 for company_1 to test the initial balance.
+ cls.move_2016_1 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2016-01-01'),
+ 'journal_id': cls.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'name': '2016_1_1', 'account_id': cls.company_data['default_account_payable'].id}),
+ (0, 0, {'debit': 200.0, 'credit': 0.0, 'name': '2016_1_2', 'account_id': cls.company_data['default_account_expense'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 300.0, 'name': '2016_1_3', 'account_id': cls.company_data['default_account_revenue'].id}),
+ ],
+ })
+ cls.move_2016_1.action_post()
+
+ # Entries in 2016 for company_2 to test the initial balance in multi-companies/multi-currencies.
+ cls.move_2016_2 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2016-06-01'),
+ 'journal_id': cls.company_data_2['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'name': '2016_2_1', 'account_id': cls.company_data_2['default_account_payable'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 100.0, 'name': '2016_2_2', 'account_id': cls.company_data_2['default_account_revenue'].id}),
+ ],
+ })
+ cls.move_2016_2.action_post()
+
+ # Entry in 2017 for company_1 to test the report at current date.
+ cls.move_2017_1 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2017-01-01'),
+ 'journal_id': cls.company_data['default_journal_sale'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'name': '2017_1_1', 'account_id': cls.company_data['default_account_receivable'].id}),
+ (0, 0, {'debit': 2000.0, 'credit': 0.0, 'name': '2017_1_2', 'account_id': cls.company_data['default_account_revenue'].id}),
+ (0, 0, {'debit': 3000.0, 'credit': 0.0, 'name': '2017_1_3', 'account_id': cls.company_data['default_account_revenue'].id}),
+ (0, 0, {'debit': 4000.0, 'credit': 0.0, 'name': '2017_1_4', 'account_id': cls.company_data['default_account_revenue'].id}),
+ (0, 0, {'debit': 5000.0, 'credit': 0.0, 'name': '2017_1_5', 'account_id': cls.company_data['default_account_revenue'].id}),
+ (0, 0, {'debit': 6000.0, 'credit': 0.0, 'name': '2017_1_6', 'account_id': cls.company_data['default_account_revenue'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 6000.0, 'name': '2017_1_7', 'account_id': cls.company_data['default_account_expense'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 7000.0, 'name': '2017_1_8', 'account_id': cls.company_data['default_account_expense'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 8000.0, 'name': '2017_1_9', 'account_id': cls.company_data['default_account_expense'].id}),
+ ],
+ })
+ cls.move_2017_1.action_post()
+
+ # Entry in 2017 for company_2 to test the current period in multi-companies/multi-currencies.
+ cls.move_2017_2 = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2017-06-01'),
+ 'journal_id': cls.company_data_2['default_journal_bank'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 400.0, 'credit': 0.0, 'name': '2017_2_1', 'account_id': cls.company_data_2['default_account_expense'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 400.0, 'name': '2017_2_2', 'account_id': cls.company_data_2['default_account_revenue'].id}),
+ ],
+ })
+ cls.move_2017_2.action_post()
+
+ # Archive 'default_journal_bank' to ensure archived entries are not filtered out.
+ cls.company_data_2['default_journal_bank'].active = False
+
+ # Deactive all currencies to ensure group_multi_currency is disabled.
+ cls.env['res.currency'].search([('name', '!=', 'USD')]).with_context(force_deactivate=True).active = False
+
+ cls.report = cls.env.ref('odex30_account_reports.trial_balance_report')
+
+ # -------------------------------------------------------------------------
+ # TESTS: Trial Balance
+ # -------------------------------------------------------------------------
+ def test_trial_balance_unaffected_earnings_current_fiscal_year(self):
+ def invoice_move(date):
+ return self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string(date),
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'name': 'payable', 'account_id': self.company_data['default_account_payable'].id}),
+ (0, 0, {'debit': 2000.0, 'credit': 0.0, 'name': 'expense', 'account_id': self.company_data['default_account_expense'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 3000.0, 'name': 'revenue', 'account_id': self.company_data['default_account_revenue'].id}),
+ ],
+ })
+
+ move_2009_12 = invoice_move('2009-12-31')
+ move_2009_12.action_post()
+
+ move_2010_01 = invoice_move('2010-01-31')
+ move_2010_01.action_post()
+
+ move_2010_02 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2010-02-01'),
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'name': 'payable', 'account_id': self.company_data['default_account_payable'].id}),
+ (0, 0, {'debit': 200.0, 'credit': 0.0, 'name': 'expense', 'account_id': self.company_data['default_account_expense'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 300.0, 'name': 'revenue', 'account_id': self.company_data['default_account_revenue'].id}),
+ ],
+ })
+ move_2010_02.action_post()
+
+ move_2010_03 = invoice_move('2010-03-01')
+ move_2010_03.action_post()
+
+ options = self._generate_options(self.report, fields.Date.from_string('2010-02-01'), fields.Date.from_string('2010-02-28'))
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # [ Initial Balance ] [ Balance ] [ Total ]
+ # Name Debit Credit Debit Credit Debit Credit
+ [ 0, 1, 2, 3, 4, 5, 6],
+ [
+ ('211000 Account Payable', 2000.0, 0.0, 100.0, 0.0, 2100.0, 0.0),
+ ('400000 Product Sales', 0.0, 3000.0, 0.0, 300.0, 0.0, 3300.0),
+ ('600000 Expenses', 2000.0, 0.0, 200.0, 0.0, 2200.0, 0.0),
+ ('999999 Undistributed Profits/Losses', 0.0, 1000.0, 0.0, 0.0, 0.0, 1000.0),
+ ('Total', 4000.0, 4000.0, 300.0, 300.0, 4300.0, 4300.0),
+ ],
+ options,
+ )
+
+ def test_trial_balance_unaffected_earnings_previous_fiscal_year(self):
+ def invoice_move(date):
+ return self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string(date),
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 1000.0, 'credit': 0.0, 'name': 'payable', 'account_id': self.company_data['default_account_payable'].id}),
+ (0, 0, {'debit': 2000.0, 'credit': 0.0, 'name': 'expense', 'account_id': self.company_data['default_account_expense'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 3000.0, 'name': 'revenue', 'account_id': self.company_data['default_account_revenue'].id}),
+ ],
+ })
+
+ move_2009_12 = invoice_move('2009-12-31')
+ move_2009_12.action_post()
+
+ move_2010_01 = invoice_move('2010-01-31')
+ move_2010_01.action_post()
+
+ move_2010_02 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2010-02-01'),
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ (0, 0, {'debit': 100.0, 'credit': 0.0, 'name': 'payable', 'account_id': self.company_data['default_account_payable'].id}),
+ (0, 0, {'debit': 200.0, 'credit': 0.0, 'name': 'expense', 'account_id': self.company_data['default_account_expense'].id}),
+ (0, 0, {'debit': 0.0, 'credit': 300.0, 'name': 'revenue', 'account_id': self.company_data['default_account_revenue'].id}),
+ ],
+ })
+ move_2010_02.action_post()
+
+ move_2010_03 = invoice_move('2010-03-01')
+ move_2010_03.action_post()
+
+ options = self._generate_options(self.report, fields.Date.from_string('2010-01-01'), fields.Date.from_string('2010-02-28'))
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # [ Initial Balance ] [ Balance ] [ Total ]
+ # Name Debit Credit Debit Credit Debit Credit
+ [ 0, 1, 2, 3, 4, 5, 6],
+ [
+ ('211000 Account Payable', 1000.0, 0.0, 1100.0, 0.0, 2100.0, 0.0),
+ ('400000 Product Sales', 0.0, 0.0, 0.0, 3300.0, 0.0, 3300.0),
+ ('600000 Expenses', 0.0, 0.0, 2200.0, 0.0, 2200.0, 0.0),
+ ('999999 Undistributed Profits/Losses', 0.0, 1000.0, 0.0, 0.0, 0.0, 1000.0),
+ ('Total', 1000.0, 1000.0, 3300.0, 3300.0, 4300.0, 4300.0),
+ ],
+ options,
+ )
+
+ def test_trial_balance_whole_report(self):
+ options = self._generate_options(self.report, fields.Date.from_string('2017-01-01'), fields.Date.from_string('2017-12-31'))
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # [ Initial Balance ] [ Balance ] [ Total ]
+ # Name Debit Credit Debit Credit Debit Credit
+ [ 0, 1, 2, 3, 4, 5, 6],
+ [
+ ('121000 Account Receivable', 0.0, 0.0, 1000.0, 0.0, 1000.0, 0.0),
+ ('211000 Account Payable', 100.0, 0.0, 0.0, 0.0, 100.0, 0.0),
+ ('211010 Account Payable', 50.0, 0.0, 0.0, 0.0, 50.0, 0.0),
+ ('400000 Product Sales', 0.0, 0.0, 20000.0, 0.0, 20000.0, 0.0),
+ ('400010 Product Sales', 0.0, 0.0, 0.0, 200.0, 0.0, 200.0),
+ ('600000 Expenses', 0.0, 0.0, 0.0, 21000.0, 0.0, 21000.0),
+ ('600010 Expenses', 0.0, 0.0, 200.0, 0.0, 200.0, 0.0),
+ ('999989 Undistributed Profits/Losses', 0.0, 50.0, 0.0, 0.0, 0.0, 50.0),
+ ('999999 Undistributed Profits/Losses', 0.0, 100.0, 0.0, 0.0, 0.0, 100.0),
+ ('Total', 150.0, 150.0, 21200.0, 21200.0, 21350.00, 21350.0),
+ ],
+ options,
+ )
+
+ def test_trial_balance_filter_journals(self):
+ self.env.companies = self.env.company
+
+ options = self._generate_options(self.report, fields.Date.from_string('2017-01-01'), fields.Date.from_string('2017-12-31'))
+ options = self._update_multi_selector_filter(options, 'journals', self.company_data['default_journal_sale'].ids)
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # [ Initial Balance ] [ Balance ] [ Total ]
+ # Name Debit Credit Debit Credit Debit Credit
+ [ 0, 1, 2, 3, 4, 5, 6],
+ [
+ ('121000 Account Receivable', 0.0, 0.0, 1000.0, 0.0, 1000.0, 0.0),
+ ('400000 Product Sales', 0.0, 0.0, 20000.0, 0.0, 20000.0, 0.0),
+ ('600000 Expenses', 0.0, 0.0, 0.0, 21000.0, 0.0, 21000.0),
+ ('Total', 0.0, 0.0, 21000.0, 21000.0, 21000.0, 21000.0),
+ ],
+ options,
+ )
+
+ def test_trial_balance_comparisons(self):
+ options = self._generate_options(self.report, '2017-01-01', '2017-12-31')
+ options = self._update_comparison_filter(options, self.report, 'previous_period', 1, fields.Date.from_string('2017-01-01'), fields.Date.from_string('2017-12-31'))
+ expected_header_values = [
+ {
+ 'name': '2016',
+ 'forced_options': {'date': {'string': '2016', 'period_type': 'fiscalyear', 'mode': 'range', 'date_from': '2016-01-01', 'date_to': '2016-12-31', 'currency_table_period_key': '_trial_balance_middle_periods'}}
+ },
+ {
+ 'name': '2017',
+ 'forced_options': {'date': {'string': '2017', 'period_type': 'fiscalyear', 'mode': 'range', 'date_from': '2017-01-01', 'date_to': '2017-12-31', 'filter': 'custom', 'currency_table_period_key': '_trial_balance_middle_periods'}}
+ },
+ ]
+
+ for i, val in enumerate(expected_header_values, start=1):
+ self.assertDictEqual(options['column_headers'][0][i], val)
+
+ # Rate for 2016 and 2017 is (1/3 (from 2016) * 366 + 1/2 (from 2017) * 365) / 731 => 0.416552668
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # [ Initial Balance ] [ 2016 ] [ 2017 ] [ Total ]
+ # Name Debit Credit Debit Credit Debit Credit Debit Credit
+ [ 0, 1, 2, 3, 4, 5, 6, 7, 8],
+ [
+ ('121000 Account Receivable', 0.0, 0.0, 0.0, 0.0, 1000.0, 0.0, 1000.0, 0.0),
+ ('211000 Account Payable', 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, 100.0, 0.0),
+ ('211010 Account Payable', 0.0, 0.0, 50.0, 0.0, 0.0, 0.0, 50.0, 0.0),
+ ('400000 Product Sales', 0.0, 0.0, 0.0, 300.0, 20000.0, 0.0, 20000.0, 0.0),
+ ('400010 Product Sales', 0.0, 0.0, 0.0, 41.66, 0.0, 166.62, 0.0, 166.62),
+ ('600000 Expenses', 0.0, 0.0, 200.0, 0.0, 0.0, 21000.0, 0.0, 21000.0),
+ ('600010 Expenses', 0.0, 0.0, 0.0, 0.0, 166.62, 0.0, 166.62, 0.0),
+ ('999989 Undistributed Profits/Losses', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 41.66),
+ ('999999 Undistributed Profits/Losses', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 100.0),
+ ('Total', 0.0, 0.0, 350.00, 341.66, 21166.62, 21166.62, 21316.62, 21308.28),
+ ],
+ options,
+ )
+
+ options['comparison']['period_order'] = 'descending'
+ options = self.report.get_options(options)
+
+ for i, val in enumerate(expected_header_values[::-1], start=1):
+ self.assertDictEqual(options['column_headers'][0][i], val)
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # [ Initial Balance ] [ 2017 ] [ 2016 ] [ Total ]
+ # Name Debit Credit Debit Credit Debit Credit Debit Credit
+ [ 0, 1, 2, 3, 4, 5, 6, 7, 8],
+ [
+ ('121000 Account Receivable', 0.0, 0.0, 1000.0, 0.0, 0.0, 0.0, 1000.0, 0.0),
+ ('211000 Account Payable', 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, 100.0, 0.0),
+ ('211010 Account Payable', 0.0, 0.0, 0.0, 0.0, 50.00, 0.0, 50.00, 0.0),
+ ('400000 Product Sales', 0.0, 0.0, 20000.0, 0.0, 0.0, 300.0, 20000.0, 0.0),
+ ('400010 Product Sales', 0.0, 0.0, 0.0, 166.62, 0.0, 41.66, 0.0, 166.62),
+ ('600000 Expenses', 0.0, 0.0, 0.0, 21000.0, 200.0, 0.0, 0.0, 21000.0),
+ ('600010 Expenses', 0.0, 0.0, 166.62, 0.0, 0.0, 0.0, 166.62, 0.0),
+ ('999989 Undistributed Profits/Losses', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 41.66),
+ ('999999 Undistributed Profits/Losses', 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 100.0),
+ ('Total', 0.0, 0.0, 21166.62, 21166.62, 350.00, 341.66, 21316.62, 21308.28),
+ ],
+ options,
+ )
+
+ def test_trial_with_disabled_comparison_filter(self):
+ self.report.filter_period_comparison = False
+ options = self._generate_options(self.report, fields.Date.from_string('2017-01-01'), fields.Date.from_string('2017-12-31'))
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # [ Initial Balance ] [ Balance ] [ Total ]
+ # Name Debit Credit Debit Credit Debit Credit
+ [ 0, 1, 2, 3, 4, 5, 6],
+ [
+ ('121000 Account Receivable', 0.0, 0.0, 1000.0, 0.0, 1000.0, 0.0),
+ ('211000 Account Payable', 100.0, 0.0, 0.0, 0.0, 100.0, 0.0),
+ ('211010 Account Payable', 50.00, 0.0, 0.0, 0.0, 50.00, 0.0),
+ ('400000 Product Sales', 0.0, 0.0, 20000.0, 0.0, 20000.0, 0.0),
+ ('400010 Product Sales', 0.0, 0.0, 0.0, 200.0, 0.0, 200.0),
+ ('600000 Expenses', 0.0, 0.0, 0.0, 21000.0, 0.0, 21000.0),
+ ('600010 Expenses', 0.0, 0.0, 200.0, 0.0, 200.0, 0.0),
+ ('999989 Undistributed Profits/Losses', 0.0, 50.0, 0.0, 0.0, 0.0, 50.0),
+ ('999999 Undistributed Profits/Losses', 0.0, 100.0, 0.0, 0.0, 0.0, 100.0),
+ ('Total', 150.00, 150.0, 21200.0, 21200.0, 21350.00, 21350.0),
+ ],
+ options,
+ )
+
+ def test_trial_balance_account_group_with_hole(self):
+ """
+ Let's say you have the following account groups: 10, 101, 1012
+ If you have entries for group 10 and 1012 but none for 101,
+ the trial balance report should work correctly
+
+ - 10 --> has entries
+ - 101 --> NO ENTRIES
+ - 1012 --> has entries
+
+ """
+
+ test_journal = self.env['account.journal'].create({
+ 'name': 'test journal',
+ 'code': 'TJ',
+ 'type': 'general',
+ })
+
+ self.env['account.group'].create([
+ {'name': 'Group_10', 'code_prefix_start': '10', 'code_prefix_end': '10'},
+ {'name': 'Group_101', 'code_prefix_start': '101', 'code_prefix_end': '101'},
+ {'name': 'Group_1012', 'code_prefix_start': '1012', 'code_prefix_end': '1012'},
+ ])
+
+ # Create the accounts.
+ account_a, account_a1 = self.env['account.account'].create([
+ {'code': '100000', 'name': 'Account A', 'account_type': 'asset_current'},
+ {'code': '101200', 'name': 'Account A1', 'account_type': 'asset_current'},
+ ])
+
+ move = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2017-06-01'),
+ 'journal_id': test_journal.id,
+ 'line_ids': [
+ Command.create({'debit': 100.0, 'credit': 0.0, 'name': 'account_a_1', 'account_id': account_a.id}),
+ Command.create({'debit': 0.0, 'credit': 100.0, 'name': 'account_a_2', 'account_id': account_a.id}),
+ Command.create({'debit': 200.0, 'credit': 0.0, 'name': 'account_a1_1', 'account_id': account_a1.id}),
+ Command.create({'debit': 0.0, 'credit': 200.0, 'name': 'account_a1_2', 'account_id': account_a1.id}),
+ ],
+ })
+ move.action_post()
+
+ options = self._generate_options(self.report, fields.Date.from_string('2017-06-01'), fields.Date.from_string('2017-06-01'))
+ options = self._update_multi_selector_filter(options, 'journals', test_journal.ids)
+ options['unfold_all'] = True
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ [ 0, 1, 2, 3, 4, 5, 6],
+ [
+ ['10 Group_10', 0.0, 0.0, 300.0, 300.0, 0.0, 0.0],
+ ['100000 Account A', 0.0, 0.0, 100.0, 100.0, 0.0, 0.0],
+ ['101 Group_101', 0.0, 0.0, 200.0, 200.0, 0.0, 0.0],
+ ['1012 Group_1012', 0.0, 0.0, 200.0, 200.0, 0.0, 0.0],
+ ['101200 Account A1', 0.0, 0.0, 200.0, 200.0, 0.0, 0.0],
+ ['Total', 0.0, 0.0, 300.0, 300.0, 0.0, 0.0]
+ ],
+ options,
+ )
+
+ def test_action_general_ledger(self):
+ """
+ This test will check that the action caret_option_open_general_ledger works as expected which means that
+ a default_filter_accounts is set and that in case of hierarchy, the group is unfolded
+ """
+ self.env['account.group'].create([
+ {'name': 'Group_6', 'code_prefix_start': '6', 'code_prefix_end': '6'},
+ ])
+ options = self._generate_options(self.report, '2017-06-01', '2017-06-01', default_options={'hierarchy': True, 'unfold_all': True})
+ lines = self.report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # [ Initial Balance ] [ Balance ] [ Total ]
+ # Name Debit Credit Debit Credit Debit Credit
+ [0, 1, 2, 3, 4, 5, 6],
+ [
+ ('6 Group_6', 0.0, 21000.0, 200.0, 0.0, 200.0, 21000.0),
+ ('600000 Expenses', 0.0, 21000.0, 0.0, 0.0, 0.0, 21000.0),
+ ('600010 Expenses', 0.0, 0.0, 200.0, 0.0, 200.0, 0.0),
+ ('(No Group)', 21150.0, 150.0, 0.0, 200.0, 21150.0, 350.0),
+ ('121000 Account Receivable', 1000.0, 0.0, 0.0, 0.0, 1000.0, 0.0),
+ ('211000 Account Payable', 100.0, 0.0, 0.0, 0.0, 100.0, 0.0),
+ ('211010 Account Payable', 50.0, 0.0, 0.0, 0.0, 50.0, 0.0),
+ ('400000 Product Sales', 20000.0, 0.0, 0.0, 0.0, 20000.0, 0.0),
+ ('400010 Product Sales', 0.0, 0.0, 0.0, 200.0, 0.0, 200.0),
+ ('999989 Undistributed Profits/Losses', 0.0, 50.0, 0.0, 0.0, 0.0, 50.0),
+ ('999999 Undistributed Profits/Losses', 0.0, 100.0, 0.0, 0.0, 0.0, 100.0),
+ ('Total', 21150.0, 21150.0, 200.0, 200.0, 21350.0, 21350.0),
+ ],
+ options,
+ )
+ general_ledger = self.env.ref('odex30_account_reports.general_ledger_report')
+ params = {'line_id': lines[1]['id']}
+ res = self.report.caret_option_open_general_ledger(options, params)
+ self.assertEqual(res['context']['default_filter_accounts'], '600000')
+ general_ledger_lines = general_ledger._get_lines(res['params']['options'])
+ unfolded_lines = [line for line in general_ledger_lines if line.get("unfolded")]
+ # Since the line 600000 Expenses has no child, unfolded is set to False. That's why we have only one element in the list
+ self.assertEqual(len(unfolded_lines), 1)
+
+ def test_blank_if_zero(self):
+ """
+ This test will check that the option blank if zero works as expected which means that
+ a '0.0' value will be blanked, but not in the total line.
+ """
+ self.report.column_ids.write({'blank_if_zero': True})
+ options = self._generate_options(self.report, fields.Date.from_string('2017-01-01'), fields.Date.from_string('2017-12-31'))
+ options = self._update_multi_selector_filter(options, 'journals', self.company_data['default_journal_sale'].ids)
+
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ # [ Initial Balance ] [ Balance ] [ Total ]
+ # Name Debit Credit Debit Credit Debit Credit
+ [ 0, 1, 2, 3, 4, 5, 6],
+ [
+ ('121000 Account Receivable', '', '', 1000.0, '', 1000.0, ''),
+ ('400000 Product Sales', '', '', 20000.0, '', 20000.0, ''),
+ ('600000 Expenses', '', '', '', 21000.0, '', 21000.0),
+ ('Total', 0.0, 0.0, 21000.0, 21000.0, 21000.0, 21000.0),
+ ],
+ options,
+ )
+
+ def test_trial_balance_analytic_groupby(self):
+ """
+ Test the analytic accounts groupby
+ """
+ self.env.user.groups_id += self.env.ref('analytic.group_analytic_accounting')
+ self.report.filter_analytic = True
+ self.report.filter_analytic_groupby = True
+
+ analytic_plan = self.env['account.analytic.plan'].create({
+ 'name': 'Plan XYZ',
+ })
+ analytic_account = self.env['account.analytic.account'].create({
+ 'name': 'Account XYZ',
+ 'plan_id': analytic_plan.id
+ })
+ move_2019 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2019-01-01'),
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ Command.create({
+ 'debit': 50.0,
+ 'credit': 0.0,
+ 'name': 'XYZ debit (2019)',
+ 'account_id': self.company_data['default_account_payable'].id,
+ 'analytic_distribution': {analytic_account.id: 100},
+ }),
+ Command.create({
+ 'debit': 0.0,
+ 'credit': 50.0,
+ 'name': 'XYZ credit (2019)',
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'analytic_distribution': {analytic_account.id: 100},
+ }),
+ ],
+ })
+ move_2019.action_post()
+ move_2020 = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2020-01-01'),
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ Command.create({
+ 'debit': 100.0,
+ 'credit': 0.0,
+ 'name': 'XYZ debit (2020)',
+ 'account_id': self.company_data['default_account_payable'].id,
+ 'analytic_distribution': {analytic_account.id: 100},
+ }),
+ Command.create({
+ 'debit': 0.0,
+ 'credit': 100.0,
+ 'name': 'XYZ credit (2020)',
+ 'account_id': self.company_data['default_account_revenue'].id,
+ 'analytic_distribution': {analytic_account.id: 100},
+ }),
+ ],
+ })
+ move_2020.action_post()
+
+ # add a group by analytic account
+ options = self._generate_options(
+ self.report,
+ '2020-01-01',
+ '2020-01-31',
+ default_options={
+ 'analytic_accounts': [analytic_account.id],
+ 'analytic_accounts_groupby': [analytic_account.id],
+ }
+ )
+ lines = self.report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # [ Initial Balance ] [ Jan 2020 ] [ End Balance ]
+ # [ Account XYZ ] [ Total ] [ Account XYZ ] [ Total ] [ Account XYZ ] [ Total ]
+ # Name Debit Credit Debit Credit Debit Credit Debit Credit Debit Credit Debit Credit
+ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
+ [
+ ('211000 Account Payable', 50.0, 0.0, 50.0, 0.0, 100.0, 0.0, 100.0, 0.0, 150.0, 0.0, 150.0, 0.0),
+ ('400000 Product Sales', 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, 100.0, 0.0, 100.0, 0.0, 100.0),
+ ('999999 Undistributed Profits/Losses', 0.0, 50.0, 0.0, 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, 50.0, 0.0, 50.0),
+ ('Total', 50.0, 50.0, 50.0, 50.0, 100.0, 100.0, 100.0, 100.0, 150.0, 150.0, 150.0, 150.0),
+ ],
+ options,
+ )
+
+ # add a group by analytic plan
+ options = self._generate_options(
+ self.report,
+ '2020-01-01',
+ '2020-01-31',
+ default_options={
+ 'analytic_accounts': [analytic_account.id],
+ 'analytic_plans_groupby': [analytic_plan.id],
+ }
+ )
+ lines = self.report._get_lines(options)
+ self.assertLinesValues(
+ lines,
+ # [ Initial Balance ] [ Jan 2020 ] [ End Balance ]
+ # [ Plan XYZ ] [ Total ] [ Plan XYZ ] [ Total ] [ Plan XYZ ] [ Total ]
+ # Name Debit Credit Debit Credit Debit Credit Debit Credit Debit Credit Debit Credit
+ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
+ [
+ ('211000 Account Payable', 50.0, 0.0, 50.0, 0.0, 100.0, 0.0, 100.0, 0.0, 150.0, 0.0, 150.0, 0.0),
+ ('400000 Product Sales', 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, 0.0, 100.0, 0.0, 100.0, 0.0, 100.0),
+ ('999999 Undistributed Profits/Losses', 0.0, 50.0, 0.0, 50.0, 0.0, 0.0, 0.0, 0.0, 0.0, 50.0, 0.0, 50.0),
+ ('Total', 50.0, 50.0, 50.0, 50.0, 100.0, 100.0, 100.0, 100.0, 150.0, 150.0, 150.0, 150.0),
+ ],
+ options,
+ )
+
+ def test_export_xlsx_with_inf_account_code(self):
+ account_with_inf_code = self.env['account.account'].create(
+ [{'code': '1E1000', 'name': '', 'account_type': 'asset_receivable'}])
+ move = self.env['account.move'].create({
+ 'date': '2025-08-02',
+ 'line_ids': [Command.create({'account_id': account_with_inf_code.id, 'name': ''})],
+ })
+ move.action_post()
+ options = self._generate_options(
+ self.report,
+ fields.Date.from_string('2025-08-01'),
+ fields.Date.from_string('2025-08-31')
+ )
+ self.report.export_to_xlsx(options)
+
+ def test_trial_balance_export_pdf_filter_hierarchy(self):
+ """
+ Test if the filter is also applied to the name of the group
+ """
+ self.env.lang = self.env['res.lang'].search([('code', '=', 'en_US')]).code
+ self.env['account.group'].create([
+ {'name': 'Group_10', 'code_prefix_start': '10', 'code_prefix_end': '10'},
+ {'name': 'Group_101', 'code_prefix_start': '101', 'code_prefix_end': '101'},
+ {'name': 'Group_1012', 'code_prefix_start': '1012', 'code_prefix_end': '1012'},
+ {'name': 'Group_102', 'code_prefix_start': '102', 'code_prefix_end': '102'},
+ ])
+
+ # Create the accounts.
+ account_a, account_a1, account_a2 = self.env['account.account'].create([
+ {'code': '100000', 'name': 'Account A', 'account_type': 'asset_current'},
+ {'code': '101200', 'name': 'Account A1', 'account_type': 'asset_current'},
+ {'code': '102200', 'name': 'Account A2', 'account_type': 'asset_current'},
+ ])
+
+ move = self.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': fields.Date.from_string('2017-06-01'),
+ 'journal_id': self.company_data['default_journal_misc'].id,
+ 'line_ids': [
+ Command.create({'debit': 100.0, 'credit': 0.0, 'name': 'account_a_1', 'account_id': account_a.id}),
+ Command.create({'debit': 0.0, 'credit': 100.0, 'name': 'account_a_2', 'account_id': account_a.id}),
+ Command.create({'debit': 200.0, 'credit': 0.0, 'name': 'account_a1_1', 'account_id': account_a1.id}),
+ Command.create({'debit': 0.0, 'credit': 200.0, 'name': 'account_a1_2', 'account_id': account_a1.id}),
+ Command.create({'debit': 333.0, 'credit': 0.0, 'name': 'account_a2_1', 'account_id': account_a2.id}),
+ Command.create({'debit': 0.0, 'credit': 333.0, 'name': 'account_a2_2', 'account_id': account_a2.id}),
+ ],
+ })
+ move.action_post()
+
+ default_options = {
+ 'hierarchy': True,
+ 'unfold_all': True,
+ 'export_mode': 'print',
+ 'filter_search_bar': 'Group_101',
+ }
+ options = self._generate_options(self.report, '2017-06-01', '2017-06-01', default_options=default_options)
+ self.assertLinesValues(
+ self.report._get_lines(options),
+ [ 0, 1, 2, 3, 4, 5, 6],
+ [
+ ['10 Group_10', 0.0, 0.0, 200.0, 200.0, 0.0, 0.0],
+ ['101 Group_101', 0.0, 0.0, 200.0, 200.0, 0.0, 0.0],
+ ['1012 Group_1012', 0.0, 0.0, 200.0, 200.0, 0.0, 0.0],
+ ['101200 Account A1', 0.0, 0.0, 200.0, 200.0, 0.0, 0.0],
+ ['Total', 0.0, 0.0, 200.0, 200.0, 0.0, 0.0],
+ ],
+ options,
+ )
diff --git a/dev_odex30_accounting/odex30_account_reports/views/account_account_views.xml b/dev_odex30_accounting/odex30_account_reports/views/account_account_views.xml
new file mode 100644
index 0000000..5bb98a2
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/views/account_account_views.xml
@@ -0,0 +1,13 @@
+
+
+
+ account.account.reports.form
+ account.account
+
+
+
+
+
+
+
+
diff --git a/dev_odex30_accounting/odex30_account_reports/views/account_activity.xml b/dev_odex30_accounting/odex30_account_reports/views/account_activity.xml
new file mode 100644
index 0000000..68b45cf
--- /dev/null
+++ b/dev_odex30_accounting/odex30_account_reports/views/account_activity.xml
@@ -0,0 +1,42 @@
+
+
+
+ account.move.form.vat.return
+ account.move
+
+
+
+
+
+
+ Proposition of tax closing journal entry.
+
+
+
+
+ This tax closing entry is posted, but the tax lock date is earlier than the covered period's last day. You might need to reset it to draft and refresh its content, in case other entries using taxes have been posted in the meantime.
+