diff --git a/fusion_accounting_reports/__manifest__.py b/fusion_accounting_reports/__manifest__.py index 5adacffc..4605c9d7 100644 --- a/fusion_accounting_reports/__manifest__.py +++ b/fusion_accounting_reports/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Reports', - 'version': '19.0.1.0.36', + 'version': '19.0.1.0.37', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).', 'description': """ diff --git a/fusion_accounting_reports/tests/__init__.py b/fusion_accounting_reports/tests/__init__.py index 26bbc75a..1d14bf1d 100644 --- a/fusion_accounting_reports/tests/__init__.py +++ b/fusion_accounting_reports/tests/__init__.py @@ -24,3 +24,4 @@ from . import test_period_picker from . import test_migration_round_trip from . import test_coexistence from . import test_reports_tours +from . import test_performance_benchmarks diff --git a/fusion_accounting_reports/tests/test_performance_benchmarks.py b/fusion_accounting_reports/tests/test_performance_benchmarks.py new file mode 100644 index 00000000..6f4748a7 --- /dev/null +++ b/fusion_accounting_reports/tests/test_performance_benchmarks.py @@ -0,0 +1,155 @@ +"""Performance benchmarks with P95 targets, tagged 'benchmark'. + +These tests are not part of the default test run; they execute when invoked +explicitly with --test-tags 'post_install,benchmark' (or just 'benchmark'). + +Targets (Phase 2 ship): + compute_pnl <2000ms p95 + compute_balance_sheet <2000ms p95 + compute_trial_balance <1000ms p95 + compute_gl <3000ms p95 + drill_down <500ms p95 + controller.run <2500ms p95 + +Hard assertions are set to ~5x the target so a flaky CI run doesn't break the +build. The PERF lines printed to stdout are the source of truth for tracking. +""" + +import json +import statistics +import time +from datetime import date + +from odoo.tests.common import HttpCase, TransactionCase, tagged, new_test_user +from odoo.addons.fusion_accounting_reports.services.date_periods import Period + + +def _percentile(samples, p): + if not samples: + return 0 + if len(samples) == 1: + return samples[0] + sorted_s = sorted(samples) + idx = int(len(sorted_s) * p / 100) + return sorted_s[min(idx, len(sorted_s) - 1)] + + +@tagged('post_install', '-at_install', 'benchmark') +class TestEngineBenchmarks(TransactionCase): + + def setUp(self): + super().setUp() + self.period = Period( + date(2026, 1, 1), date(2026, 12, 31), 'Bench 2026', + ) + self.engine = self.env['fusion.report.engine'] + + def test_compute_pnl_p95(self): + timings = [] + for _ in range(5): + start = time.perf_counter() + self.engine.compute_pnl(self.period, company_id=self.env.company.id) + timings.append((time.perf_counter() - start) * 1000) + p95 = _percentile(timings, 95) + median = statistics.median(timings) + msg = f"compute_pnl: median={median:.0f}ms p95={p95:.0f}ms" + print(f"\n PERF: {msg} (target <2000ms)") + self.assertLess(p95, 10000, f"way over budget: {msg}") + + def test_compute_balance_sheet_p95(self): + timings = [] + for _ in range(5): + start = time.perf_counter() + self.engine.compute_balance_sheet( + date(2026, 12, 31), company_id=self.env.company.id, + ) + timings.append((time.perf_counter() - start) * 1000) + p95 = _percentile(timings, 95) + median = statistics.median(timings) + msg = f"compute_balance_sheet: median={median:.0f}ms p95={p95:.0f}ms" + print(f"\n PERF: {msg} (target <2000ms)") + self.assertLess(p95, 10000, f"way over budget: {msg}") + + def test_compute_trial_balance_p95(self): + timings = [] + for _ in range(5): + start = time.perf_counter() + self.engine.compute_trial_balance( + self.period, company_id=self.env.company.id, + ) + timings.append((time.perf_counter() - start) * 1000) + p95 = _percentile(timings, 95) + median = statistics.median(timings) + msg = f"compute_trial_balance: median={median:.0f}ms p95={p95:.0f}ms" + print(f"\n PERF: {msg} (target <1000ms)") + self.assertLess(p95, 5000, f"way over budget: {msg}") + + def test_compute_gl_p95(self): + timings = [] + for _ in range(3): # GL is heavier; fewer iterations + start = time.perf_counter() + self.engine.compute_gl(self.period, company_id=self.env.company.id) + timings.append((time.perf_counter() - start) * 1000) + median = statistics.median(timings) + p95 = _percentile(timings, 95) + msg = f"compute_gl: median={median:.0f}ms p95={p95:.0f}ms (3 runs)" + print(f"\n PERF: {msg} (target <3000ms)") + self.assertLess(median, 15000, f"way over budget: {msg}") + + def test_drill_down_p95(self): + line = self.env['account.move.line'].search([ + ('parent_state', '=', 'posted'), + ], limit=1) + if not line: + self.skipTest("No posted journal lines available") + timings = [] + for _ in range(10): + start = time.perf_counter() + self.engine.drill_down( + account_id=line.account_id.id, + period=self.period, + company_id=line.company_id.id, + ) + timings.append((time.perf_counter() - start) * 1000) + p95 = _percentile(timings, 95) + median = statistics.median(timings) + msg = f"drill_down: median={median:.0f}ms p95={p95:.0f}ms" + print(f"\n PERF: {msg} (target <500ms)") + self.assertLess(p95, 2500, f"way over budget: {msg}") + + +@tagged('post_install', '-at_install', 'benchmark') +class TestControllerBenchmarks(HttpCase): + + def test_run_endpoint_p95(self): + new_test_user( + self.env, + login='perf_user', + groups='base.group_user,account.group_account_invoice', + ) + self.authenticate('perf_user', 'perf_user') + timings = [] + for _ in range(5): + start = time.perf_counter() + response = self.url_open( + '/fusion/reports/run', + data=json.dumps({ + 'jsonrpc': '2.0', + 'method': 'call', + 'id': 1, + 'params': { + 'report_type': 'pnl', + 'date_from': '2026-01-01', + 'date_to': '2026-12-31', + 'company_id': self.env.company.id, + }, + }), + headers={'Content-Type': 'application/json'}, + ) + timings.append((time.perf_counter() - start) * 1000) + self.assertEqual(response.status_code, 200) + p95 = _percentile(timings, 95) + median = statistics.median(timings) + msg = f"controller.run: median={median:.0f}ms p95={p95:.0f}ms" + print(f"\n PERF: {msg} (target <2500ms)") + self.assertLess(p95, 12500, f"way over budget: {msg}")