Files
Odoo-Modules/Fusion Accounting/models/account_general_ledger.py
2026-02-22 01:22:18 -05:00

754 lines
33 KiB
Python

# Fusion Accounting - General Ledger Report Handler
import json
from odoo import models, fields, api, _
from odoo.tools.misc import format_date
from odoo.tools import get_lang, SQL
from odoo.exceptions import UserError
from datetime import timedelta
from collections import defaultdict
class GeneralLedgerCustomHandler(models.AbstractModel):
"""Produces the General Ledger report.
Aggregates journal items by account and period, handles initial balances,
unaffected-earnings allocation, and optional tax-declaration sections.
"""
_name = 'account.general.ledger.report.handler'
_inherit = 'account.report.custom.handler'
_description = 'General Ledger Custom Handler'
# ------------------------------------------------------------------
# Display configuration
# ------------------------------------------------------------------
def _get_custom_display_config(self):
return {
'templates': {
'AccountReportLineName': 'fusion_accounting.GeneralLedgerLineName',
},
}
# ------------------------------------------------------------------
# Options
# ------------------------------------------------------------------
def _custom_options_initializer(self, report, options, previous_options):
"""Strip the multi-currency column when the user lacks the group,
and auto-unfold when printing."""
super()._custom_options_initializer(report, options, previous_options=previous_options)
if self.env.user.has_group('base.group_multi_currency'):
options['multi_currency'] = True
else:
options['columns'] = [
c for c in options['columns']
if c['expression_label'] != 'amount_currency'
]
# When printing the whole report, unfold everything unless the user
# explicitly selected specific lines.
options['unfold_all'] = (
(options['export_mode'] == 'print' and not options.get('unfolded_lines'))
or options['unfold_all']
)
# ------------------------------------------------------------------
# Dynamic lines
# ------------------------------------------------------------------
def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None):
"""Return ``[(seq, line_dict), ...]`` for every account row plus
an optional tax-declaration block and a grand-total row."""
result_lines = []
period_start = fields.Date.from_string(options['date']['date_from'])
comp_currency = self.env.company.currency_id
running_totals = defaultdict(lambda: {'debit': 0, 'credit': 0, 'balance': 0})
for account_rec, col_grp_vals in self._aggregate_account_values(report, options):
per_col = {}
any_current = False
for col_key, bucket in col_grp_vals.items():
main = bucket.get('sum', {})
unaff = bucket.get('unaffected_earnings', {})
dr = main.get('debit', 0.0) + unaff.get('debit', 0.0)
cr = main.get('credit', 0.0) + unaff.get('credit', 0.0)
bal = main.get('balance', 0.0) + unaff.get('balance', 0.0)
per_col[col_key] = {
'amount_currency': main.get('amount_currency', 0.0) + unaff.get('amount_currency', 0.0),
'debit': dr,
'credit': cr,
'balance': bal,
}
latest_date = main.get('max_date')
if latest_date and latest_date >= period_start:
any_current = True
running_totals[col_key]['debit'] += dr
running_totals[col_key]['credit'] += cr
running_totals[col_key]['balance'] += bal
result_lines.append(
self._build_account_header_line(report, options, account_rec, any_current, per_col)
)
# Round the accumulated balance
for totals in running_totals.values():
totals['balance'] = comp_currency.round(totals['balance'])
# Tax-declaration section (single column group + single journal of sale/purchase type)
active_journals = report._get_options_journals(options)
if (
len(options['column_groups']) == 1
and len(active_journals) == 1
and active_journals[0]['type'] in ('sale', 'purchase')
):
result_lines += self._produce_tax_declaration_lines(
report, options, active_journals[0]['type']
)
# Grand total
result_lines.append(self._build_grand_total_line(report, options, running_totals))
return [(0, ln) for ln in result_lines]
# ------------------------------------------------------------------
# Batch unfold helper
# ------------------------------------------------------------------
def _custom_unfold_all_batch_data_generator(self, report, options, lines_to_expand_by_function):
"""Pre-load data for all accounts that need unfolding so the engine
does not issue per-account queries."""
target_acct_ids = []
for line_info in lines_to_expand_by_function.get('_report_expand_unfoldable_line_general_ledger', []):
mdl, mdl_id = report._get_model_info_from_id(line_info['id'])
if mdl == 'account.account':
target_acct_ids.append(mdl_id)
page_size = report.load_more_limit if report.load_more_limit and not options.get('export_mode') else None
overflow_flags = {}
full_aml_data = self._fetch_aml_data(report, options, target_acct_ids)[0]
if page_size:
trimmed_aml_data = {}
for acct_id, acct_rows in full_aml_data.items():
page = {}
for key, val in acct_rows.items():
if len(page) >= page_size:
overflow_flags[acct_id] = True
break
page[key] = val
trimmed_aml_data[acct_id] = page
else:
trimmed_aml_data = full_aml_data
return {
'initial_balances': self._fetch_opening_balances(report, target_acct_ids, options),
'aml_results': trimmed_aml_data,
'has_more': overflow_flags,
}
# ------------------------------------------------------------------
# Tax declaration
# ------------------------------------------------------------------
def _produce_tax_declaration_lines(self, report, options, tax_type):
"""Append a Tax Declaration section when viewing a single
sale / purchase journal."""
header_labels = {
'debit': _("Base Amount"),
'credit': _("Tax Amount"),
}
output = [
{
'id': report._get_generic_line_id(None, None, markup='tax_decl_header_1'),
'name': _('Tax Declaration'),
'columns': [{} for _ in options['columns']],
'level': 1,
'unfoldable': False,
'unfolded': False,
},
{
'id': report._get_generic_line_id(None, None, markup='tax_decl_header_2'),
'name': _('Name'),
'columns': [
{'name': header_labels.get(c['expression_label'], '')}
for c in options['columns']
],
'level': 3,
'unfoldable': False,
'unfolded': False,
},
]
tax_report = self.env.ref('account.generic_tax_report')
tax_opts = tax_report.get_options({
**options,
'selected_variant_id': tax_report.id,
'forced_domain': [('tax_line_id.type_tax_use', '=', tax_type)],
})
tax_lines = tax_report._get_lines(tax_opts)
parent_marker = tax_report._get_generic_line_id(None, None, markup=tax_type)
for tl in tax_lines:
if tl.get('parent_id') != parent_marker:
continue
src_cols = tl['columns']
mapped = {
'debit': src_cols[0],
'credit': src_cols[1],
}
tl['columns'] = [mapped.get(c['expression_label'], {}) for c in options['columns']]
output.append(tl)
return output
# ------------------------------------------------------------------
# Core queries
# ------------------------------------------------------------------
def _aggregate_account_values(self, report, options):
"""Execute summary queries and assign unaffected-earnings.
Returns ``[(account_record, {col_group_key: {...}, ...}), ...]``
"""
combined_sql = self._build_summary_query(report, options)
if not combined_sql:
return []
by_account = {}
by_company = {}
self.env.cr.execute(combined_sql)
for row in self.env.cr.dictfetchall():
if row['groupby'] is None:
continue
cg = row['column_group_key']
bucket = row['key']
if bucket == 'sum':
by_account.setdefault(row['groupby'], {k: {} for k in options['column_groups']})
by_account[row['groupby']][cg][bucket] = row
elif bucket == 'initial_balance':
by_account.setdefault(row['groupby'], {k: {} for k in options['column_groups']})
by_account[row['groupby']][cg][bucket] = row
elif bucket == 'unaffected_earnings':
by_company.setdefault(row['groupby'], {k: {} for k in options['column_groups']})
by_company[row['groupby']][cg] = row
# Assign unaffected earnings to the equity_unaffected account
if by_company:
candidate_accounts = self.env['account.account'].search([
('display_name', 'ilike', options.get('filter_search_bar')),
*self.env['account.account']._check_company_domain(list(by_company.keys())),
('account_type', '=', 'equity_unaffected'),
])
for comp_id, comp_data in by_company.items():
target_acct = candidate_accounts.filtered(
lambda a: self.env['res.company'].browse(comp_id).root_id in a.company_ids
)
if not target_acct:
continue
for cg in options['column_groups']:
by_account.setdefault(
target_acct.id,
{k: {'unaffected_earnings': {}} for k in options['column_groups']},
)
unaff = comp_data.get(cg)
if not unaff:
continue
existing = by_account[target_acct.id][cg].get('unaffected_earnings')
if existing:
for fld in ('amount_currency', 'debit', 'credit', 'balance'):
existing[fld] = existing.get(fld, 0.0) + unaff[fld]
else:
by_account[target_acct.id][cg]['unaffected_earnings'] = unaff
if by_account:
accounts = self.env['account.account'].search([('id', 'in', list(by_account.keys()))])
else:
accounts = self.env['account.account']
return [(acct, by_account[acct.id]) for acct in accounts]
def _build_summary_query(self, report, options) -> SQL:
"""Construct the UNION ALL query that retrieves period sums and
unaffected-earnings sums for every account."""
per_col = report._split_options_per_column_group(options)
parts = []
for col_key, grp_opts in per_col.items():
# Decide date scope
scope = 'strict_range' if grp_opts.get('general_ledger_strict_range') else 'from_beginning'
domain_extra = []
if not grp_opts.get('general_ledger_strict_range'):
fy_start = fields.Date.from_string(grp_opts['date']['date_from'])
fy_dates = self.env.company.compute_fiscalyear_dates(fy_start)
domain_extra += [
'|',
('date', '>=', fy_dates['date_from']),
('account_id.include_initial_balance', '=', True),
]
if grp_opts.get('export_mode') == 'print' and grp_opts.get('filter_search_bar'):
domain_extra.append(('account_id', 'ilike', grp_opts['filter_search_bar']))
if grp_opts.get('include_current_year_in_unaff_earnings'):
domain_extra += [('account_id.include_initial_balance', '=', True)]
qry = report._get_report_query(grp_opts, scope, domain=domain_extra)
parts.append(SQL(
"""
SELECT
account_move_line.account_id AS groupby,
'sum' AS key,
MAX(account_move_line.date) AS max_date,
%(col_key)s AS column_group_key,
COALESCE(SUM(account_move_line.amount_currency), 0.0) AS amount_currency,
SUM(%(dr)s) AS debit,
SUM(%(cr)s) AS credit,
SUM(%(bal)s) AS balance
FROM %(tbl)s
%(fx)s
WHERE %(cond)s
GROUP BY account_move_line.account_id
""",
col_key=col_key,
tbl=qry.from_clause,
dr=report._currency_table_apply_rate(SQL("account_move_line.debit")),
cr=report._currency_table_apply_rate(SQL("account_move_line.credit")),
bal=report._currency_table_apply_rate(SQL("account_move_line.balance")),
fx=report._currency_table_aml_join(grp_opts),
cond=qry.where_clause,
))
# Unaffected earnings sub-query
if not grp_opts.get('general_ledger_strict_range'):
unaff_opts = self._get_options_unaffected_earnings(grp_opts)
unaff_domain = [('account_id.include_initial_balance', '=', False)]
unaff_qry = report._get_report_query(unaff_opts, 'strict_range', domain=unaff_domain)
parts.append(SQL(
"""
SELECT
account_move_line.company_id AS groupby,
'unaffected_earnings' AS key,
NULL AS max_date,
%(col_key)s AS column_group_key,
COALESCE(SUM(account_move_line.amount_currency), 0.0) AS amount_currency,
SUM(%(dr)s) AS debit,
SUM(%(cr)s) AS credit,
SUM(%(bal)s) AS balance
FROM %(tbl)s
%(fx)s
WHERE %(cond)s
GROUP BY account_move_line.company_id
""",
col_key=col_key,
tbl=unaff_qry.from_clause,
dr=report._currency_table_apply_rate(SQL("account_move_line.debit")),
cr=report._currency_table_apply_rate(SQL("account_move_line.credit")),
bal=report._currency_table_apply_rate(SQL("account_move_line.balance")),
fx=report._currency_table_aml_join(grp_opts),
cond=unaff_qry.where_clause,
))
return SQL(" UNION ALL ").join(parts)
def _get_options_unaffected_earnings(self, options):
"""Return modified options for computing prior-year unaffected
earnings (P&L accounts before the current fiscal year)."""
modified = options.copy()
modified.pop('filter_search_bar', None)
fy = self.env.company.compute_fiscalyear_dates(
fields.Date.from_string(options['date']['date_from'])
)
cutoff = (
fields.Date.from_string(modified['date']['date_to'])
if options.get('include_current_year_in_unaff_earnings')
else fy['date_from'] - timedelta(days=1)
)
modified['date'] = self.env['account.report']._get_dates_period(None, cutoff, 'single')
return modified
# ------------------------------------------------------------------
# AML detail queries
# ------------------------------------------------------------------
def _fetch_aml_data(self, report, options, account_ids, offset=0, limit=None):
"""Load individual move lines for the given accounts.
Returns ``({account_id: {(aml_id, date): {col_grp: row}}}, has_more)``
"""
container = {aid: {} for aid in account_ids}
raw_sql = self._build_aml_query(report, options, account_ids, offset=offset, limit=limit)
self.env.cr.execute(raw_sql)
row_count = 0
overflow = False
for row in self.env.cr.dictfetchall():
row_count += 1
if row_count == limit:
overflow = True
break
# Build a display-friendly communication field
if row['ref'] and row['account_type'] != 'asset_receivable':
row['communication'] = f"{row['ref']} - {row['name']}"
else:
row['communication'] = row['name']
composite_key = (row['id'], row['date'])
acct_bucket = container[row['account_id']]
if composite_key not in acct_bucket:
acct_bucket[composite_key] = {cg: {} for cg in options['column_groups']}
prior = acct_bucket[composite_key][row['column_group_key']]
if prior:
prior['debit'] += row['debit']
prior['credit'] += row['credit']
prior['balance'] += row['balance']
prior['amount_currency'] += row['amount_currency']
else:
acct_bucket[composite_key][row['column_group_key']] = row
return container, overflow
def _build_aml_query(self, report, options, account_ids, offset=0, limit=None) -> SQL:
"""SQL for individual move lines within the strict period range."""
extra_domain = [('account_id', 'in', account_ids)] if account_ids is not None else None
fragments = []
journal_label = self.env['account.journal']._field_to_sql('journal', 'name')
for col_key, grp_opts in report._split_options_per_column_group(options).items():
qry = report._get_report_query(grp_opts, domain=extra_domain, date_scope='strict_range')
acct_a = qry.join(
lhs_alias='account_move_line', lhs_column='account_id',
rhs_table='account_account', rhs_column='id', link='account_id',
)
code_f = self.env['account.account']._field_to_sql(acct_a, 'code', qry)
name_f = self.env['account.account']._field_to_sql(acct_a, 'name')
type_f = self.env['account.account']._field_to_sql(acct_a, 'account_type')
fragments.append(SQL(
'''
SELECT
account_move_line.id,
account_move_line.date,
account_move_line.date_maturity,
account_move_line.name,
account_move_line.ref,
account_move_line.company_id,
account_move_line.account_id,
account_move_line.payment_id,
account_move_line.partner_id,
account_move_line.currency_id,
account_move_line.amount_currency,
COALESCE(account_move_line.invoice_date, account_move_line.date) AS invoice_date,
account_move_line.date AS date,
%(dr)s AS debit,
%(cr)s AS credit,
%(bal)s AS balance,
mv.name AS move_name,
co.currency_id AS company_currency_id,
prt.name AS partner_name,
mv.move_type AS move_type,
%(code_f)s AS account_code,
%(name_f)s AS account_name,
%(type_f)s AS account_type,
journal.code AS journal_code,
%(journal_label)s AS journal_name,
fr.id AS full_rec_name,
%(col_key)s AS column_group_key
FROM %(tbl)s
JOIN account_move mv ON mv.id = account_move_line.move_id
%(fx)s
LEFT JOIN res_company co ON co.id = account_move_line.company_id
LEFT JOIN res_partner prt ON prt.id = account_move_line.partner_id
LEFT JOIN account_journal journal ON journal.id = account_move_line.journal_id
LEFT JOIN account_full_reconcile fr ON fr.id = account_move_line.full_reconcile_id
WHERE %(cond)s
ORDER BY account_move_line.date, account_move_line.move_name, account_move_line.id
''',
code_f=code_f,
name_f=name_f,
type_f=type_f,
journal_label=journal_label,
col_key=col_key,
tbl=qry.from_clause,
fx=report._currency_table_aml_join(grp_opts),
dr=report._currency_table_apply_rate(SQL("account_move_line.debit")),
cr=report._currency_table_apply_rate(SQL("account_move_line.credit")),
bal=report._currency_table_apply_rate(SQL("account_move_line.balance")),
cond=qry.where_clause,
))
combined = SQL(" UNION ALL ").join(SQL("(%s)", f) for f in fragments)
if offset:
combined = SQL('%s OFFSET %s ', combined, offset)
if limit:
combined = SQL('%s LIMIT %s ', combined, limit)
return combined
# ------------------------------------------------------------------
# Initial balance
# ------------------------------------------------------------------
def _fetch_opening_balances(self, report, account_ids, options):
"""Compute the opening balance per account at the start of the
reporting period."""
parts = []
for col_key, grp_opts in report._split_options_per_column_group(options).items():
init_opts = self._get_options_initial_balance(grp_opts)
domain = [('account_id', 'in', account_ids)]
if not init_opts.get('general_ledger_strict_range'):
domain += [
'|',
('date', '>=', init_opts['date']['date_from']),
('account_id.include_initial_balance', '=', True),
]
if init_opts.get('include_current_year_in_unaff_earnings'):
domain += [('account_id.include_initial_balance', '=', True)]
qry = report._get_report_query(init_opts, 'from_beginning', domain=domain)
parts.append(SQL(
"""
SELECT
account_move_line.account_id AS groupby,
'initial_balance' AS key,
NULL AS max_date,
%(col_key)s AS column_group_key,
COALESCE(SUM(account_move_line.amount_currency), 0.0) AS amount_currency,
SUM(%(dr)s) AS debit,
SUM(%(cr)s) AS credit,
SUM(%(bal)s) AS balance
FROM %(tbl)s
%(fx)s
WHERE %(cond)s
GROUP BY account_move_line.account_id
""",
col_key=col_key,
tbl=qry.from_clause,
dr=report._currency_table_apply_rate(SQL("account_move_line.debit")),
cr=report._currency_table_apply_rate(SQL("account_move_line.credit")),
bal=report._currency_table_apply_rate(SQL("account_move_line.balance")),
fx=report._currency_table_aml_join(grp_opts),
cond=qry.where_clause,
))
self.env.cr.execute(SQL(" UNION ALL ").join(parts))
init_map = {
aid: {cg: {} for cg in options['column_groups']}
for aid in account_ids
}
for row in self.env.cr.dictfetchall():
init_map[row['groupby']][row['column_group_key']] = row
accts = self.env['account.account'].browse(account_ids)
return {a.id: (a, init_map[a.id]) for a in accts}
def _get_options_initial_balance(self, options):
"""Derive an options dict whose date range ends just before the
report's ``date_from``, suitable for computing opening balances."""
derived = options.copy()
# End date
raw_to = (
derived['comparison']['periods'][-1]['date_from']
if derived.get('comparison', {}).get('periods')
else derived['date']['date_from']
)
end_dt = fields.Date.from_string(raw_to) - timedelta(days=1)
# Start date: if date_from aligns with a fiscal-year boundary take the
# previous FY; otherwise use the current FY start.
start_dt = fields.Date.from_string(derived['date']['date_from'])
fy = self.env.company.compute_fiscalyear_dates(start_dt)
if start_dt == fy['date_from']:
prev_fy = self.env.company.compute_fiscalyear_dates(start_dt - timedelta(days=1))
begin_dt = prev_fy['date_from']
include_curr_yr = True
else:
begin_dt = fy['date_from']
include_curr_yr = False
derived['date'] = self.env['account.report']._get_dates_period(begin_dt, end_dt, 'range')
derived['include_current_year_in_unaff_earnings'] = include_curr_yr
return derived
# ------------------------------------------------------------------
# Line builders
# ------------------------------------------------------------------
def _build_account_header_line(self, report, options, account, has_entries, col_data):
"""Produce the foldable account-level line."""
cols = []
for col_def in options['columns']:
expr = col_def['expression_label']
raw = col_data.get(col_def['column_group_key'], {}).get(expr)
display_val = (
None
if raw is None or (expr == 'amount_currency' and not account.currency_id)
else raw
)
cols.append(report._build_column_dict(
display_val, col_def, options=options,
currency=account.currency_id if expr == 'amount_currency' else None,
))
lid = report._get_generic_line_id('account.account', account.id)
is_unfolded = any(
report._get_res_id_from_line_id(ul, 'account.account') == account.id
for ul in options.get('unfolded_lines')
)
return {
'id': lid,
'name': account.display_name,
'columns': cols,
'level': 1,
'unfoldable': has_entries,
'unfolded': has_entries and (is_unfolded or options.get('unfold_all')),
'expand_function': '_report_expand_unfoldable_line_general_ledger',
}
def _get_aml_line(self, report, parent_line_id, options, col_dict, running_bal):
"""Build a single move-line row under a given account header."""
cols = []
for col_def in options['columns']:
expr = col_def['expression_label']
raw = col_dict[col_def['column_group_key']].get(expr)
cur = None
if raw is not None:
if expr == 'amount_currency':
cur = self.env['res.currency'].browse(col_dict[col_def['column_group_key']]['currency_id'])
raw = None if cur == self.env.company.currency_id else raw
elif expr == 'balance':
raw += (running_bal[col_def['column_group_key']] or 0)
cols.append(report._build_column_dict(raw, col_def, options=options, currency=cur))
aml_id = None
move_label = None
caret = None
row_date = None
for grp_data in col_dict.values():
aml_id = grp_data.get('id', '')
if aml_id:
caret = 'account.payment' if grp_data.get('payment_id') else 'account.move.line'
move_label = grp_data['move_name']
row_date = str(grp_data.get('date', ''))
break
return {
'id': report._get_generic_line_id(
'account.move.line', aml_id,
parent_line_id=parent_line_id, markup=row_date,
),
'caret_options': caret,
'parent_id': parent_line_id,
'name': move_label,
'columns': cols,
'level': 3,
}
@api.model
def _build_grand_total_line(self, report, options, col_totals):
"""Build the bottom total row."""
cols = []
for col_def in options['columns']:
raw = col_totals[col_def['column_group_key']].get(col_def['expression_label'])
cols.append(report._build_column_dict(raw if raw is not None else None, col_def, options=options))
return {
'id': report._get_generic_line_id(None, None, markup='total'),
'name': _('Total'),
'level': 1,
'columns': cols,
}
# ------------------------------------------------------------------
# Caret / expand handlers
# ------------------------------------------------------------------
def caret_option_audit_tax(self, options, params):
return self.env['account.generic.tax.report.handler'].caret_option_audit_tax(options, params)
def _report_expand_unfoldable_line_general_ledger(
self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None,
):
"""Called when an account line is unfolded. Returns initial-balance,
individual AML lines, and load-more metadata."""
def _extract_running_balance(line_dict):
return {
c['column_group_key']: lc.get('no_format', 0)
for c, lc in zip(options['columns'], line_dict['columns'])
if c['expression_label'] == 'balance'
}
report = self.env.ref('fusion_accounting.general_ledger_report')
mdl, mdl_id = report._get_model_info_from_id(line_dict_id)
if mdl != 'account.account':
raise UserError(_("Invalid line ID for general ledger expansion: %s", line_dict_id))
lines = []
# Opening balance (only on first page)
if offset == 0:
if unfold_all_batch_data:
acct_rec, init_by_cg = unfold_all_batch_data['initial_balances'][mdl_id]
else:
acct_rec, init_by_cg = self._fetch_opening_balances(report, [mdl_id], options)[mdl_id]
opening_line = report._get_partner_and_general_ledger_initial_balance_line(
options, line_dict_id, init_by_cg, acct_rec.currency_id,
)
if opening_line:
lines.append(opening_line)
progress = _extract_running_balance(opening_line)
# Move lines
page_size = report.load_more_limit + 1 if report.load_more_limit and options['export_mode'] != 'print' else None
if unfold_all_batch_data:
aml_rows = unfold_all_batch_data['aml_results'][mdl_id]
has_more = unfold_all_batch_data['has_more'].get(mdl_id, False)
else:
aml_rows, has_more = self._fetch_aml_data(report, options, [mdl_id], offset=offset, limit=page_size)
aml_rows = aml_rows[mdl_id]
running = progress
for entry in aml_rows.values():
row_line = self._get_aml_line(report, line_dict_id, options, entry, running)
lines.append(row_line)
running = _extract_running_balance(row_line)
return {
'lines': lines,
'offset_increment': report.load_more_limit,
'has_more': has_more,
'progress': running,
}