855 lines
36 KiB
Python
855 lines
36 KiB
Python
# Fusion Accounting - Cash Flow Statement Report Handler
|
||
|
||
from odoo import models, _
|
||
from odoo.tools import SQL, Query
|
||
|
||
|
||
class CashFlowReportCustomHandler(models.AbstractModel):
|
||
"""Generates the cash flow statement using the direct method.
|
||
|
||
Reference: https://www.investopedia.com/terms/d/direct_method.asp
|
||
|
||
The handler fetches liquidity journal entries, splits them into
|
||
operating / investing / financing buckets based on account tags,
|
||
and renders both section totals and per-account detail rows.
|
||
"""
|
||
|
||
_name = 'account.cash.flow.report.handler'
|
||
_inherit = 'account.report.custom.handler'
|
||
_description = 'Cash Flow Report Custom Handler'
|
||
|
||
# ------------------------------------------------------------------
|
||
# Public entry points
|
||
# ------------------------------------------------------------------
|
||
|
||
def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None):
|
||
"""Build every line of the cash flow statement.
|
||
|
||
Returns a list of ``(sequence, line_dict)`` tuples ready for the
|
||
report engine.
|
||
"""
|
||
output_lines = []
|
||
|
||
section_structure = self._build_section_structure()
|
||
computed_data = self._compute_report_data(report, options, section_structure)
|
||
|
||
# Render each section header
|
||
for section_key, section_meta in section_structure.items():
|
||
output_lines.append(
|
||
(0, self._render_section_line(report, options, section_key, section_meta, computed_data))
|
||
)
|
||
|
||
# Render detail rows grouped by account under this section
|
||
if section_key in computed_data and 'aml_groupby_account' in computed_data[section_key]:
|
||
detail_entries = computed_data[section_key]['aml_groupby_account'].values()
|
||
|
||
# Separate entries with / without an account code for sorting
|
||
coded_entries = [e for e in detail_entries if e['account_code'] is not None]
|
||
uncoded_entries = [e for e in detail_entries if e['account_code'] is None]
|
||
|
||
sorted_details = sorted(coded_entries, key=lambda r: r['account_code']) + uncoded_entries
|
||
for detail in sorted_details:
|
||
output_lines.append((0, self._render_detail_line(report, options, detail)))
|
||
|
||
# Append an unexplained-difference line when the numbers don't tie
|
||
diff_line = self._render_unexplained_difference(report, options, computed_data)
|
||
if diff_line:
|
||
output_lines.append((0, diff_line))
|
||
|
||
return output_lines
|
||
|
||
def _custom_options_initializer(self, report, options, previous_options):
|
||
"""Restrict selectable journals to bank, cash, and general types."""
|
||
super()._custom_options_initializer(report, options, previous_options=previous_options)
|
||
report._init_options_journals(
|
||
options,
|
||
previous_options=previous_options,
|
||
additional_journals_domain=[('type', 'in', ('bank', 'cash', 'general'))],
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Data computation
|
||
# ------------------------------------------------------------------
|
||
|
||
def _compute_report_data(self, report, options, section_structure):
|
||
"""Aggregate all cash-flow numbers into *report_data*.
|
||
|
||
The returned dictionary maps section keys (from
|
||
``_build_section_structure``) to balance and per-account detail
|
||
dictionaries.
|
||
"""
|
||
report_data = {}
|
||
|
||
liquidity_acct_ids = self._fetch_liquidity_account_ids(report, options)
|
||
if not liquidity_acct_ids:
|
||
return report_data
|
||
|
||
# Beginning-of-period balances
|
||
for row in self._query_liquidity_balances(report, options, liquidity_acct_ids, 'to_beginning_of_period'):
|
||
self._merge_into_report_data('opening_balance', row, section_structure, report_data)
|
||
self._merge_into_report_data('closing_balance', row, section_structure, report_data)
|
||
|
||
# Period movements
|
||
for row in self._query_liquidity_balances(report, options, liquidity_acct_ids, 'strict_range'):
|
||
self._merge_into_report_data('closing_balance', row, section_structure, report_data)
|
||
|
||
tag_map = self._resolve_cashflow_tags()
|
||
cf_tag_ids = self._list_cashflow_tag_ids()
|
||
|
||
# Liquidity-side entries
|
||
for grouped_rows in self._fetch_liquidity_side_entries(report, options, liquidity_acct_ids, cf_tag_ids):
|
||
for row_data in grouped_rows.values():
|
||
self._route_entry_to_section(tag_map, row_data, section_structure, report_data)
|
||
|
||
# Reconciled counterpart entries
|
||
for grouped_rows in self._fetch_reconciled_counterparts(report, options, liquidity_acct_ids, cf_tag_ids):
|
||
for row_data in grouped_rows.values():
|
||
self._route_entry_to_section(tag_map, row_data, section_structure, report_data)
|
||
|
||
return report_data
|
||
|
||
def _merge_into_report_data(self, section_key, row, section_structure, report_data):
|
||
"""Insert or accumulate *row* into *report_data* under *section_key*.
|
||
|
||
Also propagates the balance upward through parent sections so that
|
||
all ancestor totals stay correct.
|
||
|
||
The *report_data* dictionary uses two sub-keys per section:
|
||
* ``balance`` – a ``{column_group_key: float}`` mapping
|
||
* ``aml_groupby_account`` – per-account detail rows
|
||
"""
|
||
|
||
def _propagate_to_parent(sec_key, col_grp, amount, structure, data):
|
||
"""Walk the parent chain and add *amount* to every ancestor."""
|
||
parent_ref = structure[sec_key].get('parent_line_id')
|
||
if parent_ref:
|
||
data.setdefault(parent_ref, {'balance': {}})
|
||
data[parent_ref]['balance'].setdefault(col_grp, 0.0)
|
||
data[parent_ref]['balance'][col_grp] += amount
|
||
_propagate_to_parent(parent_ref, col_grp, amount, structure, data)
|
||
|
||
col_grp = row['column_group_key']
|
||
acct_id = row['account_id']
|
||
acct_code = row['account_code']
|
||
acct_label = row['account_name']
|
||
amt = row['balance']
|
||
tag_ref = row.get('account_tag_id')
|
||
|
||
if self.env.company.currency_id.is_zero(amt):
|
||
return
|
||
|
||
report_data.setdefault(section_key, {
|
||
'balance': {},
|
||
'aml_groupby_account': {},
|
||
})
|
||
|
||
report_data[section_key]['aml_groupby_account'].setdefault(acct_id, {
|
||
'parent_line_id': section_key,
|
||
'account_id': acct_id,
|
||
'account_code': acct_code,
|
||
'account_name': acct_label,
|
||
'account_tag_id': tag_ref,
|
||
'level': section_structure[section_key]['level'] + 1,
|
||
'balance': {},
|
||
})
|
||
|
||
report_data[section_key]['balance'].setdefault(col_grp, 0.0)
|
||
report_data[section_key]['balance'][col_grp] += amt
|
||
|
||
acct_entry = report_data[section_key]['aml_groupby_account'][acct_id]
|
||
acct_entry['balance'].setdefault(col_grp, 0.0)
|
||
acct_entry['balance'][col_grp] += amt
|
||
|
||
_propagate_to_parent(section_key, col_grp, amt, section_structure, report_data)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Tag helpers
|
||
# ------------------------------------------------------------------
|
||
|
||
def _resolve_cashflow_tags(self):
|
||
"""Return a mapping of activity type to account.account.tag ID."""
|
||
return {
|
||
'operating': self.env.ref('account.account_tag_operating').id,
|
||
'investing': self.env.ref('account.account_tag_investing').id,
|
||
'financing': self.env.ref('account.account_tag_financing').id,
|
||
}
|
||
|
||
def _list_cashflow_tag_ids(self):
|
||
"""Return an iterable of all cash-flow-relevant tag IDs."""
|
||
return self._resolve_cashflow_tags().values()
|
||
|
||
def _route_entry_to_section(self, tag_map, entry, section_structure, report_data):
|
||
"""Determine the correct report section for a single entry and
|
||
merge it into *report_data*.
|
||
|
||
Receivable / payable lines go to advance-payment sections.
|
||
Other lines are classified by tag + sign (cash in vs cash out).
|
||
"""
|
||
acct_type = entry['account_account_type']
|
||
amt = entry['balance']
|
||
|
||
if acct_type == 'asset_receivable':
|
||
target = 'advance_payments_customer'
|
||
elif acct_type == 'liability_payable':
|
||
target = 'advance_payments_suppliers'
|
||
elif amt < 0:
|
||
tag_id = entry.get('account_tag_id')
|
||
if tag_id == tag_map['operating']:
|
||
target = 'paid_operating_activities'
|
||
elif tag_id == tag_map['investing']:
|
||
target = 'investing_activities_cash_out'
|
||
elif tag_id == tag_map['financing']:
|
||
target = 'financing_activities_cash_out'
|
||
else:
|
||
target = 'unclassified_activities_cash_out'
|
||
elif amt > 0:
|
||
tag_id = entry.get('account_tag_id')
|
||
if tag_id == tag_map['operating']:
|
||
target = 'received_operating_activities'
|
||
elif tag_id == tag_map['investing']:
|
||
target = 'investing_activities_cash_in'
|
||
elif tag_id == tag_map['financing']:
|
||
target = 'financing_activities_cash_in'
|
||
else:
|
||
target = 'unclassified_activities_cash_in'
|
||
else:
|
||
return
|
||
|
||
self._merge_into_report_data(target, entry, section_structure, report_data)
|
||
|
||
# ------------------------------------------------------------------
|
||
# SQL queries
|
||
# ------------------------------------------------------------------
|
||
|
||
def _fetch_liquidity_account_ids(self, report, options):
|
||
"""Return a tuple of account IDs used by liquidity journals.
|
||
|
||
Includes default accounts of bank/cash journals as well as any
|
||
payment-method-specific accounts.
|
||
"""
|
||
chosen_journal_ids = [j['id'] for j in report._get_options_journals(options)]
|
||
|
||
if chosen_journal_ids:
|
||
where_fragment = "aj.id IN %s"
|
||
where_args = [tuple(chosen_journal_ids)]
|
||
else:
|
||
where_fragment = "aj.type IN ('bank', 'cash', 'general')"
|
||
where_args = []
|
||
|
||
self.env.cr.execute(f'''
|
||
SELECT
|
||
array_remove(ARRAY_AGG(DISTINCT aa.id), NULL),
|
||
array_remove(ARRAY_AGG(DISTINCT apml.payment_account_id), NULL)
|
||
FROM account_journal aj
|
||
JOIN res_company rc ON aj.company_id = rc.id
|
||
LEFT JOIN account_payment_method_line apml
|
||
ON aj.id = apml.journal_id
|
||
LEFT JOIN account_account aa
|
||
ON aj.default_account_id = aa.id
|
||
AND aa.account_type IN ('asset_cash', 'liability_credit_card')
|
||
WHERE {where_fragment}
|
||
''', where_args)
|
||
|
||
fetched = self.env.cr.fetchone()
|
||
combined = set((fetched[0] or []) + (fetched[1] or []))
|
||
return tuple(combined) if combined else ()
|
||
|
||
def _build_move_ids_subquery(self, report, liquidity_acct_ids, col_group_opts) -> SQL:
|
||
"""Build a sub-select that returns move IDs touching liquidity accounts."""
|
||
base_query = report._get_report_query(
|
||
col_group_opts, 'strict_range',
|
||
[('account_id', 'in', list(liquidity_acct_ids))],
|
||
)
|
||
return SQL(
|
||
'''
|
||
SELECT array_agg(DISTINCT account_move_line.move_id) AS move_id
|
||
FROM %(tbl_refs)s
|
||
WHERE %(conditions)s
|
||
''',
|
||
tbl_refs=base_query.from_clause,
|
||
conditions=base_query.where_clause,
|
||
)
|
||
|
||
def _query_liquidity_balances(self, report, options, liquidity_acct_ids, scope):
|
||
"""Compute per-account balances for liquidity accounts.
|
||
|
||
*scope* is either ``'to_beginning_of_period'`` (opening) or
|
||
``'strict_range'`` (period movement).
|
||
"""
|
||
sql_parts = []
|
||
|
||
for col_key, col_opts in report._split_options_per_column_group(options).items():
|
||
qry = report._get_report_query(col_opts, scope, domain=[('account_id', 'in', liquidity_acct_ids)])
|
||
acct_alias = qry.join(
|
||
lhs_alias='account_move_line', lhs_column='account_id',
|
||
rhs_table='account_account', rhs_column='id', link='account_id',
|
||
)
|
||
code_sql = self.env['account.account']._field_to_sql(acct_alias, 'code', qry)
|
||
name_sql = self.env['account.account']._field_to_sql(acct_alias, 'name')
|
||
|
||
sql_parts.append(SQL(
|
||
'''
|
||
SELECT
|
||
%(col_key)s AS column_group_key,
|
||
account_move_line.account_id,
|
||
%(code_sql)s AS account_code,
|
||
%(name_sql)s AS account_name,
|
||
SUM(%(bal_expr)s) AS balance
|
||
FROM %(tbl_refs)s
|
||
%(fx_join)s
|
||
WHERE %(conditions)s
|
||
GROUP BY account_move_line.account_id, account_code, account_name
|
||
''',
|
||
col_key=col_key,
|
||
code_sql=code_sql,
|
||
name_sql=name_sql,
|
||
tbl_refs=qry.from_clause,
|
||
bal_expr=report._currency_table_apply_rate(SQL("account_move_line.balance")),
|
||
fx_join=report._currency_table_aml_join(col_opts),
|
||
conditions=qry.where_clause,
|
||
))
|
||
|
||
self.env.cr.execute(SQL(' UNION ALL ').join(sql_parts))
|
||
return self.env.cr.dictfetchall()
|
||
|
||
def _fetch_liquidity_side_entries(self, report, options, liquidity_acct_ids, cf_tag_ids):
|
||
"""Retrieve the non-liquidity side of moves that touch liquidity accounts.
|
||
|
||
Three sub-queries per column group capture:
|
||
1. Credit-side partial reconciliation amounts
|
||
2. Debit-side partial reconciliation amounts
|
||
3. Full line balances (for unreconciled portions)
|
||
|
||
Returns a list of dicts keyed by ``(account_id, column_group_key)``.
|
||
"""
|
||
aggregated = {}
|
||
sql_parts = []
|
||
|
||
for col_key, col_opts in report._split_options_per_column_group(options).items():
|
||
move_sub = self._build_move_ids_subquery(report, liquidity_acct_ids, col_opts)
|
||
q = Query(self.env, 'account_move_line')
|
||
acct_alias = q.join(
|
||
lhs_alias='account_move_line', lhs_column='account_id',
|
||
rhs_table='account_account', rhs_column='id', link='account_id',
|
||
)
|
||
code_sql = self.env['account.account']._field_to_sql(acct_alias, 'code', q)
|
||
name_sql = self.env['account.account']._field_to_sql(acct_alias, 'name')
|
||
type_sql = SQL.identifier(acct_alias, 'account_type')
|
||
|
||
sql_parts.append(SQL(
|
||
'''
|
||
(WITH liq_moves AS (%(move_sub)s)
|
||
|
||
-- 1) Credit-side partial amounts
|
||
SELECT
|
||
%(col_key)s AS column_group_key,
|
||
account_move_line.account_id,
|
||
%(code_sql)s AS account_code,
|
||
%(name_sql)s AS account_name,
|
||
%(type_sql)s AS account_account_type,
|
||
aat.account_account_tag_id AS account_tag_id,
|
||
SUM(%(partial_bal)s) AS balance
|
||
FROM %(from_cl)s
|
||
%(fx_join)s
|
||
LEFT JOIN account_partial_reconcile
|
||
ON account_partial_reconcile.credit_move_id = account_move_line.id
|
||
LEFT JOIN account_account_account_tag aat
|
||
ON aat.account_account_id = account_move_line.account_id
|
||
AND aat.account_account_tag_id IN %(cf_tags)s
|
||
WHERE account_move_line.move_id IN (SELECT unnest(liq_moves.move_id) FROM liq_moves)
|
||
AND account_move_line.account_id NOT IN %(liq_accts)s
|
||
AND account_partial_reconcile.max_date BETWEEN %(dt_from)s AND %(dt_to)s
|
||
GROUP BY account_move_line.company_id, account_move_line.account_id,
|
||
account_code, account_name, account_account_type,
|
||
aat.account_account_tag_id
|
||
|
||
UNION ALL
|
||
|
||
-- 2) Debit-side partial amounts (negated)
|
||
SELECT
|
||
%(col_key)s AS column_group_key,
|
||
account_move_line.account_id,
|
||
%(code_sql)s AS account_code,
|
||
%(name_sql)s AS account_name,
|
||
%(type_sql)s AS account_account_type,
|
||
aat.account_account_tag_id AS account_tag_id,
|
||
-SUM(%(partial_bal)s) AS balance
|
||
FROM %(from_cl)s
|
||
%(fx_join)s
|
||
LEFT JOIN account_partial_reconcile
|
||
ON account_partial_reconcile.debit_move_id = account_move_line.id
|
||
LEFT JOIN account_account_account_tag aat
|
||
ON aat.account_account_id = account_move_line.account_id
|
||
AND aat.account_account_tag_id IN %(cf_tags)s
|
||
WHERE account_move_line.move_id IN (SELECT unnest(liq_moves.move_id) FROM liq_moves)
|
||
AND account_move_line.account_id NOT IN %(liq_accts)s
|
||
AND account_partial_reconcile.max_date BETWEEN %(dt_from)s AND %(dt_to)s
|
||
GROUP BY account_move_line.company_id, account_move_line.account_id,
|
||
account_code, account_name, account_account_type,
|
||
aat.account_account_tag_id
|
||
|
||
UNION ALL
|
||
|
||
-- 3) Full line balances
|
||
SELECT
|
||
%(col_key)s AS column_group_key,
|
||
account_move_line.account_id,
|
||
%(code_sql)s AS account_code,
|
||
%(name_sql)s AS account_name,
|
||
%(type_sql)s AS account_account_type,
|
||
aat.account_account_tag_id AS account_tag_id,
|
||
SUM(%(line_bal)s) AS balance
|
||
FROM %(from_cl)s
|
||
%(fx_join)s
|
||
LEFT JOIN account_account_account_tag aat
|
||
ON aat.account_account_id = account_move_line.account_id
|
||
AND aat.account_account_tag_id IN %(cf_tags)s
|
||
WHERE account_move_line.move_id IN (SELECT unnest(liq_moves.move_id) FROM liq_moves)
|
||
AND account_move_line.account_id NOT IN %(liq_accts)s
|
||
GROUP BY account_move_line.account_id, account_code, account_name,
|
||
account_account_type, aat.account_account_tag_id)
|
||
''',
|
||
col_key=col_key,
|
||
move_sub=move_sub,
|
||
code_sql=code_sql,
|
||
name_sql=name_sql,
|
||
type_sql=type_sql,
|
||
from_cl=q.from_clause,
|
||
fx_join=report._currency_table_aml_join(col_opts),
|
||
partial_bal=report._currency_table_apply_rate(SQL("account_partial_reconcile.amount")),
|
||
line_bal=report._currency_table_apply_rate(SQL("account_move_line.balance")),
|
||
cf_tags=tuple(cf_tag_ids),
|
||
liq_accts=liquidity_acct_ids,
|
||
dt_from=col_opts['date']['date_from'],
|
||
dt_to=col_opts['date']['date_to'],
|
||
))
|
||
|
||
self.env.cr.execute(SQL(' UNION ALL ').join(sql_parts))
|
||
|
||
for rec in self.env.cr.dictfetchall():
|
||
acct_id = rec['account_id']
|
||
aggregated.setdefault(acct_id, {})
|
||
aggregated[acct_id].setdefault(rec['column_group_key'], {
|
||
'column_group_key': rec['column_group_key'],
|
||
'account_id': acct_id,
|
||
'account_code': rec['account_code'],
|
||
'account_name': rec['account_name'],
|
||
'account_account_type': rec['account_account_type'],
|
||
'account_tag_id': rec['account_tag_id'],
|
||
'balance': 0.0,
|
||
})
|
||
aggregated[acct_id][rec['column_group_key']]['balance'] -= rec['balance']
|
||
|
||
return list(aggregated.values())
|
||
|
||
def _fetch_reconciled_counterparts(self, report, options, liquidity_acct_ids, cf_tag_ids):
|
||
"""Retrieve moves reconciled with liquidity moves but that are not
|
||
themselves liquidity moves.
|
||
|
||
Each amount is valued proportionally to what has actually been paid,
|
||
so a partially-paid invoice appears at the paid percentage.
|
||
"""
|
||
reconciled_acct_ids_by_col = {cg: set() for cg in options['column_groups']}
|
||
pct_map = {cg: {} for cg in options['column_groups']}
|
||
fx_table = report._get_currency_table(options)
|
||
|
||
# Step 1 – gather reconciliation amounts per move / account
|
||
step1_parts = []
|
||
for col_key, col_opts in report._split_options_per_column_group(options).items():
|
||
move_sub = self._build_move_ids_subquery(report, liquidity_acct_ids, col_opts)
|
||
step1_parts.append(SQL(
|
||
'''
|
||
(WITH liq_moves AS (%(move_sub)s)
|
||
|
||
SELECT
|
||
%(col_key)s AS column_group_key,
|
||
dr.move_id, dr.account_id,
|
||
SUM(%(partial_amt)s) AS balance
|
||
FROM account_move_line AS cr
|
||
LEFT JOIN account_partial_reconcile
|
||
ON account_partial_reconcile.credit_move_id = cr.id
|
||
JOIN %(fx_tbl)s
|
||
ON account_currency_table.company_id = account_partial_reconcile.company_id
|
||
AND account_currency_table.rate_type = 'current'
|
||
INNER JOIN account_move_line AS dr
|
||
ON dr.id = account_partial_reconcile.debit_move_id
|
||
WHERE cr.move_id IN (SELECT unnest(liq_moves.move_id) FROM liq_moves)
|
||
AND cr.account_id NOT IN %(liq_accts)s
|
||
AND cr.credit > 0.0
|
||
AND dr.move_id NOT IN (SELECT unnest(liq_moves.move_id) FROM liq_moves)
|
||
AND account_partial_reconcile.max_date BETWEEN %(dt_from)s AND %(dt_to)s
|
||
GROUP BY dr.move_id, dr.account_id
|
||
|
||
UNION ALL
|
||
|
||
SELECT
|
||
%(col_key)s AS column_group_key,
|
||
cr2.move_id, cr2.account_id,
|
||
-SUM(%(partial_amt)s) AS balance
|
||
FROM account_move_line AS dr2
|
||
LEFT JOIN account_partial_reconcile
|
||
ON account_partial_reconcile.debit_move_id = dr2.id
|
||
JOIN %(fx_tbl)s
|
||
ON account_currency_table.company_id = account_partial_reconcile.company_id
|
||
AND account_currency_table.rate_type = 'current'
|
||
INNER JOIN account_move_line AS cr2
|
||
ON cr2.id = account_partial_reconcile.credit_move_id
|
||
WHERE dr2.move_id IN (SELECT unnest(liq_moves.move_id) FROM liq_moves)
|
||
AND dr2.account_id NOT IN %(liq_accts)s
|
||
AND dr2.debit > 0.0
|
||
AND cr2.move_id NOT IN (SELECT unnest(liq_moves.move_id) FROM liq_moves)
|
||
AND account_partial_reconcile.max_date BETWEEN %(dt_from)s AND %(dt_to)s
|
||
GROUP BY cr2.move_id, cr2.account_id)
|
||
''',
|
||
move_sub=move_sub,
|
||
col_key=col_key,
|
||
liq_accts=liquidity_acct_ids,
|
||
dt_from=col_opts['date']['date_from'],
|
||
dt_to=col_opts['date']['date_to'],
|
||
fx_tbl=fx_table,
|
||
partial_amt=report._currency_table_apply_rate(SQL("account_partial_reconcile.amount")),
|
||
))
|
||
|
||
self.env.cr.execute(SQL(' UNION ALL ').join(step1_parts))
|
||
|
||
for rec in self.env.cr.dictfetchall():
|
||
cg = rec['column_group_key']
|
||
pct_map[cg].setdefault(rec['move_id'], {})
|
||
pct_map[cg][rec['move_id']].setdefault(rec['account_id'], [0.0, 0.0])
|
||
pct_map[cg][rec['move_id']][rec['account_id']][0] += rec['balance']
|
||
reconciled_acct_ids_by_col[cg].add(rec['account_id'])
|
||
|
||
if not any(pct_map.values()):
|
||
return []
|
||
|
||
# Step 2 – total balance per move / reconciled account
|
||
step2_parts = []
|
||
for col in options['columns']:
|
||
cg = col['column_group_key']
|
||
mv_ids = tuple(pct_map[cg].keys()) or (None,)
|
||
ac_ids = tuple(reconciled_acct_ids_by_col[cg]) or (None,)
|
||
step2_parts.append(SQL(
|
||
'''
|
||
SELECT
|
||
%(col_key)s AS column_group_key,
|
||
account_move_line.move_id,
|
||
account_move_line.account_id,
|
||
SUM(%(bal_expr)s) AS balance
|
||
FROM account_move_line
|
||
JOIN %(fx_tbl)s
|
||
ON account_currency_table.company_id = account_move_line.company_id
|
||
AND account_currency_table.rate_type = 'current'
|
||
WHERE account_move_line.move_id IN %(mv_ids)s
|
||
AND account_move_line.account_id IN %(ac_ids)s
|
||
GROUP BY account_move_line.move_id, account_move_line.account_id
|
||
''',
|
||
col_key=cg,
|
||
fx_tbl=fx_table,
|
||
bal_expr=report._currency_table_apply_rate(SQL("account_move_line.balance")),
|
||
mv_ids=mv_ids,
|
||
ac_ids=ac_ids,
|
||
))
|
||
|
||
self.env.cr.execute(SQL(' UNION ALL ').join(step2_parts))
|
||
for rec in self.env.cr.dictfetchall():
|
||
cg = rec['column_group_key']
|
||
mv = rec['move_id']
|
||
ac = rec['account_id']
|
||
if ac in pct_map[cg].get(mv, {}):
|
||
pct_map[cg][mv][ac][1] += rec['balance']
|
||
|
||
# Step 3 – fetch full detail with account type & tag, then apply pct
|
||
result_map = {}
|
||
|
||
detail_q = Query(self.env, 'account_move_line')
|
||
acct_a = detail_q.join(
|
||
lhs_alias='account_move_line', lhs_column='account_id',
|
||
rhs_table='account_account', rhs_column='id', link='account_id',
|
||
)
|
||
code_fld = self.env['account.account']._field_to_sql(acct_a, 'code', detail_q)
|
||
name_fld = self.env['account.account']._field_to_sql(acct_a, 'name')
|
||
type_fld = SQL.identifier(acct_a, 'account_type')
|
||
|
||
step3_parts = []
|
||
for col in options['columns']:
|
||
cg = col['column_group_key']
|
||
step3_parts.append(SQL(
|
||
'''
|
||
SELECT
|
||
%(col_key)s AS column_group_key,
|
||
account_move_line.move_id,
|
||
account_move_line.account_id,
|
||
%(code_fld)s AS account_code,
|
||
%(name_fld)s AS account_name,
|
||
%(type_fld)s AS account_account_type,
|
||
aat.account_account_tag_id AS account_tag_id,
|
||
SUM(%(bal_expr)s) AS balance
|
||
FROM %(from_cl)s
|
||
%(fx_join)s
|
||
LEFT JOIN account_account_account_tag aat
|
||
ON aat.account_account_id = account_move_line.account_id
|
||
AND aat.account_account_tag_id IN %(cf_tags)s
|
||
WHERE account_move_line.move_id IN %(mv_ids)s
|
||
GROUP BY account_move_line.move_id, account_move_line.account_id,
|
||
account_code, account_name, account_account_type,
|
||
aat.account_account_tag_id
|
||
''',
|
||
col_key=cg,
|
||
code_fld=code_fld,
|
||
name_fld=name_fld,
|
||
type_fld=type_fld,
|
||
from_cl=detail_q.from_clause,
|
||
fx_join=report._currency_table_aml_join(options),
|
||
bal_expr=report._currency_table_apply_rate(SQL("account_move_line.balance")),
|
||
cf_tags=tuple(cf_tag_ids),
|
||
mv_ids=tuple(pct_map[cg].keys()) or (None,),
|
||
))
|
||
|
||
self.env.cr.execute(SQL(' UNION ALL ').join(step3_parts))
|
||
|
||
for rec in self.env.cr.dictfetchall():
|
||
cg = rec['column_group_key']
|
||
mv = rec['move_id']
|
||
ac = rec['account_id']
|
||
line_bal = rec['balance']
|
||
|
||
# Sum reconciled & total for the whole move
|
||
sum_reconciled = 0.0
|
||
sum_total = 0.0
|
||
for r_amt, t_amt in pct_map[cg][mv].values():
|
||
sum_reconciled += r_amt
|
||
sum_total += t_amt
|
||
|
||
# Compute the applicable portion
|
||
if sum_total and ac not in pct_map[cg][mv]:
|
||
ratio = sum_reconciled / sum_total
|
||
line_bal *= ratio
|
||
elif not sum_total and ac in pct_map[cg][mv]:
|
||
line_bal = -pct_map[cg][mv][ac][0]
|
||
else:
|
||
continue
|
||
|
||
result_map.setdefault(ac, {})
|
||
result_map[ac].setdefault(cg, {
|
||
'column_group_key': cg,
|
||
'account_id': ac,
|
||
'account_code': rec['account_code'],
|
||
'account_name': rec['account_name'],
|
||
'account_account_type': rec['account_account_type'],
|
||
'account_tag_id': rec['account_tag_id'],
|
||
'balance': 0.0,
|
||
})
|
||
result_map[ac][cg]['balance'] -= line_bal
|
||
|
||
return list(result_map.values())
|
||
|
||
# ------------------------------------------------------------------
|
||
# Line rendering
|
||
# ------------------------------------------------------------------
|
||
|
||
def _build_section_structure(self):
|
||
"""Define the hierarchical layout of the cash flow statement.
|
||
|
||
Returns an ordered dictionary whose keys identify each section and
|
||
whose values carry the display name, nesting level, parent reference,
|
||
and optional CSS class.
|
||
"""
|
||
return {
|
||
'opening_balance': {
|
||
'name': _('Cash and cash equivalents, beginning of period'),
|
||
'level': 0,
|
||
},
|
||
'net_increase': {
|
||
'name': _('Net increase in cash and cash equivalents'),
|
||
'level': 0,
|
||
'unfolded': True,
|
||
},
|
||
'operating_activities': {
|
||
'name': _('Cash flows from operating activities'),
|
||
'level': 2,
|
||
'parent_line_id': 'net_increase',
|
||
'class': 'fw-bold',
|
||
'unfolded': True,
|
||
},
|
||
'advance_payments_customer': {
|
||
'name': _('Advance Payments received from customers'),
|
||
'level': 4,
|
||
'parent_line_id': 'operating_activities',
|
||
},
|
||
'received_operating_activities': {
|
||
'name': _('Cash received from operating activities'),
|
||
'level': 4,
|
||
'parent_line_id': 'operating_activities',
|
||
},
|
||
'advance_payments_suppliers': {
|
||
'name': _('Advance payments made to suppliers'),
|
||
'level': 4,
|
||
'parent_line_id': 'operating_activities',
|
||
},
|
||
'paid_operating_activities': {
|
||
'name': _('Cash paid for operating activities'),
|
||
'level': 4,
|
||
'parent_line_id': 'operating_activities',
|
||
},
|
||
'investing_activities': {
|
||
'name': _('Cash flows from investing & extraordinary activities'),
|
||
'level': 2,
|
||
'parent_line_id': 'net_increase',
|
||
'class': 'fw-bold',
|
||
'unfolded': True,
|
||
},
|
||
'investing_activities_cash_in': {
|
||
'name': _('Cash in'),
|
||
'level': 4,
|
||
'parent_line_id': 'investing_activities',
|
||
},
|
||
'investing_activities_cash_out': {
|
||
'name': _('Cash out'),
|
||
'level': 4,
|
||
'parent_line_id': 'investing_activities',
|
||
},
|
||
'financing_activities': {
|
||
'name': _('Cash flows from financing activities'),
|
||
'level': 2,
|
||
'parent_line_id': 'net_increase',
|
||
'class': 'fw-bold',
|
||
'unfolded': True,
|
||
},
|
||
'financing_activities_cash_in': {
|
||
'name': _('Cash in'),
|
||
'level': 4,
|
||
'parent_line_id': 'financing_activities',
|
||
},
|
||
'financing_activities_cash_out': {
|
||
'name': _('Cash out'),
|
||
'level': 4,
|
||
'parent_line_id': 'financing_activities',
|
||
},
|
||
'unclassified_activities': {
|
||
'name': _('Cash flows from unclassified activities'),
|
||
'level': 2,
|
||
'parent_line_id': 'net_increase',
|
||
'class': 'fw-bold',
|
||
'unfolded': True,
|
||
},
|
||
'unclassified_activities_cash_in': {
|
||
'name': _('Cash in'),
|
||
'level': 4,
|
||
'parent_line_id': 'unclassified_activities',
|
||
},
|
||
'unclassified_activities_cash_out': {
|
||
'name': _('Cash out'),
|
||
'level': 4,
|
||
'parent_line_id': 'unclassified_activities',
|
||
},
|
||
'closing_balance': {
|
||
'name': _('Cash and cash equivalents, closing balance'),
|
||
'level': 0,
|
||
},
|
||
}
|
||
|
||
def _render_section_line(self, report, options, section_key, section_meta, report_data):
|
||
"""Produce a single section / header line dictionary."""
|
||
line_id = report._get_generic_line_id(None, None, markup=section_key)
|
||
has_detail = (
|
||
section_key in report_data
|
||
and 'aml_groupby_account' in report_data[section_key]
|
||
)
|
||
|
||
col_vals = []
|
||
for col in options['columns']:
|
||
expr = col['expression_label']
|
||
cg = col['column_group_key']
|
||
raw = (
|
||
report_data[section_key][expr].get(cg, 0.0)
|
||
if section_key in report_data
|
||
else 0.0
|
||
)
|
||
col_vals.append(report._build_column_dict(raw, col, options=options))
|
||
|
||
return {
|
||
'id': line_id,
|
||
'name': section_meta['name'],
|
||
'level': section_meta['level'],
|
||
'class': section_meta.get('class', ''),
|
||
'columns': col_vals,
|
||
'unfoldable': has_detail,
|
||
'unfolded': (
|
||
line_id in options['unfolded_lines']
|
||
or section_meta.get('unfolded')
|
||
or (options.get('unfold_all') and has_detail)
|
||
),
|
||
}
|
||
|
||
def _render_detail_line(self, report, options, detail):
|
||
"""Produce a per-account detail line under a section."""
|
||
parent_id = report._get_generic_line_id(None, None, detail['parent_line_id'])
|
||
line_id = report._get_generic_line_id(
|
||
'account.account', detail['account_id'], parent_line_id=parent_id,
|
||
)
|
||
|
||
col_vals = []
|
||
for col in options['columns']:
|
||
expr = col['expression_label']
|
||
cg = col['column_group_key']
|
||
raw = detail[expr].get(cg, 0.0)
|
||
col_vals.append(report._build_column_dict(raw, col, options=options))
|
||
|
||
display_name = (
|
||
f"{detail['account_code']} {detail['account_name']}"
|
||
if detail['account_code']
|
||
else detail['account_name']
|
||
)
|
||
|
||
return {
|
||
'id': line_id,
|
||
'name': display_name,
|
||
'caret_options': 'account.account',
|
||
'level': detail['level'],
|
||
'parent_id': parent_id,
|
||
'columns': col_vals,
|
||
}
|
||
|
||
def _render_unexplained_difference(self, report, options, report_data):
|
||
"""If closing != opening + net_increase, emit an extra line showing
|
||
the gap so the user can investigate."""
|
||
found_gap = False
|
||
col_vals = []
|
||
|
||
for col in options['columns']:
|
||
expr = col['expression_label']
|
||
cg = col['column_group_key']
|
||
|
||
opening = (
|
||
report_data['opening_balance'][expr].get(cg, 0.0)
|
||
if 'opening_balance' in report_data else 0.0
|
||
)
|
||
closing = (
|
||
report_data['closing_balance'][expr].get(cg, 0.0)
|
||
if 'closing_balance' in report_data else 0.0
|
||
)
|
||
net_chg = (
|
||
report_data['net_increase'][expr].get(cg, 0.0)
|
||
if 'net_increase' in report_data else 0.0
|
||
)
|
||
|
||
gap = closing - opening - net_chg
|
||
|
||
if not self.env.company.currency_id.is_zero(gap):
|
||
found_gap = True
|
||
|
||
col_vals.append(report._build_column_dict(
|
||
gap,
|
||
{'figure_type': 'monetary', 'expression_label': 'balance'},
|
||
options=options,
|
||
))
|
||
|
||
if found_gap:
|
||
return {
|
||
'id': report._get_generic_line_id(None, None, markup='unexplained_difference'),
|
||
'name': _('Unexplained Difference'),
|
||
'level': 1,
|
||
'columns': col_vals,
|
||
}
|
||
return None
|