import logging from odoo import models, fields, _ from odoo.exceptions import UserError from odoo.tools import SQL _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=None): 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 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 previous_options and '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 = report.user_has_groups('base.group_multi_currency') and report.user_has_groups('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) bank_miscellaneous_domain = self._get_bank_miscellaneous_move_lines_domain(options, journal) misc_operations_amount = self.env["account.move.line"]._read_group( domain=bank_miscellaneous_domain or [], groupby=current_groupby or [], aggregates=['balance:sum'] )[-1][0] # Needed to get the balance from the tuples given by the read group return self._build_custom_engine_result(amount=misc_operations_amount or 0, amount_currency_id=journal_currency.id) 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)), ) tables, where_clause, where_params = report._query_get(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("") # 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 %(tables)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 %(where_clause)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", SQL.identifier('account_move_line', current_groupby)) if current_groupby else SQL('null'), tables=SQL(tables), where_clause=SQL(where_clause, *where_params), 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=SQL.identifier('account_move_line', current_groupby) 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() tables, where_clause, where_params = report._query_get(options, 'normal', domain=[ ('journal_id', '=', journal.id), ('account_id', 'in', accounts.ids), ('full_reconcile_id', '=', False), ('amount_residual_currency', '!=', 0.0) ]) # Build query 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 %(tables)s JOIN account_account account ON account.id = account_move_line.account_id WHERE %(where_clause)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", SQL.identifier('account_move_line', current_groupby)) if current_groupby else SQL('null'), tables=SQL(tables), where_clause=SQL(where_clause, *where_params), is_receipt=SQL("account_move_line.balance > 0") if internal_type == "receipts" else SQL("account_move_line.balance < 0"), group_by=SQL.identifier('account_move_line', current_groupby) 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, warnings=None): lines = super()._custom_line_postprocessor(report, options, lines, warnings=warnings) journal, journal_currency, company_currency = self._get_bank_journal_and_currencies(options) if not journal: return lines 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) 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 # This part of the code will deal with the warnings displayed on top of the report if warnings is not None: if last_statement and general_ledger_not_matching: warnings['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['account_reports.inconsistent_statement_warning'] = {'alert_type': 'warning', 'args': inconsistent_statement} if has_bank_miscellaneous_move_lines: warnings['account_reports.has_bank_miscellaneous_move_lines'] = {'alert_type': 'warning', 'args': journal.default_account_id.display_name} return lines def _compute_journal_balances(self, report, options, journal, journal_currency): """ This function compute all necessary information for the warning '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, 'normal') 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, currency=journal_currency, figure_type='monetary') balance_end = report.format_value(options, balance_end, currency=journal_currency, figure_type='monetary') difference = report.format_value(options, difference, currency=journal_currency, 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, 'normal'), ] if journal.company_id.fiscalyear_lock_date: domain.append(('date', '>', journal.company_id.fiscalyear_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('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 tree 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 tree 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