# Fusion Accounting - Bank Reconciliation Report Handler # Statement balance tracking, outstanding items, and miscellaneous ops import logging from datetime import date from odoo import models, fields, _ from odoo.exceptions import UserError from odoo.tools import SQL _log = logging.getLogger(__name__) class FusionBankReconciliationHandler(models.AbstractModel): """Custom handler for the bank reconciliation report. Computes last-statement balances, unreconciled items, outstanding payments/receipts, and miscellaneous bank journal operations.""" _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['ignore_totals_below_sections'] = True if 'active_id' in self.env.context and self.env.context.get('active_model') == 'account.journal': options['bank_reconciliation_report_journal_id'] = self.env.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: options['bank_reconciliation_report_journal_id'] = ( self.env['account.journal'].search([('type', '=', 'bank')], limit=1).id ) has_multicur = ( self.env.user.has_group('base.group_multi_currency') and self.env.user.has_group('base.group_no_one') ) if not has_multicur: options['columns'] = [ c for c in options['columns'] if c['expression_label'] not in ('amount_currency', 'currency') ] # ================================================================ # GETTERS # ================================================================ def _get_bank_journal_and_currencies(self, options): jnl = self.env['account.journal'].browse( options.get('bank_reconciliation_report_journal_id'), ) co_cur = jnl.company_id.currency_id jnl_cur = jnl.currency_id or co_cur return jnl, jnl_cur, co_cur # ================================================================ # RESULT BUILDER # ================================================================ 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, } # ================================================================ # CUSTOM ENGINES # ================================================================ def _report_custom_engine_forced_currency_amount(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None): _j, jcur, _c = self._get_bank_journal_and_currencies(options) return self._build_custom_engine_result(amount_currency_id=jcur.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._common_st_line_engine(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._common_st_line_engine(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._common_st_line_engine(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._common_st_line_engine(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._outstanding_engine(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._outstanding_engine(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 []) jnl, jcur, _c = self._get_bank_journal_and_currencies(options) misc_domain = self._get_bank_miscellaneous_move_lines_domain(options, jnl) misc_total = self.env["account.move.line"]._read_group( domain=misc_domain or [], groupby=current_groupby or [], aggregates=['balance:sum'], )[-1][0] return self._build_custom_engine_result(amount=misc_total or 0, amount_currency_id=jcur.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(_("Last-statement balance does not support groupby.")) jnl, jcur, _c = self._get_bank_journal_and_currencies(options) last_stmt = self._get_last_bank_statement(jnl, options) return self._build_custom_engine_result(amount=last_stmt.balance_end_real, amount_currency_id=jcur.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._common_st_line_engine(options, 'all', current_groupby, False, unreconciled=False) # ================================================================ # SHARED ENGINES # ================================================================ def _common_st_line_engine(self, options, direction, current_groupby, from_last_stmt, unreconciled=True): jnl, jcur, _ccur = self._get_bank_journal_and_currencies(options) if not jnl: 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 _assemble(rows): if current_groupby == 'id': r = rows[0] fcur = self.env['res.currency'].browse(r['foreign_currency_id']) rate = (r['amount'] / r['amount_currency']) if r['amount_currency'] else 0 return self._build_custom_engine_result( date=r['date'] or None, label=r['payment_ref'] or r['ref'] or '/', amount_currency=-r['amount_residual'] if r['foreign_currency_id'] else None, amount_currency_currency_id=fcur.id if r['foreign_currency_id'] else None, currency=fcur.display_name if r['foreign_currency_id'] else None, amount=-r['amount_residual'] * rate if r['amount_residual'] else None, amount_currency_id=jcur.id, ) total = 0 for r in rows: rate = (r['amount'] / r['amount_currency']) if r['foreign_currency_id'] and r['amount_currency'] else 1 total += -r.get('amount_residual', 0) * rate if unreconciled else r.get('amount', 0) return self._build_custom_engine_result(amount=total, amount_currency_id=jcur.id, has_sublines=bool(rows)) qry = report._get_report_query(options, 'strict_range', domain=[ ('journal_id', '=', jnl.id), ('account_id', '=', jnl.default_account_id.id), ]) if from_last_stmt: last_stmt_id = self._get_last_bank_statement(jnl, options).id if last_stmt_id: stmt_cond = SQL("st_line.statement_id = %s", last_stmt_id) else: return self._compute_result([], current_groupby, _assemble) else: stmt_cond = SQL("st_line.statement_id IS NULL") if direction == 'receipts': amt_cond = SQL("AND st_line.amount > 0") elif direction == 'payments': amt_cond = SQL("AND st_line.amount < 0") else: amt_cond = SQL("") full_sql = SQL(""" SELECT %(sel_gb)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 %(tbl)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)s %(unrec)s %(amt_cond)s AND %(stmt_cond)s GROUP BY %(gb)s, st_line.id, move.id """, sel_gb=SQL("%s AS grouping_key", SQL.identifier('account_move_line', current_groupby)) if current_groupby else SQL('null'), tbl=qry.from_clause, where=qry.where_clause, unrec=SQL("AND NOT st_line.is_reconciled") if unreconciled else SQL(""), amt_cond=amt_cond, stmt_cond=stmt_cond, gb=SQL.identifier('account_move_line', current_groupby) if current_groupby else SQL('st_line.id'), ) self.env.cr.execute(full_sql) return self._compute_result(self.env.cr.dictfetchall(), current_groupby, _assemble) def _outstanding_engine(self, options, direction, current_groupby): jnl, jcur, ccur = self._get_bank_journal_and_currencies(options) if not jnl: 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 _assemble(rows): if current_groupby == 'id': r = rows[0] convert = not (jcur and r['currency_id'] == jcur.id) amt_cur = r['amount_residual_currency'] if r['is_account_reconcile'] else r['amount_currency'] bal = r['amount_residual'] if r['is_account_reconcile'] else r['balance'] fcur = self.env['res.currency'].browse(r['currency_id']) return self._build_custom_engine_result( date=r['date'] or None, label=r['ref'] or None, amount_currency=amt_cur if convert else None, amount_currency_currency_id=fcur.id if convert else None, currency=fcur.display_name if convert else None, amount=ccur._convert(bal, jcur, jnl.company_id, options['date']['date_to']) if convert else amt_cur, amount_currency_id=jcur.id, ) total = 0 for r in rows: convert = not (jcur and r['currency_id'] == jcur.id) if convert: bal = r['amount_residual'] if r['is_account_reconcile'] else r['balance'] total += ccur._convert(bal, jcur, jnl.company_id, options['date']['date_to']) else: total += r['amount_residual_currency'] if r['is_account_reconcile'] else r['amount_currency'] return self._build_custom_engine_result(amount=total, amount_currency_id=jcur.id, has_sublines=bool(rows)) accts = jnl._get_journal_inbound_outstanding_payment_accounts() + jnl._get_journal_outbound_outstanding_payment_accounts() qry = report._get_report_query(options, 'from_beginning', domain=[ ('journal_id', '=', jnl.id), ('account_id', 'in', accts.ids), ('full_reconcile_id', '=', False), ('amount_residual_currency', '!=', 0.0), ]) full_sql = SQL(""" SELECT %(sel_gb)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 %(tbl)s JOIN account_account account ON account.id = account_move_line.account_id WHERE %(where)s AND %(dir_cond)s GROUP BY %(gb)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 """, sel_gb=SQL("%s AS grouping_key", SQL.identifier('account_move_line', current_groupby)) if current_groupby else SQL('null'), tbl=qry.from_clause, where=qry.where_clause, dir_cond=SQL("account_move_line.balance > 0") if direction == 'receipts' else SQL("account_move_line.balance < 0"), gb=SQL.identifier('account_move_line', current_groupby) if current_groupby else SQL('account_move_line.account_id'), ) self.env.cr.execute(full_sql) return self._compute_result(self.env.cr.dictfetchall(), current_groupby, _assemble) def _compute_result(self, rows, current_groupby, builder): if not current_groupby: return builder(rows) grouped = {} for r in rows: grouped.setdefault(r['grouping_key'], []).append(r) return [(k, builder(v)) for k, v in grouped.items()] # ================================================================ # POST-PROCESSING & WARNINGS # ================================================================ def _custom_line_postprocessor(self, report, options, lines): lines = super()._custom_line_postprocessor(report, options, lines) jnl, _jc, _cc = self._get_bank_journal_and_currencies(options) if not jnl: return lines last_stmt = self._get_last_bank_statement(jnl, options) for ln in lines: line_id = report._get_res_id_from_line_id(ln['id'], 'account.report.line') code = self.env['account.report.line'].browse(line_id).code if code == "balance_bank": ln['name'] = _("Balance of '%s'", jnl.default_account_id.display_name) if code == "last_statement_balance": ln['class'] = 'o_bold_tr' if last_stmt: ln['columns'][1].update({'name': last_stmt.display_name, 'auditable': True}) if code in ("transaction_without_statement", "misc_operations"): ln['class'] = 'o_bold_tr' mdl, _mid = report._get_model_info_from_id(ln['id']) if mdl == "account.move.line": ln['name'] = ln['name'].split()[0] return lines def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings): jnl, jcur, _cc = self._get_bank_journal_and_currencies(options) bad_stmts = self._get_inconsistent_statements(options, jnl).ids misc_domain = self._get_bank_miscellaneous_move_lines_domain(options, jnl) has_misc = misc_domain and bool(self.env['account.move.line'].search_count(misc_domain, limit=1)) last_stmt, gl_bal, end_bal, diff, mismatch = self._compute_journal_balances(report, options, jnl, jcur) if warnings is not None: if last_stmt and mismatch: warnings['fusion_accounting.journal_balance'] = { 'alert_type': 'warning', 'general_ledger_amount': gl_bal, 'last_bank_statement_amount': end_bal, 'unexplained_difference': diff, } if bad_stmts: warnings['fusion_accounting.inconsistent_statement_warning'] = {'alert_type': 'warning', 'args': bad_stmts} if has_misc: warnings['fusion_accounting.has_bank_miscellaneous_move_lines'] = { 'alert_type': 'warning', 'args': jnl.default_account_id.display_name, } # ================================================================ # BALANCE COMPUTATION # ================================================================ def _compute_journal_balances(self, report, options, journal, jcur): domain = report._get_options_domain(options, 'from_beginning') gl_raw = journal._get_journal_bank_account_balance(domain=domain)[0] last_stmt, end_raw, diff_raw, mismatch = self._compute_balances(options, journal, gl_raw, jcur) fmt = lambda v: report.format_value(options, v, format_params={'currency_id': jcur.id}, figure_type='monetary') return last_stmt, fmt(gl_raw), fmt(end_raw), fmt(diff_raw), mismatch def _compute_balances(self, options, journal, gl_balance, report_currency): rpt_date = fields.Date.from_string(options['date']['date_to']) last_stmt = self._get_last_bank_statement(journal, options) end_bal = diff = 0 mismatch = False if last_stmt: lines_in_range = last_stmt.line_ids.filtered(lambda l: l.date <= rpt_date) end_bal = last_stmt.balance_start + sum(lines_in_range.mapped('amount')) diff = gl_balance - end_bal mismatch = not report_currency.is_zero(diff) return last_stmt, end_bal, diff, mismatch # ================================================================ # STATEMENT HELPERS # ================================================================ def _get_last_bank_statement(self, journal, options): rpt_date = fields.Date.from_string(options['date']['date_to']) last_line = self.env['account.bank.statement.line'].search([ ('journal_id', '=', journal.id), ('statement_id', '!=', False), ('date', '<=', rpt_date), ], order='date desc, id desc', limit=1) return last_line.statement_id def _get_inconsistent_statements(self, options, journal): 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): 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'), ] lock_date = journal.company_id._get_user_fiscal_lock_date(journal) if lock_date != date.min: domain.append(('date', '>', 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 ACTIONS # ================================================================ def action_audit_cell(self, options, params): rpt_line = self.env['account.report.line'].browse(params['report_line_id']) if rpt_line.code == "balance_bank": return self.action_redirect_to_general_ledger(options) elif rpt_line.code == "misc_operations": return self.open_bank_miscellaneous_move_lines(options) elif rpt_line.code == "last_statement_balance": return self.action_redirect_to_bank_statement_widget(options) return rpt_line.report_id.action_audit_cell(options, params) def action_redirect_to_general_ledger(self, options): gl_action = self.env['ir.actions.actions']._for_xml_id( 'fusion_accounting.action_account_report_general_ledger', ) gl_action['params'] = {'options': options, 'ignore_session': True} return gl_action def action_redirect_to_bank_statement_widget(self, options): jnl = self.env['account.journal'].browse( options.get('bank_reconciliation_report_journal_id'), ) last_stmt = self._get_last_bank_statement(jnl, options) return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget( default_context={'create': False, 'search_default_statement_id': last_stmt.id}, name=last_stmt.display_name, ) def open_bank_miscellaneous_move_lines(self, options): jnl = 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, jnl), } def bank_reconciliation_report_open_inconsistent_statements(self, options, params=None): stmt_ids = params['args'] action = { 'name': _("Inconsistent Statements"), 'type': 'ir.actions.act_window', 'res_model': 'account.bank.statement', } if len(stmt_ids) == 1: action.update({'view_mode': 'form', 'res_id': stmt_ids[0], 'views': [(False, 'form')]}) else: action.update({'view_mode': 'list', 'domain': [('id', 'in', stmt_ids)], 'views': [(False, 'list')]}) return action