diff --git a/fusion_accounting_reports/controllers/reports_controller.py b/fusion_accounting_reports/controllers/reports_controller.py index 5db231c6..cbee355e 100644 --- a/fusion_accounting_reports/controllers/reports_controller.py +++ b/fusion_accounting_reports/controllers/reports_controller.py @@ -65,7 +65,7 @@ class FusionReportsController(http.Controller): @http.route('/fusion/reports/run', type='jsonrpc', auth='user') def run(self, report_type, date_from=None, date_to=None, - comparison='none', company_id=None): + comparison='none', company_id=None, report_code=None): if report_type not in REPORT_TYPES: raise ValidationError(_("Unknown report type: %s") % report_type) company_id = int(company_id) if company_id else request.env.company.id @@ -75,16 +75,20 @@ class FusionReportsController(http.Controller): period = _build_period(date_from, date_to) return engine.compute_pnl( period, comparison=comparison, company_id=company_id, + report_code=report_code, ) if report_type == 'balance_sheet': return engine.compute_balance_sheet( _parse_date(date_to), comparison=comparison, company_id=company_id, + report_code=report_code, ) if report_type == 'trial_balance': period = _build_period(date_from, date_to) - return engine.compute_trial_balance(period, company_id=company_id) + return engine.compute_trial_balance( + period, company_id=company_id, report_code=report_code, + ) if report_type in PARTNER_GROUPED_ACCOUNT_TYPE: period = _build_period(date_from, date_to) return engine.compute_partner_grouped( @@ -95,7 +99,9 @@ class FusionReportsController(http.Controller): ) # general_ledger period = _build_period(date_from, date_to) - return engine.compute_gl(period, company_id=company_id) + return engine.compute_gl( + period, company_id=company_id, report_code=report_code, + ) @http.route('/fusion/reports/drill_down', type='jsonrpc', auth='user') def drill_down(self, account_id, date_from, date_to, company_id=None): diff --git a/fusion_accounting_reports/models/fusion_report_engine.py b/fusion_accounting_reports/models/fusion_report_engine.py index d28d3397..ac2cf47e 100644 --- a/fusion_accounting_reports/models/fusion_report_engine.py +++ b/fusion_accounting_reports/models/fusion_report_engine.py @@ -39,10 +39,17 @@ class FusionReportEngine(models.AbstractModel): @api.model def compute_pnl( self, period: Period, *, comparison: str = 'none', - company_id: int | None = None, + company_id: int | None = None, report_code: str | None = None, ) -> dict: - """Income statement (P&L) for the given period.""" - report = self._get_report('pnl', company_id=company_id) + """Income statement (P&L) for the given period. + + ``report_code`` selects between multiple PnL-typed report definitions + (``pnl``, ``cash_flow``, ``executive_summary``, ``annual_statements``). + When omitted, falls back to the canonical ``pnl`` definition. + """ + report = self._get_report( + 'pnl', company_id=company_id, code=report_code, + ) return self._compute( report, period, comparison=comparison, company_id=company_id, ) @@ -50,11 +57,13 @@ class FusionReportEngine(models.AbstractModel): @api.model def compute_balance_sheet( self, date_to: date, *, comparison: str = 'none', - company_id: int | None = None, + company_id: int | None = None, report_code: str | None = None, ) -> dict: """Balance sheet AS OF date_to. Period.date_from is set to a far-past date so balances are cumulative-since-inception.""" - report = self._get_report('balance_sheet', company_id=company_id) + report = self._get_report( + 'balance_sheet', company_id=company_id, code=report_code, + ) period = Period( date_from=date(1970, 1, 1), date_to=date_to, @@ -67,10 +76,17 @@ class FusionReportEngine(models.AbstractModel): @api.model def compute_trial_balance( self, period: Period, *, company_id: int | None = None, + report_code: str | None = None, ) -> dict: """Trial balance for the given period - every account with - non-zero balance.""" - report = self._get_report('trial_balance', company_id=company_id) + non-zero balance. + + ``report_code`` selects between multiple TB-typed reports (e.g. + ``trial_balance``, ``tax_summary``). + """ + report = self._get_report( + 'trial_balance', company_id=company_id, code=report_code, + ) return self._compute( report, period, comparison='none', company_id=company_id, ) @@ -78,12 +94,14 @@ class FusionReportEngine(models.AbstractModel): @api.model def compute_gl( self, period: Period, *, account_ids: list | None = None, - company_id: int | None = None, + company_id: int | None = None, report_code: str | None = None, ) -> dict: """General ledger for the given period. Returns per-account move-line listings rather than aggregated rows.""" - report = self._get_report('general_ledger', company_id=company_id) + report = self._get_report( + 'general_ledger', company_id=company_id, code=report_code, + ) company_id = company_id or self.env.company.id result = self._compute( report, period, comparison='none', company_id=company_id, @@ -246,23 +264,60 @@ class FusionReportEngine(models.AbstractModel): # PRIVATE HELPERS # ============================================================ - def _get_report(self, report_type: str, *, company_id: int | None = None): - """Look up the active fusion.report definition for a given - type+company. If no per-company override, falls back to global - (company_id=False).""" + def _get_report( + self, report_type: str, *, company_id: int | None = None, + code: str | None = None, + ): + """Look up the active fusion.report definition. + + When ``code`` is provided, prefer the report with that exact code + (validating its ``report_type`` matches). Otherwise fall back to + the canonical-by-type lookup: prefer code == report_type, then any + report of that type. Per-company overrides win over global. + """ Report = self.env['fusion.report'].sudo() company_id = company_id or self.env.company.id + company_domain = [ + ('active', '=', True), + '|', + ('company_id', '=', company_id), + ('company_id', '=', False), + ] + if code: + report = Report.search( + [('code', '=', code)] + company_domain, + order='company_id desc nulls last', + limit=1, + ) + if not report: + raise ValidationError( + _("No active fusion.report definition with code '%s'") % code + ) + if report.report_type != report_type: + raise ValidationError( + _("Report '%(code)s' has type '%(actual)s' but '%(expected)s' was expected.") + % { + 'code': code, + 'actual': report.report_type, + 'expected': report_type, + } + ) + return report + + # No code: prefer the canonical (code == report_type), then any + # other report of that type. report = Report.search( - [ - ('report_type', '=', report_type), - ('active', '=', True), - '|', - ('company_id', '=', company_id), - ('company_id', '=', False), - ], + [('code', '=', report_type), ('report_type', '=', report_type)] + company_domain, order='company_id desc nulls last', limit=1, ) + if report: + return report + report = Report.search( + [('report_type', '=', report_type)] + company_domain, + order='company_id desc nulls last, sequence', + limit=1, + ) if not report: raise ValidationError( _("No active fusion.report definition for type '%s'") % report_type diff --git a/fusion_accounting_reports/tests/test_fusion_report_engine.py b/fusion_accounting_reports/tests/test_fusion_report_engine.py index 0fbdf048..2020c45b 100644 --- a/fusion_accounting_reports/tests/test_fusion_report_engine.py +++ b/fusion_accounting_reports/tests/test_fusion_report_engine.py @@ -107,6 +107,58 @@ class TestFusionReportEngine(TransactionCase): ): self.assertIn(key, result['rows'][0]) + def test_report_code_disambiguates_same_report_type(self): + """Multiple reports of report_type='pnl' must each be addressable + by code so the engine returns the requested definition's line_specs + (not whichever was first by company_id).""" + spec_one = [ + {'label': 'A', 'account_type_prefix': 'income_', 'sign': 1}, + ] + spec_two = [ + {'label': 'X', 'account_type_prefix': 'income_', 'sign': 1}, + {'label': 'Y', 'account_type_prefix': 'expense_', 'sign': -1}, + {'label': 'Z', 'account_type_prefix': 'asset_', 'sign': 1}, + ] + self.env['fusion.report'].create({ + 'name': 'Variant One', 'code': 'variant_one', + 'report_type': 'pnl', 'line_specs': spec_one, + 'company_id': self.env.company.id, + }) + self.env['fusion.report'].create({ + 'name': 'Variant Two', 'code': 'variant_two', + 'report_type': 'pnl', 'line_specs': spec_two, + 'company_id': self.env.company.id, + }) + period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test') + engine = self.env['fusion.report.engine'] + r1 = engine.compute_pnl( + period, company_id=self.env.company.id, + report_code='variant_one', + ) + r2 = engine.compute_pnl( + period, company_id=self.env.company.id, + report_code='variant_two', + ) + self.assertEqual(r1['report_name'], 'Variant One') + self.assertEqual(r2['report_name'], 'Variant Two') + self.assertEqual(len(r1['rows']), 1) + self.assertEqual(len(r2['rows']), 3) + + def test_report_code_validates_type_match(self): + """Asking for a 'pnl' computation but giving a balance_sheet code + should raise ValidationError, not silently mis-render.""" + self.env['fusion.report'].create({ + 'name': 'Wrong Type', 'code': 'wrong_type_test', + 'report_type': 'balance_sheet', 'line_specs': [], + 'company_id': self.env.company.id, + }) + period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test') + with self.assertRaises(ValidationError): + self.env['fusion.report.engine'].compute_pnl( + period, company_id=self.env.company.id, + report_code='wrong_type_test', + ) + 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