From ff6d21a561b6efc46ee6ac8694c831ae3cd7f29a Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 23:54:32 -0400 Subject: [PATCH] feat(fusion_accounting_reports): partner-grouped engine method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds engine.compute_partner_grouped(period, account_type=...) that returns per-partner aggregations with aging buckets (current/1-30/ 31-60/61-90/90+). SQL-direct for performance — single GROUP BY query with conditional sum per bucket. Foundation for the 3 partner-grouped reports landing in commit 3: Aged Receivable, Aged Payable, Partner Ledger. Made-with: Cursor --- .../models/fusion_report_engine.py | 126 +++++++++++++++++- .../tests/test_fusion_report_engine.py | 17 +++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/fusion_accounting_reports/models/fusion_report_engine.py b/fusion_accounting_reports/models/fusion_report_engine.py index 4f030f98..d28d3397 100644 --- a/fusion_accounting_reports/models/fusion_report_engine.py +++ b/fusion_accounting_reports/models/fusion_report_engine.py @@ -14,7 +14,7 @@ Internal pipeline (per report run): """ import logging -from datetime import date +from datetime import date, timedelta from odoo import _, api, models from odoo.exceptions import ValidationError @@ -118,6 +118,130 @@ class FusionReportEngine(models.AbstractModel): limit=500, ) + @api.model + def compute_partner_grouped( + self, period: Period, *, account_type: str = 'asset_receivable', + comparison: str = 'none', company_id: int | None = None, + ) -> dict: + """Per-partner aggregation report (Aged Receivable, Aged Payable, + Partner Ledger). + + Returns a dict with ``rows`` = list of partner-level aggregates. + Each row has the partner_id, partner_name, total residual, and + aging buckets: current / 1-30 / 31-60 / 61-90 / 90+ days past + ``period.date_to``. + + SQL-direct for performance: a single GROUP BY query with conditional + sum per bucket. Only un-reconciled, posted lines with non-zero + residual at the as-of date are included. + """ + company_id = company_id or self.env.company.id + + accounts = self.env['account.account'].sudo().search([ + ('account_type', '=', account_type), + ('company_ids', 'in', company_id), + ]) + if not accounts: + return { + 'report_type': 'partner_grouped', + 'account_type': account_type, + 'period': { + 'date_from': str(period.date_from), + 'date_to': str(period.date_to), + 'label': period.label, + }, + 'rows': [], + 'total': 0.0, + 'partner_count': 0, + } + + as_of = period.date_to + d30 = as_of - timedelta(days=30) + d60 = as_of - timedelta(days=60) + d90 = as_of - timedelta(days=90) + + self.env.cr.execute( + """ + SELECT + COALESCE(p.id, 0) AS partner_id, + COALESCE(p.name, '(no partner)') AS partner_name, + SUM(aml.amount_residual) AS total_residual, + SUM(CASE + WHEN aml.date_maturity >= %s + OR aml.date_maturity IS NULL + THEN aml.amount_residual ELSE 0 + END) AS bucket_current, + SUM(CASE + WHEN aml.date_maturity < %s + AND aml.date_maturity >= %s + THEN aml.amount_residual ELSE 0 + END) AS bucket_1_30, + SUM(CASE + WHEN aml.date_maturity < %s + AND aml.date_maturity >= %s + THEN aml.amount_residual ELSE 0 + END) AS bucket_31_60, + SUM(CASE + WHEN aml.date_maturity < %s + AND aml.date_maturity >= %s + THEN aml.amount_residual ELSE 0 + END) AS bucket_61_90, + SUM(CASE + WHEN aml.date_maturity < %s + THEN aml.amount_residual ELSE 0 + END) AS bucket_90_plus, + COUNT(*) AS line_count + FROM account_move_line aml + LEFT JOIN res_partner p ON p.id = aml.partner_id + WHERE aml.account_id = ANY(%s) + AND aml.parent_state = 'posted' + AND aml.reconciled = false + AND aml.amount_residual != 0 + AND aml.company_id = %s + AND aml.date <= %s + GROUP BY p.id, p.name + HAVING SUM(aml.amount_residual) != 0 + ORDER BY total_residual DESC + """, + ( + as_of, + as_of, d30, + d30, d60, + d60, d90, + d90, + list(accounts.ids), company_id, as_of, + ), + ) + + rows = [] + for r in self.env.cr.dictfetchall(): + rows.append({ + 'partner_id': r['partner_id'] or False, + 'partner_name': r['partner_name'] or '(no partner)', + 'total': float(r['total_residual'] or 0), + 'bucket_current': float(r['bucket_current'] or 0), + 'bucket_1_30': float(r['bucket_1_30'] or 0), + 'bucket_31_60': float(r['bucket_31_60'] or 0), + 'bucket_61_90': float(r['bucket_61_90'] or 0), + 'bucket_90_plus': float(r['bucket_90_plus'] or 0), + 'line_count': r['line_count'], + }) + + total = sum(r['total'] for r in rows) + return { + 'report_type': 'partner_grouped', + 'account_type': account_type, + 'period': { + 'date_from': str(period.date_from), + 'date_to': str(period.date_to), + 'label': period.label, + }, + 'company_id': company_id, + 'rows': rows, + 'total': total, + 'partner_count': len(rows), + } + # ============================================================ # PRIVATE HELPERS # ============================================================ diff --git a/fusion_accounting_reports/tests/test_fusion_report_engine.py b/fusion_accounting_reports/tests/test_fusion_report_engine.py index 43e4e21a..0fbdf048 100644 --- a/fusion_accounting_reports/tests/test_fusion_report_engine.py +++ b/fusion_accounting_reports/tests/test_fusion_report_engine.py @@ -90,6 +90,23 @@ class TestFusionReportEngine(TransactionCase): ) self.assertIsInstance(rows, list) + def test_compute_partner_grouped_receivable(self): + period = Period(date(2025, 1, 1), date(2025, 12, 31), 'Test') + result = self.env['fusion.report.engine'].compute_partner_grouped( + period, account_type='asset_receivable', + ) + self.assertEqual(result['report_type'], 'partner_grouped') + self.assertEqual(result['account_type'], 'asset_receivable') + self.assertIn('rows', result) + self.assertIn('total', result) + self.assertIn('partner_count', result) + if result['rows']: + for key in ( + 'partner_name', 'total', 'bucket_current', 'bucket_1_30', + 'bucket_31_60', 'bucket_61_90', 'bucket_90_plus', + ): + self.assertIn(key, result['rows'][0]) + def test_no_report_raises_validation_error(self): period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test 2026') # Inactivate any pre-existing GL definitions so the lookup