Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View File

@@ -0,0 +1,453 @@
# 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