Initial commit
This commit is contained in:
233
Fusion Accounting/models/cash_basis_report.py
Normal file
233
Fusion Accounting/models/cash_basis_report.py
Normal file
@@ -0,0 +1,233 @@
|
||||
# Fusion Accounting - Cash Basis Reporting
|
||||
# Copyright (C) 2026 Nexa Systems Inc. (https://nexasystems.ca)
|
||||
# Original implementation for the Fusion Accounting module.
|
||||
#
|
||||
# Alternative report handler that uses payment dates instead of invoice
|
||||
# dates for recognizing revenue and expenses, supporting the cash basis
|
||||
# accounting method.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.tools import SQL, Query, float_is_zero
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionCashBasisReport(models.AbstractModel):
|
||||
"""Cash basis report custom handler.
|
||||
|
||||
Unlike the standard accrual-based reporting, cash basis reports
|
||||
recognise revenue when payment is received and expenses when payment
|
||||
is made, regardless of when the invoice was issued.
|
||||
|
||||
This handler:
|
||||
- Replaces the invoice/bill date with the payment reconciliation date
|
||||
- Filters transactions to only include those with matching payments
|
||||
- Provides a toggle in report options to switch between accrual and
|
||||
cash basis views
|
||||
"""
|
||||
|
||||
_name = 'account.cash.basis.report.handler'
|
||||
_inherit = 'account.report.custom.handler'
|
||||
_description = 'Cash Basis Report Custom Handler'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Options Initializer
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _custom_options_initializer(self, report, options, previous_options):
|
||||
"""Add cash-basis specific options to the report."""
|
||||
super()._custom_options_initializer(report, options, previous_options=previous_options)
|
||||
|
||||
# Add the cash basis toggle
|
||||
options['fusion_cash_basis'] = previous_options.get('fusion_cash_basis', True)
|
||||
|
||||
# Restrict to journals that support cash basis
|
||||
report._init_options_journals(
|
||||
options,
|
||||
previous_options=previous_options,
|
||||
additional_journals_domain=[('type', 'in', ('sale', 'purchase', 'bank', 'cash', 'general'))],
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dynamic Lines
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None):
|
||||
"""Generate report lines based on cash basis (payment date) accounting.
|
||||
|
||||
Returns a list of (sequence, line_dict) tuples for the report engine.
|
||||
"""
|
||||
output_lines = []
|
||||
|
||||
if not options.get('fusion_cash_basis', True):
|
||||
# Fallback to standard accrual-based processing
|
||||
return output_lines
|
||||
|
||||
cash_data = self._compute_cash_basis_data(report, options)
|
||||
|
||||
# ---- Revenue Section ----
|
||||
revenue_total = sum(d['amount'] for d in cash_data.get('revenue', []))
|
||||
output_lines.append((0, self._build_section_line(
|
||||
report, options, 'revenue',
|
||||
_("Cash Revenue"), revenue_total,
|
||||
)))
|
||||
for entry in sorted(cash_data.get('revenue', []), key=lambda e: e.get('date', '')):
|
||||
output_lines.append((1, self._build_detail_line(report, options, entry)))
|
||||
|
||||
# ---- Expense Section ----
|
||||
expense_total = sum(d['amount'] for d in cash_data.get('expense', []))
|
||||
output_lines.append((0, self._build_section_line(
|
||||
report, options, 'expense',
|
||||
_("Cash Expenses"), expense_total,
|
||||
)))
|
||||
for entry in sorted(cash_data.get('expense', []), key=lambda e: e.get('date', '')):
|
||||
output_lines.append((1, self._build_detail_line(report, options, entry)))
|
||||
|
||||
# ---- Net Cash Income ----
|
||||
net_total = revenue_total - abs(expense_total)
|
||||
output_lines.append((0, self._build_section_line(
|
||||
report, options, 'net_income',
|
||||
_("Net Cash Income"), net_total,
|
||||
)))
|
||||
|
||||
return output_lines
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Data Computation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _compute_cash_basis_data(self, report, options):
|
||||
"""Compute cash basis amounts grouped by revenue/expense.
|
||||
|
||||
Queries reconciled payments to find the actual cash dates for
|
||||
recognised amounts.
|
||||
|
||||
:returns: dict with keys ``revenue`` and ``expense``, each
|
||||
containing a list of entry dicts with amount, date,
|
||||
account, and partner information.
|
||||
"""
|
||||
result = {'revenue': [], 'expense': []}
|
||||
company_ids = [c['id'] for c in options.get('companies', [{'id': self.env.company.id}])]
|
||||
date_from = options.get('date', {}).get('date_from')
|
||||
date_to = options.get('date', {}).get('date_to')
|
||||
|
||||
if not date_from or not date_to:
|
||||
return result
|
||||
|
||||
# Query: find all payment reconciliation entries within the period
|
||||
query = """
|
||||
SELECT
|
||||
aml.id AS line_id,
|
||||
aml.account_id,
|
||||
aa.name AS account_name,
|
||||
aa.code AS account_code,
|
||||
aml.partner_id,
|
||||
rp.name AS partner_name,
|
||||
apr.max_date AS cash_date,
|
||||
CASE
|
||||
WHEN aa.account_type IN ('income', 'income_other')
|
||||
THEN aml.credit - aml.debit
|
||||
ELSE aml.debit - aml.credit
|
||||
END AS amount
|
||||
FROM account_move_line aml
|
||||
JOIN account_account aa ON aa.id = aml.account_id
|
||||
LEFT JOIN res_partner rp ON rp.id = aml.partner_id
|
||||
JOIN account_move am ON am.id = aml.move_id
|
||||
JOIN (
|
||||
SELECT
|
||||
apr2.debit_move_id,
|
||||
apr2.credit_move_id,
|
||||
apr2.max_date
|
||||
FROM account_partial_reconcile apr2
|
||||
WHERE apr2.max_date >= %s
|
||||
AND apr2.max_date <= %s
|
||||
) apr ON (apr.debit_move_id = aml.id OR apr.credit_move_id = aml.id)
|
||||
WHERE am.state = 'posted'
|
||||
AND am.company_id IN %s
|
||||
AND aa.account_type IN (
|
||||
'income', 'income_other',
|
||||
'expense', 'expense_direct_cost', 'expense_depreciation'
|
||||
)
|
||||
ORDER BY apr.max_date, aa.code
|
||||
"""
|
||||
|
||||
self.env.cr.execute(query, (date_from, date_to, tuple(company_ids)))
|
||||
rows = self.env.cr.dictfetchall()
|
||||
|
||||
seen_lines = set()
|
||||
for row in rows:
|
||||
# Avoid counting the same line twice if partially reconciled
|
||||
if row['line_id'] in seen_lines:
|
||||
continue
|
||||
seen_lines.add(row['line_id'])
|
||||
|
||||
entry = {
|
||||
'line_id': row['line_id'],
|
||||
'account_id': row['account_id'],
|
||||
'account_name': row['account_name'],
|
||||
'account_code': row['account_code'] or '',
|
||||
'partner_id': row['partner_id'],
|
||||
'partner_name': row['partner_name'] or '',
|
||||
'date': str(row['cash_date']),
|
||||
'amount': row['amount'] or 0.0,
|
||||
}
|
||||
|
||||
account_type = self.env['account.account'].browse(
|
||||
row['account_id']
|
||||
).account_type
|
||||
if account_type in ('income', 'income_other'):
|
||||
result['revenue'].append(entry)
|
||||
else:
|
||||
result['expense'].append(entry)
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Line Builders
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_section_line(self, report, options, section_id, name, total):
|
||||
"""Build a section header line for the report.
|
||||
|
||||
:param section_id: unique identifier for the section
|
||||
:param name: display name of the section
|
||||
:param total: aggregated monetary total
|
||||
:returns: line dict compatible with the report engine
|
||||
"""
|
||||
columns = report._build_column_dict(total, options, figure_type='monetary')
|
||||
return {
|
||||
'id': report._get_generic_line_id(None, None, markup=f'fusion_cb_{section_id}'),
|
||||
'name': name,
|
||||
'level': 1,
|
||||
'columns': [columns],
|
||||
'unfoldable': False,
|
||||
'unfolded': False,
|
||||
}
|
||||
|
||||
def _build_detail_line(self, report, options, entry):
|
||||
"""Build a detail line for a single cash-basis entry.
|
||||
|
||||
:param entry: dict with amount, account_code, account_name, etc.
|
||||
:returns: line dict compatible with the report engine
|
||||
"""
|
||||
name = f"{entry['account_code']} {entry['account_name']}"
|
||||
if entry.get('partner_name'):
|
||||
name += f" - {entry['partner_name']}"
|
||||
|
||||
columns = report._build_column_dict(
|
||||
entry['amount'], options, figure_type='monetary',
|
||||
)
|
||||
|
||||
return {
|
||||
'id': report._get_generic_line_id(
|
||||
'account.move.line', entry['line_id'],
|
||||
markup='fusion_cb_detail',
|
||||
),
|
||||
'name': name,
|
||||
'level': 3,
|
||||
'columns': [columns],
|
||||
'caret_options': 'account.move.line',
|
||||
'unfoldable': False,
|
||||
}
|
||||
Reference in New Issue
Block a user