551 lines
23 KiB
Python
551 lines
23 KiB
Python
# 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')
|