454 lines
23 KiB
Python
454 lines
23 KiB
Python
# 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
|