# pylint: disable=C0326 import logging import itertools import contextlib from odoo.addons.account_reports.tests.common import TestAccountReportsCommon from odoo.addons.account.tests.common import AccountTestInvoicingCommon from odoo.addons.account import SYSCOHADA_LIST from odoo.tests import tagged from odoo import fields _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 = { 'account_reports.balance_sheet': { 'asset_line_ref': 'account_reports.account_financial_report_total_assets0', 'liability_line_ref': '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_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_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_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_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_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_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', }, } # === 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 setUpClass(cls): # OVERRIDE: don't create demo companies, and don't set env.user super(AccountTestInvoicingCommon, cls).setUpClass() 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() savepoint = self.env.cr.savepoint(flush=False) for coa in installed_coas: with self.subTest(CoA=coa): try: # === 1. Set-up localization === # available_reports, aml_pairs, accounts_by_aml = self._set_up_localization(coa) # 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.') finally: savepoint.rollback() 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_id', '=', self.env.company.id), ('account_type', '=', 'asset_receivable'), ], limit=1) coa_setup_data['income_account'] = self.env['account.account'].search([ ('company_id', '=', 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: company_data = self.setup_company_data('company_3', chart_template=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('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_id', '=', 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)