Compare commits
4 Commits
d623b67157
...
fusion_acc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e53955e9c | ||
|
|
8dab9b36da | ||
|
|
14e59148c6 | ||
|
|
55eb368195 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'Fusion Accounting',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.0.1',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 25,
|
||||
'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).',
|
||||
@@ -13,9 +13,9 @@ Currently installs:
|
||||
- fusion_accounting_core Shared schema, security, runtime helpers
|
||||
- fusion_accounting_ai AI Co-Pilot (Claude/GPT)
|
||||
- fusion_accounting_migration Transitional Enterprise->Fusion data migration
|
||||
- fusion_accounting_bank_rec AI-assisted bank reconciliation (Phase 1)
|
||||
|
||||
Future sub-modules (added per the roadmap as each Phase ships):
|
||||
- fusion_accounting_bank_rec (Phase 1)
|
||||
- fusion_accounting_reports (Phase 2)
|
||||
- fusion_accounting_dashboard (Phase 3)
|
||||
- fusion_accounting_followup (Phase 5)
|
||||
@@ -33,6 +33,7 @@ Built by Nexa Systems Inc.
|
||||
'fusion_accounting_core',
|
||||
'fusion_accounting_ai',
|
||||
'fusion_accounting_migration',
|
||||
'fusion_accounting_bank_rec',
|
||||
],
|
||||
'data': [],
|
||||
'installable': True,
|
||||
|
||||
103
fusion_accounting_bank_rec/CLAUDE.md
Normal file
103
fusion_accounting_bank_rec/CLAUDE.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# fusion_accounting_bank_rec — Cursor / Claude Context
|
||||
|
||||
## Purpose
|
||||
|
||||
Replaces (or augments — coexists with) Odoo Enterprise's `account_accountant`
|
||||
bank reconciliation widget with a Fusion-native, AI-assistive implementation.
|
||||
Ships in Phase 1 of the fusion_accounting roadmap.
|
||||
|
||||
## Architecture
|
||||
|
||||
Hybrid: the engine (`fusion.reconcile.engine`, AbstractModel) is the SINGLE
|
||||
write surface for reconciliations. Everything else (controller, OWL widget,
|
||||
AI tools, wizards, cron) routes through the engine's 6-method API:
|
||||
|
||||
- `reconcile_one(line, against_lines, write_off_vals=None)`
|
||||
- `reconcile_batch(lines, strategy='auto')`
|
||||
- `suggest_matches(lines, limit_per_line=3)`
|
||||
- `accept_suggestion(suggestion)`
|
||||
- `write_off(line, account, amount, label, tax_id=None)`
|
||||
- `unreconcile(partial_reconciles)`
|
||||
|
||||
Pure-Python services live in `services/`:
|
||||
- `memo_tokenizer` — Canadian bank memo regex
|
||||
- `exchange_diff` — FX gain/loss pre-compute
|
||||
- `matching_strategies` — AmountExact, FIFO, MultiInvoice
|
||||
- `precedent_lookup` — K-nearest search
|
||||
- `pattern_extractor` — per-partner aggregate
|
||||
- `confidence_scoring` — 4-pass pipeline (statistical → AI re-rank)
|
||||
- `precedent_backfill` — migration helper
|
||||
|
||||
Persistent models in `models/`:
|
||||
- `fusion.reconcile.pattern` — per-(company, partner) learned profile
|
||||
- `fusion.reconcile.precedent` — per-decision history
|
||||
- `fusion.reconcile.suggestion` — AI suggestions with state lifecycle
|
||||
- `fusion.bank.rec.widget` — TransientModel for OWL round-trip
|
||||
- `fusion.unreconciled.bank.line.mv` — pre-aggregated MV for fast UI listing
|
||||
- `fusion.bank.rec.cron` — cron handler (suggest, pattern refresh, MV refresh)
|
||||
- `fusion.auto.reconcile.wizard` / `fusion.bulk.reconcile.wizard` — TransientModel wizards
|
||||
- `fusion.migration.wizard` (inherits) — adds `_bank_rec_bootstrap_step`
|
||||
- `account.bank.statement.line` (inherits) — adds fusion_top_suggestion_id, fusion_confidence_band, etc.
|
||||
- `account.reconcile.model` (inherits) — adds fusion_ai_confidence_threshold
|
||||
|
||||
Controller: `controllers/bank_rec_controller.py` exposes 10 JSON-RPC endpoints
|
||||
under `/fusion/bank_rec/*`. All calls route through the engine.
|
||||
|
||||
OWL frontend: `static/src/`
|
||||
- `services/bank_reconciliation_service.js` — central reactive state + RPC wrappers
|
||||
- `views/kanban/bank_rec_kanban_*.js` — top-level controller + renderer
|
||||
- `components/bank_reconciliation/<...>` — 14 mirrored Enterprise components + 8 fusion-only components (ai_suggestion folder, batch_action_bar, reconcile_model_picker, attachment_strip, partner_history_panel)
|
||||
- `tours/bank_rec_tours.js` — 5 OWL tour smoke tests
|
||||
|
||||
## Conventions
|
||||
|
||||
- **V19 deprecations to avoid:** `_sql_constraints` (use `models.Constraint`),
|
||||
`@api.depends('id')` (raises `NotImplementedError`), `@route(type='json')`
|
||||
(use `type='jsonrpc'`), `numbercall` field on `ir.cron` (removed),
|
||||
`groups_id` on `res.users` (use `all_group_ids` for searching),
|
||||
`users` field on `res.groups` (use `user_ids`), `groups_id` on
|
||||
`ir.ui.menu` (use `group_ids`).
|
||||
|
||||
- **Coexistence:** When `account_accountant` is installed, the fusion menu
|
||||
is hidden via `fusion_accounting_core.group_fusion_show_when_enterprise_absent`
|
||||
(a computed group). Engine model is always available.
|
||||
|
||||
- **Materialized view refresh:** Triggered on `fusion.reconcile.suggestion`
|
||||
create/write (best-effort, non-blocking). Cron refreshes every 5 min via
|
||||
a dedicated autocommit cursor (REFRESH CONCURRENTLY can't run inside
|
||||
Odoo's regular transaction).
|
||||
|
||||
- **Test factories:** `tests/_factories.py` provides `make_bank_journal`,
|
||||
`make_bank_line`, `make_invoice`, `make_reconcileable_pair`, `make_suggestion`,
|
||||
`make_pattern`, `make_precedent`. NOTE: `make_bank_journal` defaults to
|
||||
code `'TEST'` so multiple calls in one test will collide; pass an explicit
|
||||
unique code or share a journal across calls.
|
||||
|
||||
- **Hypothesis property tests:** Use `@settings(suppress_health_check=[...])`
|
||||
to silence function_scoped_fixture warnings in TransactionCase.
|
||||
|
||||
## Test counts (as of Phase 1 complete)
|
||||
|
||||
- 157 logical tests total in fusion_accounting_bank_rec
|
||||
- 0 failures, 0 errors
|
||||
- Includes: 4 benchmark tests (tagged 'benchmark'), 1 local LLM smoke (tagged 'local_llm', skips when no LLM), 5 OWL tour tests (tagged 'tour')
|
||||
|
||||
## Performance baseline
|
||||
|
||||
| Operation | P95 | Budget |
|
||||
|---|---|---|
|
||||
| `engine.suggest_matches` (1 line) | 234ms | <500ms |
|
||||
| `engine.reconcile_batch` (50 lines) | 3318ms | <5000ms |
|
||||
| `controller.list_unreconciled` (50 lines) | 77ms | <200ms |
|
||||
| MV refresh | 60ms | <2000ms |
|
||||
|
||||
All within 1x of budget at Phase 1 ship.
|
||||
|
||||
## Known concerns / Phase 1.5 backlog
|
||||
|
||||
- `accept_suggestion` returns `partial_ids` but not `is_reconciled` — UI reads it post-call
|
||||
- `engine.write_off` mixed mode (write-off + against_lines) implemented but untested
|
||||
- `engine.reconcile_one` returns `exchange_diff_move_id: None` (Odoo's reconcile() handles FX inline; surfacing the move_id needs an extra query)
|
||||
- `against_lines` early-break in `reconcile_one` silently drops excess; auto strategy avoids this but manual callers should pre-validate
|
||||
- Reconcile-model bulk wizard `_apply_lines_for_bank_statement_line` is Enterprise-only (Community falls back to per-line error)
|
||||
- OWL tour tests skip-mode when websocket-client absent
|
||||
41
fusion_accounting_bank_rec/README.md
Normal file
41
fusion_accounting_bank_rec/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# fusion_accounting_bank_rec
|
||||
|
||||
AI-assisted bank reconciliation for Odoo 19 Community — a Fusion-native
|
||||
replacement for Enterprise's `account_accountant` bank reconciliation widget.
|
||||
|
||||
## What it does
|
||||
|
||||
- Side-by-side parity with Enterprise's bank reconciliation UI (kanban + side
|
||||
panel, multi-currency, write-offs, attachments, chatter)
|
||||
- AI-assistive: confidence-scored suggestions per bank line via the
|
||||
`fusion.reconcile.engine` 4-pass scoring pipeline (statistical + optional
|
||||
LLM re-rank)
|
||||
- Coexists with `account_accountant` (Enterprise wins by default; Fusion menu
|
||||
appears only when Enterprise is uninstalled)
|
||||
- Migration-aware: bootstrap step backfills `fusion.reconcile.precedent` from
|
||||
existing `account.partial.reconcile` rows so the AI has memory from day 1
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Install
|
||||
odoo --addons-path=... -i fusion_accounting_bank_rec
|
||||
|
||||
# Open the widget (when Enterprise's account_accountant is NOT installed)
|
||||
# Apps → Bank Reconciliation → Reconcile Bank Lines
|
||||
|
||||
# When Enterprise IS installed: use Enterprise's UI; the engine + AI tools
|
||||
# are still available via the AI chat.
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- Local LLM (LM Studio, Ollama):
|
||||
- `fusion_accounting.openai_base_url` = `http://host.docker.internal:1234/v1`
|
||||
- `fusion_accounting.openai_model` = your local model name
|
||||
- `fusion_accounting.provider.bank_rec_suggest` = `openai`
|
||||
|
||||
## See also
|
||||
|
||||
- `CLAUDE.md` — agent context
|
||||
- `UPGRADE_NOTES.md` — Odoo version anchoring
|
||||
34
fusion_accounting_bank_rec/UPGRADE_NOTES.md
Normal file
34
fusion_accounting_bank_rec/UPGRADE_NOTES.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# fusion_accounting_bank_rec — Upgrade Notes
|
||||
|
||||
## Odoo Version Anchor
|
||||
|
||||
This module targets **Odoo 19.0** (community-base).
|
||||
|
||||
Reference snapshot of Enterprise code mirrored from:
|
||||
- `account_accountant` (Odoo 19.0.x)
|
||||
- Source: `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_accountant/`
|
||||
|
||||
## Cross-Version Diff Strategy
|
||||
|
||||
When a new Odoo version ships:
|
||||
|
||||
1. Run `check_odoo_diff.sh` (in repo root) against the new Enterprise version
|
||||
2. Note any breaking changes in `account.bank.statement.line` API
|
||||
3. For mirrored OWL components, diff Enterprise's new versions against ours and
|
||||
port material changes (signature renames, new behaviour we want to inherit)
|
||||
4. Re-run the full test suite + tour tests against the new Odoo version
|
||||
5. Update this file with the new version anchor + any deviations
|
||||
|
||||
## V19 Migration Notes (already applied)
|
||||
|
||||
- `_sql_constraints` → `models.Constraint` (Tasks 14, 15)
|
||||
- `@api.depends('id')` → removed (Task 17)
|
||||
- `@route(type='json')` → `type='jsonrpc'` (Task 26)
|
||||
- `numbercall` removed from `ir.cron` (Task 25)
|
||||
- `res.groups.users` → `user_ids` (Task 43)
|
||||
- `ir.ui.menu.groups_id` → `group_ids` (Tasks 42, 43)
|
||||
|
||||
## Phase 1 → Phase 1.5 Migration
|
||||
|
||||
If we ship Phase 1.5 (UI polish, deferred features), changes will go in
|
||||
incremental commits. No DB migration needed (Phase 1 schema is forward-compatible).
|
||||
@@ -21,3 +21,5 @@ from . import test_bulk_reconcile_wizard
|
||||
from . import test_migration_round_trip
|
||||
from . import test_coexistence
|
||||
from . import test_bank_rec_tours
|
||||
from . import test_performance_benchmarks
|
||||
from . import test_local_llm_compat
|
||||
|
||||
102
fusion_accounting_bank_rec/tests/test_local_llm_compat.py
Normal file
102
fusion_accounting_bank_rec/tests/test_local_llm_compat.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Local LLM compatibility test (LM Studio, Ollama, etc.).
|
||||
|
||||
Skips if no local OpenAI-compatible LLM server is reachable. When one is
|
||||
running (LM Studio at :1234, Ollama at :11434), runs an end-to-end:
|
||||
|
||||
1. Configure ``ir.config_parameter`` to point at the local server.
|
||||
2. Trigger ``engine.suggest_matches`` with the 'openai' provider.
|
||||
3. Assert the call did not crash and produced at least one suggestion.
|
||||
|
||||
The smoke is intentionally lenient: local models often emit malformed
|
||||
JSON, in which case ``confidence_scoring`` falls back to statistical-only
|
||||
ranking. We assert end-to-end happiness, not AI re-rank quality.
|
||||
"""
|
||||
|
||||
import socket
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
from . import _factories as f
|
||||
|
||||
|
||||
def _server_reachable(host, port, timeout=1.0):
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except (OSError, socket.timeout):
|
||||
return False
|
||||
|
||||
|
||||
def _detect_local_llm():
|
||||
"""Return (base_url, model_name) tuple, or (None, None) if no server.
|
||||
|
||||
Tries LM Studio (:1234) and Ollama (:11434) on both
|
||||
``host.docker.internal`` (so the container can reach the host) and
|
||||
``localhost`` (so a non-containerised run finds the same servers).
|
||||
"""
|
||||
candidates = (
|
||||
('host.docker.internal', 1234, 'local-model'), # LM Studio
|
||||
('host.docker.internal', 11434, 'llama3.1:8b'), # Ollama
|
||||
('localhost', 1234, 'local-model'),
|
||||
('localhost', 11434, 'llama3.1:8b'),
|
||||
)
|
||||
for host, port, default_model in candidates:
|
||||
if _server_reachable(host, port, timeout=0.5):
|
||||
return (f'http://{host}:{port}/v1', default_model)
|
||||
return (None, None)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'local_llm')
|
||||
class TestLocalLLMCompat(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.base_url, self.model = _detect_local_llm()
|
||||
if not self.base_url:
|
||||
self.skipTest(
|
||||
"No local LLM server detected "
|
||||
"(LM Studio :1234 / Ollama :11434)")
|
||||
|
||||
def test_suggest_matches_with_local_llm(self):
|
||||
params = self.env['ir.config_parameter'].sudo()
|
||||
prior = {
|
||||
'fusion_accounting.openai_base_url': params.get_param(
|
||||
'fusion_accounting.openai_base_url'),
|
||||
'fusion_accounting.openai_model': params.get_param(
|
||||
'fusion_accounting.openai_model'),
|
||||
'fusion_accounting.openai_api_key': params.get_param(
|
||||
'fusion_accounting.openai_api_key'),
|
||||
'fusion_accounting.provider.bank_rec_suggest': params.get_param(
|
||||
'fusion_accounting.provider.bank_rec_suggest'),
|
||||
}
|
||||
|
||||
params.set_param('fusion_accounting.openai_base_url', self.base_url)
|
||||
params.set_param('fusion_accounting.openai_model', self.model)
|
||||
# Local servers ignore the key but the adapter requires *some* value.
|
||||
params.set_param('fusion_accounting.openai_api_key', 'lm-studio')
|
||||
params.set_param(
|
||||
'fusion_accounting.provider.bank_rec_suggest', 'openai')
|
||||
|
||||
try:
|
||||
partner = self.env['res.partner'].create(
|
||||
{'name': 'Local LLM Partner'})
|
||||
f.make_invoice(self.env, partner=partner, amount=750)
|
||||
bank_line = f.make_bank_line(
|
||||
self.env, amount=750, partner=partner,
|
||||
memo='REF 12345 Local LLM test')
|
||||
|
||||
result = self.env['fusion.reconcile.engine'].suggest_matches(
|
||||
bank_line, limit_per_line=3)
|
||||
|
||||
self.assertIn(bank_line.id, result)
|
||||
suggestions = self.env['fusion.reconcile.suggestion'].search([
|
||||
('statement_line_id', '=', bank_line.id),
|
||||
])
|
||||
self.assertGreater(
|
||||
len(suggestions), 0,
|
||||
"Local LLM run should still produce at least one suggestion "
|
||||
"(statistical fallback if AI re-rank fails)")
|
||||
finally:
|
||||
for key, value in prior.items():
|
||||
if value is not None:
|
||||
params.set_param(key, value)
|
||||
188
fusion_accounting_bank_rec/tests/test_performance_benchmarks.py
Normal file
188
fusion_accounting_bank_rec/tests/test_performance_benchmarks.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Performance benchmarks with P95 targets.
|
||||
|
||||
Tagged with ``benchmark`` so they can be selected explicitly:
|
||||
odoo --test-tags 'benchmark' ...
|
||||
|
||||
These tests measure wall-clock time and assert P95 stays within plan
|
||||
budgets. They run a small N (e.g. 10 iterations) so total test time
|
||||
stays under 30s. For real load testing, use a separate harness.
|
||||
|
||||
Hard-fail thresholds are 5x the plan budget — they catch egregious
|
||||
regressions without flaking on cold-start variance in CI.
|
||||
"""
|
||||
|
||||
import json
|
||||
import statistics
|
||||
import time
|
||||
|
||||
from odoo.tests.common import HttpCase, TransactionCase, new_test_user, tagged
|
||||
|
||||
from . import _factories as f
|
||||
|
||||
|
||||
def _percentile(samples, p):
|
||||
"""Return the ``p``-th percentile of ``samples`` (0-100)."""
|
||||
if not samples:
|
||||
return None
|
||||
if len(samples) == 1:
|
||||
return samples[0]
|
||||
return statistics.quantiles(samples, n=100)[p - 1]
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'benchmark')
|
||||
class TestEngineBenchmarks(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Bench Partner'})
|
||||
# Pre-create a dedicated journal+statement and reuse them across all
|
||||
# iterations -- otherwise the second make_bank_line() collides on the
|
||||
# (code, company) unique constraint of the default 'TEST' journal.
|
||||
self.journal = f.make_bank_journal(
|
||||
self.env, name='Engine Bench Bank', code='EBB')
|
||||
self.statement = f.make_bank_statement(
|
||||
self.env, journal=self.journal, name='Engine Bench Stmt')
|
||||
# Pre-create some invoices so suggest_matches has something to score
|
||||
self.invoices = []
|
||||
for amount in (100, 200, 300, 400, 500):
|
||||
inv = f.make_invoice(self.env, partner=self.partner, amount=amount)
|
||||
self.invoices.append(inv)
|
||||
|
||||
def test_suggest_matches_p95_under_500ms(self):
|
||||
timings = []
|
||||
for _ in range(10):
|
||||
line = f.make_bank_line(
|
||||
self.env, journal=self.journal, statement=self.statement,
|
||||
amount=300, partner=self.partner)
|
||||
start = time.perf_counter()
|
||||
self.env['fusion.reconcile.engine'].suggest_matches(
|
||||
line, limit_per_line=3)
|
||||
elapsed = (time.perf_counter() - start) * 1000 # ms
|
||||
timings.append(elapsed)
|
||||
timings.sort()
|
||||
p95 = _percentile(timings, 95)
|
||||
median = statistics.median(timings)
|
||||
msg = f"suggest_matches: median={median:.1f}ms p95={p95:.1f}ms"
|
||||
print(f"\n PERF: {msg} (target <500ms)")
|
||||
# Soft assertion -- log but don't fail under 5x budget (cold-start
|
||||
# variance). Hard fail above 5x catches egregious regressions.
|
||||
self.assertLess(
|
||||
p95, 2500,
|
||||
f"suggest_matches P95 way over budget: {msg} "
|
||||
f"(target <500ms, hard fail >2500ms)")
|
||||
|
||||
def test_reconcile_batch_p95_under_5s(self):
|
||||
# Create 50 matchable pairs on a shared journal/statement so we
|
||||
# don't blow the (code, company) constraint.
|
||||
journal = f.make_bank_journal(
|
||||
self.env, name='Batch Bench Bank', code='BBB')
|
||||
statement = f.make_bank_statement(
|
||||
self.env, journal=journal, name='Batch Bench Stmt')
|
||||
line_ids = []
|
||||
for i in range(50):
|
||||
invoice = f.make_invoice(
|
||||
self.env, partner=self.partner, amount=100 + i)
|
||||
del invoice # ensures the receivable JE exists for engine to find
|
||||
line = f.make_bank_line(
|
||||
self.env, journal=journal, statement=statement,
|
||||
amount=100 + i, partner=self.partner)
|
||||
line_ids.append(line.id)
|
||||
lines = self.env['account.bank.statement.line'].browse(line_ids)
|
||||
start = time.perf_counter()
|
||||
result = self.env['fusion.reconcile.engine'].reconcile_batch(
|
||||
lines, strategy='auto')
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
msg = (f"reconcile_batch(50 lines): {elapsed:.0f}ms, "
|
||||
f"reconciled={result.get('reconciled_count', 'n/a')}")
|
||||
print(f"\n PERF: {msg} (target <5000ms)")
|
||||
self.assertLess(
|
||||
elapsed, 25000,
|
||||
f"reconcile_batch way over budget: {msg} "
|
||||
f"(target <5000ms, hard fail >25000ms)")
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'benchmark')
|
||||
class TestControllerBenchmarks(HttpCase):
|
||||
|
||||
USER_LOGIN = 'bench_ctrl_user'
|
||||
USER_PASSWORD = 'bench_ctrl_user'
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Mirrors test_controller.py auth setup -- a fresh test user with
|
||||
# the same group bundle the controller expects. The dev DB's admin
|
||||
# password is non-default, so we cannot rely on 'admin'/'admin'.
|
||||
new_test_user(
|
||||
self.env,
|
||||
login=self.USER_LOGIN,
|
||||
password=self.USER_PASSWORD,
|
||||
groups=(
|
||||
'base.group_user,'
|
||||
'account.group_account_user,'
|
||||
'fusion_accounting_core.group_fusion_accounting_admin'
|
||||
),
|
||||
)
|
||||
|
||||
def test_list_unreconciled_p95_under_200ms(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Ctrl Bench'})
|
||||
journal = f.make_bank_journal(
|
||||
self.env, name='Ctrl Bench Bank', code='CBB')
|
||||
statement = f.make_bank_statement(
|
||||
self.env, journal=journal, name='Ctrl Bench Stmt')
|
||||
for i in range(50):
|
||||
f.make_bank_line(
|
||||
self.env, journal=journal, statement=statement,
|
||||
amount=100 + i, partner=partner,
|
||||
memo=f'Ctrl bench line {i}')
|
||||
self.authenticate(self.USER_LOGIN, self.USER_PASSWORD)
|
||||
body = json.dumps({
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'call',
|
||||
'params': {
|
||||
'journal_id': journal.id,
|
||||
'limit': 50,
|
||||
'offset': 0,
|
||||
'company_id': self.env.company.id,
|
||||
},
|
||||
'id': 1,
|
||||
})
|
||||
timings = []
|
||||
for _ in range(10):
|
||||
start = time.perf_counter()
|
||||
response = self.url_open(
|
||||
'/fusion/bank_rec/list_unreconciled',
|
||||
data=body,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
self.assertEqual(response.status_code, 200)
|
||||
timings.append(elapsed)
|
||||
timings.sort()
|
||||
p95 = _percentile(timings, 95)
|
||||
median = statistics.median(timings)
|
||||
msg = f"list_unreconciled: median={median:.1f}ms p95={p95:.1f}ms"
|
||||
print(f"\n PERF: {msg} (target <200ms)")
|
||||
self.assertLess(
|
||||
p95, 1000,
|
||||
f"list_unreconciled P95 way over budget: {msg} "
|
||||
f"(target <200ms, hard fail >1000ms)")
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'benchmark')
|
||||
class TestMVBenchmarks(TransactionCase):
|
||||
|
||||
def test_mv_refresh_under_2s(self):
|
||||
# Non-concurrent refresh works even before the MV has been seeded
|
||||
# with a concurrent-refresh-eligible state.
|
||||
start = time.perf_counter()
|
||||
self.env['fusion.unreconciled.bank.line.mv']._refresh(
|
||||
concurrently=False)
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
msg = (f"MV refresh: {elapsed:.0f}ms "
|
||||
f"(current row count varies with DB state)")
|
||||
print(f"\n PERF: {msg} (target <2000ms)")
|
||||
# Soft hard ceiling: 10s
|
||||
self.assertLess(
|
||||
elapsed, 10000,
|
||||
f"MV refresh way over budget: {msg} "
|
||||
f"(target <2000ms, hard fail >10000ms)")
|
||||
Reference in New Issue
Block a user