# 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')