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,550 @@
# Fusion Accounting - Aged Partner Balance Report Handler
import datetime
from odoo import models, fields, _
from odoo.tools import SQL
from odoo.tools.misc import format_date
from dateutil.relativedelta import relativedelta
from itertools import chain
class AgedPartnerBalanceCustomHandler(models.AbstractModel):
"""Base handler for aged receivable / payable reports.
Groups outstanding amounts into configurable aging buckets so the user
can visualise how long balances have been open.
"""
_name = 'account.aged.partner.balance.report.handler'
_inherit = 'account.report.custom.handler'
_description = 'Aged Partner Balance Custom Handler'
# ------------------------------------------------------------------
# Display & options
# ------------------------------------------------------------------
def _get_custom_display_config(self):
return {
'css_custom_class': 'aged_partner_balance',
'templates': {
'AccountReportLineName': 'fusion_accounting.AgedPartnerBalanceLineName',
},
'components': {
'AccountReportFilters': 'fusion_accounting.AgedPartnerBalanceFilters',
},
}
def _custom_options_initializer(self, report, options, previous_options):
"""Configure multi-currency, aging interval, and column labels."""
super()._custom_options_initializer(report, options, previous_options=previous_options)
cols_to_hide = set()
options['multi_currency'] = report.env.user.has_group('base.group_multi_currency')
options['show_currency'] = (
options['multi_currency']
and (previous_options or {}).get('show_currency', False)
)
if not options['show_currency']:
cols_to_hide.update(['amount_currency', 'currency'])
options['show_account'] = (previous_options or {}).get('show_account', False)
if not options['show_account']:
cols_to_hide.add('account_name')
options['columns'] = [
c for c in options['columns']
if c['expression_label'] not in cols_to_hide
]
options['order_column'] = previous_options.get('order_column') or {
'expression_label': 'invoice_date',
'direction': 'ASC',
}
options['aging_based_on'] = previous_options.get('aging_based_on') or 'base_on_maturity_date'
options['aging_interval'] = previous_options.get('aging_interval') or 30
# Relabel period columns to reflect the chosen interval
bucket_size = options['aging_interval']
for col in options['columns']:
label = col['expression_label']
if label.startswith('period'):
bucket_idx = int(label.replace('period', '')) - 1
if 0 <= bucket_idx < 4:
lo = bucket_size * bucket_idx + 1
hi = bucket_size * (bucket_idx + 1)
col['name'] = f'{lo}-{hi}'
# ------------------------------------------------------------------
# Post-processing
# ------------------------------------------------------------------
def _custom_line_postprocessor(self, report, options, lines):
"""Inject the partner trust level into each partner line."""
partner_line_map = {}
for ln in lines:
mdl, mid = report._get_model_info_from_id(ln['id'])
if mdl == 'res.partner':
partner_line_map[mid] = ln
if partner_line_map:
partners = self.env['res.partner'].browse(partner_line_map)
for partner, ln_dict in zip(partners, partner_line_map.values()):
ln_dict['trust'] = partner.with_company(
partner.company_id or self.env.company
).trust
return lines
# ------------------------------------------------------------------
# Report engines
# ------------------------------------------------------------------
def _report_custom_engine_aged_receivable(
self, expressions, options, date_scope,
current_groupby, next_groupby,
offset=0, limit=None, warnings=None,
):
return self._aged_partner_report_custom_engine_common(
options, 'asset_receivable', current_groupby, next_groupby,
offset=offset, limit=limit,
)
def _report_custom_engine_aged_payable(
self, expressions, options, date_scope,
current_groupby, next_groupby,
offset=0, limit=None, warnings=None,
):
return self._aged_partner_report_custom_engine_common(
options, 'liability_payable', current_groupby, next_groupby,
offset=offset, limit=limit,
)
def _aged_partner_report_custom_engine_common(
self, options, account_type, current_groupby, next_groupby,
offset=0, limit=None,
):
"""Core query and aggregation logic shared by receivable and payable.
Builds aging periods dynamically from the chosen interval, runs a
single SQL query that joins against a period table, and returns
either a flat result or a list of ``(grouping_key, result)`` pairs.
"""
report = self.env['account.report'].browse(options['report_id'])
report._check_groupby_fields(
(next_groupby.split(',') if next_groupby else [])
+ ([current_groupby] if current_groupby else [])
)
def _subtract_days(dt, n):
return fields.Date.to_string(dt - relativedelta(days=n))
# Determine the aging date field
if options['aging_based_on'] == 'base_on_invoice_date':
age_field = SQL.identifier('invoice_date')
else:
age_field = SQL.identifier('date_maturity')
report_end = fields.Date.from_string(options['date']['date_to'])
interval = options['aging_interval']
# Build period boundaries: [(start_or_None, stop_or_None), ...]
period_list = [(False, fields.Date.to_string(report_end))]
period_col_count = len([
c for c in options['columns'] if c['expression_label'].startswith('period')
]) - 1
for p in range(period_col_count):
p_start = _subtract_days(report_end, interval * p + 1)
p_stop = _subtract_days(report_end, interval * (p + 1)) if p < period_col_count - 1 else False
period_list.append((p_start, p_stop))
# Helper: aggregate query rows into the result dictionary
def _aggregate_rows(rpt, rows):
agg = {f'period{k}': 0 for k in range(len(period_list))}
for r in rows:
for k in range(len(period_list)):
agg[f'period{k}'] += r[f'period{k}']
if current_groupby == 'id':
single = rows[0]
cur_obj = (
self.env['res.currency'].browse(single['currency_id'][0])
if len(single['currency_id']) == 1 else None
)
agg.update({
'invoice_date': single['invoice_date'][0] if len(single['invoice_date']) == 1 else None,
'due_date': single['due_date'][0] if len(single['due_date']) == 1 else None,
'amount_currency': single['amount_currency'],
'currency_id': single['currency_id'][0] if len(single['currency_id']) == 1 else None,
'currency': cur_obj.display_name if cur_obj else None,
'account_name': single['account_name'][0] if len(single['account_name']) == 1 else None,
'total': None,
'has_sublines': single['aml_count'] > 0,
'partner_id': single['partner_id'][0] if single['partner_id'] else None,
})
else:
agg.update({
'invoice_date': None,
'due_date': None,
'amount_currency': None,
'currency_id': None,
'currency': None,
'account_name': None,
'total': sum(agg[f'period{k}'] for k in range(len(period_list))),
'has_sublines': False,
})
return agg
# Build the VALUES clause for the period table
period_vals_fmt = '(VALUES %s)' % ','.join('(%s, %s, %s)' for _ in period_list)
flat_params = list(chain.from_iterable(
(p[0] or None, p[1] or None, idx)
for idx, p in enumerate(period_list)
))
period_tbl = SQL(period_vals_fmt, *flat_params)
# Build the main report query
base_qry = report._get_report_query(
options, 'strict_range',
domain=[('account_id.account_type', '=', account_type)],
)
acct_alias = base_qry.left_join(
lhs_alias='account_move_line', lhs_column='account_id',
rhs_table='account_account', rhs_column='id', link='account_id',
)
acct_code_sql = self.env['account.account']._field_to_sql(acct_alias, 'code', base_qry)
fixed_groupby = SQL("period_table.period_index")
if current_groupby:
select_grp = SQL("%s AS grouping_key,", SQL.identifier("account_move_line", current_groupby))
full_groupby = SQL("%s, %s", SQL.identifier("account_move_line", current_groupby), fixed_groupby)
else:
select_grp = SQL()
full_groupby = fixed_groupby
sign = -1 if account_type == 'liability_payable' else 1
period_case_sql = SQL(',').join(
SQL("""
CASE WHEN period_table.period_index = %(idx)s
THEN %(sign)s * SUM(%(bal)s)
ELSE 0 END AS %(col_id)s
""",
idx=n,
sign=sign,
col_id=SQL.identifier(f"period{n}"),
bal=report._currency_table_apply_rate(SQL(
"account_move_line.balance"
" - COALESCE(part_debit.amount, 0)"
" + COALESCE(part_credit.amount, 0)"
)),
)
for n in range(len(period_list))
)
tail_sql = report._get_engine_query_tail(offset, limit)
full_sql = SQL(
"""
WITH period_table(date_start, date_stop, period_index) AS (%(period_tbl)s)
SELECT
%(select_grp)s
%(sign)s * (
SUM(account_move_line.amount_currency)
- COALESCE(SUM(part_debit.debit_amount_currency), 0)
+ COALESCE(SUM(part_credit.credit_amount_currency), 0)
) AS amount_currency,
ARRAY_AGG(DISTINCT account_move_line.partner_id) AS partner_id,
ARRAY_AGG(account_move_line.payment_id) AS payment_id,
ARRAY_AGG(DISTINCT move.invoice_date) AS invoice_date,
ARRAY_AGG(DISTINCT COALESCE(account_move_line.%(age_field)s, account_move_line.date)) AS report_date,
ARRAY_AGG(DISTINCT %(acct_code)s) AS account_name,
ARRAY_AGG(DISTINCT COALESCE(account_move_line.%(age_field)s, account_move_line.date)) AS due_date,
ARRAY_AGG(DISTINCT account_move_line.currency_id) AS currency_id,
COUNT(account_move_line.id) AS aml_count,
ARRAY_AGG(%(acct_code)s) AS account_code,
%(period_case_sql)s
FROM %(tbl_refs)s
JOIN account_journal jnl ON jnl.id = account_move_line.journal_id
JOIN account_move move ON move.id = account_move_line.move_id
%(fx_join)s
LEFT JOIN LATERAL (
SELECT
SUM(pr.amount) AS amount,
SUM(pr.debit_amount_currency) AS debit_amount_currency,
pr.debit_move_id
FROM account_partial_reconcile pr
WHERE pr.max_date <= %(cutoff)s AND pr.debit_move_id = account_move_line.id
GROUP BY pr.debit_move_id
) part_debit ON TRUE
LEFT JOIN LATERAL (
SELECT
SUM(pr.amount) AS amount,
SUM(pr.credit_amount_currency) AS credit_amount_currency,
pr.credit_move_id
FROM account_partial_reconcile pr
WHERE pr.max_date <= %(cutoff)s AND pr.credit_move_id = account_move_line.id
GROUP BY pr.credit_move_id
) part_credit ON TRUE
JOIN period_table ON
(
period_table.date_start IS NULL
OR COALESCE(account_move_line.%(age_field)s, account_move_line.date) <= DATE(period_table.date_start)
)
AND
(
period_table.date_stop IS NULL
OR COALESCE(account_move_line.%(age_field)s, account_move_line.date) >= DATE(period_table.date_stop)
)
WHERE %(where_cond)s
GROUP BY %(full_groupby)s
HAVING
ROUND(SUM(%(having_dr)s), %(precision)s) != 0
OR ROUND(SUM(%(having_cr)s), %(precision)s) != 0
ORDER BY %(full_groupby)s
%(tail)s
""",
acct_code=acct_code_sql,
period_tbl=period_tbl,
select_grp=select_grp,
period_case_sql=period_case_sql,
sign=sign,
age_field=age_field,
tbl_refs=base_qry.from_clause,
fx_join=report._currency_table_aml_join(options),
cutoff=report_end,
where_cond=base_qry.where_clause,
full_groupby=full_groupby,
having_dr=report._currency_table_apply_rate(SQL(
"CASE WHEN account_move_line.balance > 0 THEN account_move_line.balance ELSE 0 END"
" - COALESCE(part_debit.amount, 0)"
)),
having_cr=report._currency_table_apply_rate(SQL(
"CASE WHEN account_move_line.balance < 0 THEN -account_move_line.balance ELSE 0 END"
" - COALESCE(part_credit.amount, 0)"
)),
precision=self.env.company.currency_id.decimal_places,
tail=tail_sql,
)
self.env.cr.execute(full_sql)
fetched_rows = self.env.cr.dictfetchall()
if not current_groupby:
return _aggregate_rows(report, fetched_rows)
# Group rows by their grouping key
grouped = {}
for row in fetched_rows:
gk = row['grouping_key']
grouped.setdefault(gk, []).append(row)
return [(gk, _aggregate_rows(report, rows)) for gk, rows in grouped.items()]
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def open_journal_items(self, options, params):
params['view_ref'] = 'account.view_move_line_tree_grouped_partner'
audit_opts = {**options, 'date': {**options['date'], 'date_from': None}}
report = self.env['account.report'].browse(options['report_id'])
action = report.open_journal_items(options=audit_opts, params=params)
action.get('context', {}).update({
'search_default_group_by_account': 0,
'search_default_group_by_partner': 1,
})
return action
def open_partner_ledger(self, options, params):
report = self.env['account.report'].browse(options['report_id'])
rec_model, rec_id = report._get_model_info_from_id(params.get('line_id'))
return self.env[rec_model].browse(rec_id).open_partner_ledger()
# ------------------------------------------------------------------
# Batch unfold
# ------------------------------------------------------------------
def _common_custom_unfold_all_batch_data_generator(
self, account_type, report, options, lines_to_expand_by_function,
):
"""Pre-load all data needed to unfold every partner in one pass."""
output = {}
num_periods = 6
for fn_name, expand_lines in lines_to_expand_by_function.items():
for target_line in expand_lines:
if fn_name != '_report_expand_unfoldable_line_with_groupby':
continue
report_line_id = report._get_res_id_from_line_id(target_line['id'], 'account.report.line')
custom_exprs = report.line_ids.expression_ids.filtered(
lambda x: x.report_line_id.id == report_line_id and x.engine == 'custom'
)
if not custom_exprs:
continue
for cg_key, cg_opts in report._split_options_per_column_group(options).items():
by_partner = {}
for aml_id, aml_vals in self._aged_partner_report_custom_engine_common(
cg_opts, account_type, 'id', None,
):
aml_vals['aml_id'] = aml_id
by_partner.setdefault(aml_vals['partner_id'], []).append(aml_vals)
partner_expr_totals = (
output
.setdefault(f"[{report_line_id}]=>partner_id", {})
.setdefault(cg_key, {expr: {'value': []} for expr in custom_exprs})
)
for pid, aml_list in by_partner.items():
pv = self._prepare_partner_values()
for k in range(num_periods):
pv[f'period{k}'] = 0
aml_expr_totals = (
output
.setdefault(f"[{report_line_id}]partner_id:{pid}=>id", {})
.setdefault(cg_key, {expr: {'value': []} for expr in custom_exprs})
)
for aml_data in aml_list:
for k in range(num_periods):
period_val = aml_data[f'period{k}']
pv[f'period{k}'] += period_val
pv['total'] += period_val
for expr in custom_exprs:
aml_expr_totals[expr]['value'].append(
(aml_data['aml_id'], aml_data[expr.subformula])
)
for expr in custom_exprs:
partner_expr_totals[expr]['value'].append(
(pid, pv[expr.subformula])
)
return output
def _prepare_partner_values(self):
return {
'invoice_date': None,
'due_date': None,
'amount_currency': None,
'currency_id': None,
'currency': None,
'account_name': None,
'total': 0,
}
# ------------------------------------------------------------------
# Audit action
# ------------------------------------------------------------------
def aged_partner_balance_audit(self, options, params, journal_type):
"""Open a filtered list of invoices / bills for the clicked cell."""
report = self.env['account.report'].browse(options['report_id'])
action = self.env['ir.actions.actions']._for_xml_id('account.action_amounts_to_settle')
excluded_type = {'purchase': 'sale', 'sale': 'purchase'}
if options:
action['domain'] = [
('account_id.reconcile', '=', True),
('journal_id.type', '!=', excluded_type.get(journal_type)),
*self._build_domain_from_period(options, params['expression_label']),
*report._get_options_domain(options, 'from_beginning'),
*report._get_audit_line_groupby_domain(params['calling_line_dict_id']),
]
return action
def _build_domain_from_period(self, options, period_label):
"""Translate a period column label into a date-maturity domain."""
if period_label == 'total' or not period_label[-1].isdigit():
return []
bucket_num = int(period_label[-1])
end_date = datetime.datetime.strptime(options['date']['date_to'], '%Y-%m-%d')
if bucket_num == 0:
return [('date_maturity', '>=', options['date']['date_to'])]
upper_bound = end_date - datetime.timedelta(30 * (bucket_num - 1) + 1)
lower_bound = end_date - datetime.timedelta(30 * bucket_num)
if bucket_num == 5:
return [('date_maturity', '<=', lower_bound)]
return [
('date_maturity', '>=', lower_bound),
('date_maturity', '<=', upper_bound),
]
# ======================================================================
# Payable sub-handler
# ======================================================================
class AgedPayableCustomHandler(models.AbstractModel):
"""Specialised handler for aged payable balances."""
_name = 'account.aged.payable.report.handler'
_inherit = 'account.aged.partner.balance.report.handler'
_description = 'Aged Payable Custom Handler'
def open_journal_items(self, options, params):
payable_filter = {'id': 'trade_payable', 'name': _("Payable"), 'selected': True}
options.setdefault('account_type', []).append(payable_filter)
return super().open_journal_items(options, params)
def _custom_unfold_all_batch_data_generator(self, report, options, lines_to_expand_by_function):
ref_line = self.env.ref('fusion_accounting.aged_payable_line')
if ref_line.groupby.replace(' ', '') == 'partner_id,id':
return self._common_custom_unfold_all_batch_data_generator(
'liability_payable', report, options, lines_to_expand_by_function,
)
return {}
def action_audit_cell(self, options, params):
return super().aged_partner_balance_audit(options, params, 'purchase')
# ======================================================================
# Receivable sub-handler
# ======================================================================
class AgedReceivableCustomHandler(models.AbstractModel):
"""Specialised handler for aged receivable balances."""
_name = 'account.aged.receivable.report.handler'
_inherit = 'account.aged.partner.balance.report.handler'
_description = 'Aged Receivable Custom Handler'
def open_journal_items(self, options, params):
receivable_filter = {'id': 'trade_receivable', 'name': _("Receivable"), 'selected': True}
options.setdefault('account_type', []).append(receivable_filter)
return super().open_journal_items(options, params)
def _custom_unfold_all_batch_data_generator(self, report, options, lines_to_expand_by_function):
ref_line = self.env.ref('fusion_accounting.aged_receivable_line')
if ref_line.groupby.replace(' ', '') == 'partner_id,id':
return self._common_custom_unfold_all_batch_data_generator(
'asset_receivable', report, options, lines_to_expand_by_function,
)
return {}
def action_audit_cell(self, options, params):
return super().aged_partner_balance_audit(options, params, 'sale')