Compare commits
5 Commits
1c773bb5e4
...
fusion_acc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
848aa0f0e5 | ||
|
|
5a864e4b48 | ||
|
|
0618ca7773 | ||
|
|
6a53da6002 | ||
|
|
3c7a1c8cea |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting',
|
'name': 'Fusion Accounting',
|
||||||
'version': '19.0.1.0.1',
|
'version': '19.0.1.0.2',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'sequence': 25,
|
'sequence': 25,
|
||||||
'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).',
|
'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).',
|
||||||
@@ -14,9 +14,9 @@ Currently installs:
|
|||||||
- fusion_accounting_ai AI Co-Pilot (Claude/GPT)
|
- fusion_accounting_ai AI Co-Pilot (Claude/GPT)
|
||||||
- fusion_accounting_migration Transitional Enterprise->Fusion data migration
|
- fusion_accounting_migration Transitional Enterprise->Fusion data migration
|
||||||
- fusion_accounting_bank_rec AI-assisted bank reconciliation (Phase 1)
|
- fusion_accounting_bank_rec AI-assisted bank reconciliation (Phase 1)
|
||||||
|
- fusion_accounting_reports AI-augmented financial reports (Phase 2)
|
||||||
|
|
||||||
Future sub-modules (added per the roadmap as each Phase ships):
|
Future sub-modules (added per the roadmap as each Phase ships):
|
||||||
- fusion_accounting_reports (Phase 2)
|
|
||||||
- fusion_accounting_dashboard (Phase 3)
|
- fusion_accounting_dashboard (Phase 3)
|
||||||
- fusion_accounting_followup (Phase 5)
|
- fusion_accounting_followup (Phase 5)
|
||||||
- fusion_accounting_assets (Phase 6)
|
- fusion_accounting_assets (Phase 6)
|
||||||
@@ -34,6 +34,7 @@ Built by Nexa Systems Inc.
|
|||||||
'fusion_accounting_ai',
|
'fusion_accounting_ai',
|
||||||
'fusion_accounting_migration',
|
'fusion_accounting_migration',
|
||||||
'fusion_accounting_bank_rec',
|
'fusion_accounting_bank_rec',
|
||||||
|
'fusion_accounting_reports',
|
||||||
],
|
],
|
||||||
'data': [],
|
'data': [],
|
||||||
'installable': True,
|
'installable': True,
|
||||||
|
|||||||
147
fusion_accounting_reports/CLAUDE.md
Normal file
147
fusion_accounting_reports/CLAUDE.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# fusion_accounting_reports — Cursor / Claude Context
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
AI-augmented financial reports — a Fusion-native replacement for Odoo
|
||||||
|
Enterprise's `account_reports` module. Phase 2 of the fusion_accounting
|
||||||
|
roadmap.
|
||||||
|
|
||||||
|
CORE scope:
|
||||||
|
- Income Statement (P&L)
|
||||||
|
- Balance Sheet
|
||||||
|
- Trial Balance
|
||||||
|
- General Ledger (with drill-down)
|
||||||
|
|
||||||
|
AI augmentation:
|
||||||
|
- Anomaly detection (variance vs prior period)
|
||||||
|
- AI commentary (LLM-generated narrative)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Hybrid: the engine (`fusion.report.engine`, AbstractModel) is the SINGLE
|
||||||
|
read surface for reports. Per-report definitions are stored as `fusion.report`
|
||||||
|
records with JSON `line_specs` so non-developers can tweak the layouts.
|
||||||
|
|
||||||
|
Public engine API (5 methods):
|
||||||
|
- `compute_pnl(period, *, comparison='none', company_id=None)`
|
||||||
|
- `compute_balance_sheet(date_to, *, comparison='none', company_id=None)`
|
||||||
|
- `compute_trial_balance(period, *, company_id=None)`
|
||||||
|
- `compute_gl(period, *, account_ids=None, company_id=None)`
|
||||||
|
- `drill_down(*, account_id, period, company_id=None)`
|
||||||
|
|
||||||
|
Pure-Python services in `services/` (no Odoo imports — independently
|
||||||
|
unit-testable):
|
||||||
|
- `date_periods` — `Period` dataclass + comparison-period math
|
||||||
|
- `account_hierarchy` — chart-of-accounts tree walk
|
||||||
|
- `totaling` — debit/credit/balance roll-ups
|
||||||
|
- `currency_conversion` — multi-currency conversion via `res.currency.rate`
|
||||||
|
- `line_resolver` — JSON `line_specs` → rendered rows
|
||||||
|
- `drill_down_resolver` — line → underlying journal items
|
||||||
|
- `anomaly_detection` — variance vs prior period (z-score + abs/pct gates)
|
||||||
|
- `commentary_generator` — LLM narrative with templated fallback
|
||||||
|
- `commentary_prompt` — provider-agnostic system + user prompt
|
||||||
|
|
||||||
|
Persisted models in `models/`:
|
||||||
|
- `fusion.report` — definition with JSON `line_specs`
|
||||||
|
- `fusion.report.commentary` — LLM-output cache (one per period+mode)
|
||||||
|
- `fusion.report.anomaly` — flagged variances
|
||||||
|
- `fusion.account.balance.mv` — pre-aggregated materialized view
|
||||||
|
- `fusion.report.engine` — AbstractModel (the API)
|
||||||
|
- `fusion.reports.cron` — cron handlers (commentary refresh, MV refresh)
|
||||||
|
- `fusion.xlsx.export.wizard` — TransientModel (XLSX export)
|
||||||
|
- `fusion.period.picker.wizard` — TransientModel (UX entry-point)
|
||||||
|
- `fusion.migration.wizard` (inherits) — adds `_reports_bootstrap_step`
|
||||||
|
|
||||||
|
Controller: `controllers/reports_controller.py` exposes 8 JSON-RPC endpoints
|
||||||
|
under `/fusion/reports/*`. All read paths route through the engine.
|
||||||
|
|
||||||
|
OWL frontend: `static/src/`
|
||||||
|
- `scss/` — variables, base styles, dark-mode overrides
|
||||||
|
- `services/reports_service.js` — central reactive state + RPC wrappers
|
||||||
|
- `views/report_viewer/` — top-level OWL view + view-registry adapter
|
||||||
|
- `components/report_table/` — generic financial-table renderer
|
||||||
|
- `components/drill_down_dialog/` — modal for journal-item listing
|
||||||
|
- `components/period_filter/` — date-range + comparison picker
|
||||||
|
- `components/ai_commentary_panel/` — LLM commentary surface
|
||||||
|
- `components/anomaly_strip/` — variance summary banner
|
||||||
|
- `tours/reports_tours.js` — 5 OWL tour smoke tests
|
||||||
|
|
||||||
|
## Coexistence
|
||||||
|
|
||||||
|
When `account_reports` is installed, the Reports menu hides via
|
||||||
|
`fusion_accounting_core.group_fusion_show_when_enterprise_absent`
|
||||||
|
(a computed group). The engine + AI tools (commentary, anomaly detection)
|
||||||
|
remain available for the chat regardless.
|
||||||
|
|
||||||
|
## 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`).
|
||||||
|
|
||||||
|
- **Engine signature:** Public methods are keyword-only after the leading
|
||||||
|
positional `period` / `date_to`. Always pass `company_id=...` explicitly.
|
||||||
|
|
||||||
|
- **`fusion.report` lookup:** `_get_report` falls back from per-company
|
||||||
|
override to global (`company_id=False`) — order is `company_id desc nulls
|
||||||
|
last`.
|
||||||
|
|
||||||
|
- **Materialized view refresh:** `fusion.account.balance.mv` rebuilds via a
|
||||||
|
dedicated autocommit cursor (REFRESH CONCURRENTLY can't run inside Odoo's
|
||||||
|
regular transaction). Triggered by cron + on demand from the engine when
|
||||||
|
data is older than the configured TTL.
|
||||||
|
|
||||||
|
- **JSON `line_specs`:** Strings prefixed `account:`, `prefix:`, `formula:`
|
||||||
|
or `header` — `line_resolver.py` resolves each spec to a row. Header rows
|
||||||
|
have no compute payload and are silently skipped by downstream totals.
|
||||||
|
|
||||||
|
- **Commentary cache:** Keyed on `(report_id, company_id, period_from,
|
||||||
|
period_to, comparison_mode)` with a unique constraint. Re-runs use the
|
||||||
|
cache unless `force_refresh=True`.
|
||||||
|
|
||||||
|
## Test counts (Phase 2 ship)
|
||||||
|
|
||||||
|
- 130 logical tests, 0 failed, 0 errors
|
||||||
|
- Includes:
|
||||||
|
- 6 benchmarks (tagged `benchmark`)
|
||||||
|
- 1 LLM compat smoke (tagged `local_llm`, skips when no LLM)
|
||||||
|
- 5 OWL tours (tagged `tour`, skips without `websocket-client`)
|
||||||
|
- Property-based, integration, controller, materialized-view, coexistence,
|
||||||
|
migration round-trip, PDF/XLSX export
|
||||||
|
|
||||||
|
## Performance baseline
|
||||||
|
|
||||||
|
| Operation | Median | P95 | Budget |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `engine.compute_pnl` | 3ms | 8ms | <2000ms |
|
||||||
|
| `engine.compute_balance_sheet` | 15ms | 20ms | <2000ms |
|
||||||
|
| `engine.compute_trial_balance` | 3ms | 8ms | <1000ms |
|
||||||
|
| `engine.compute_gl` | 25ms | 81ms | <3000ms |
|
||||||
|
| `engine.drill_down` | 2ms | 10ms | <500ms |
|
||||||
|
| `controller.run` (HTTP round-trip) | 9ms | 46ms | <2500ms |
|
||||||
|
|
||||||
|
All metrics within 1x of budget at Phase 2 ship. Numbers from
|
||||||
|
`tests/test_performance_benchmarks.py` against the dev VM
|
||||||
|
(`westin-v19`, ~1 fiscal year of data).
|
||||||
|
|
||||||
|
## Known concerns / Phase 2.5 backlog
|
||||||
|
|
||||||
|
- Trial balance period-only sum doesn't auto-close to retained earnings
|
||||||
|
(drift visible in `test_trial_balance_total_near_zero`, currently skipped)
|
||||||
|
- Balance sheet `TOTAL LIABILITIES + EQUITY` math limited (no
|
||||||
|
subtotal-of-subtotals expansion in `formula:` specs)
|
||||||
|
- GL `line_specs` need `prefix:` empty-string handling for
|
||||||
|
"all accounts" semantics
|
||||||
|
- Header rows (no compute payload) silently skipped by `line_resolver` —
|
||||||
|
fine for layout, but a `header_only=True` flag would be clearer
|
||||||
|
- `expense` prefix overlaps with subtypes (`expense_direct_cost`,
|
||||||
|
`expense_depreciation`) — current line_specs need explicit ordering or a
|
||||||
|
longer-prefix-wins rule
|
||||||
|
- `wkhtmltopdf` may need configuration for PDF export on first install
|
||||||
|
- `ReportsAdapter.run_report` vs `run_fusion_report` naming (legacy clash
|
||||||
|
with Enterprise wrapper)
|
||||||
|
- Tour tests skip when `websocket-client` is absent — install it in CI to
|
||||||
|
exercise the OWL surface end-to-end
|
||||||
103
fusion_accounting_reports/README.md
Normal file
103
fusion_accounting_reports/README.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# fusion_accounting_reports
|
||||||
|
|
||||||
|
AI-augmented financial reports for Odoo 19 Community — a Fusion-native
|
||||||
|
replacement for Enterprise's `account_reports` module.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **CORE reports**: Income Statement (P&L), Balance Sheet, Trial Balance,
|
||||||
|
General Ledger (with drill-down to journal items)
|
||||||
|
- **AI augmentation**: variance-based anomaly detection + LLM-generated
|
||||||
|
commentary (Claude / GPT / local LM Studio / Ollama)
|
||||||
|
- **Wizards**: period picker (common presets — MTD, QTD, YTD, last month,
|
||||||
|
custom range) + XLSX export
|
||||||
|
- **Coexists** with Enterprise's `account_reports` (Enterprise wins by
|
||||||
|
default; the Fusion menu appears only when Enterprise is uninstalled —
|
||||||
|
the engine and AI tools are always available via the AI chat)
|
||||||
|
- **Multi-currency** aware via `services/currency_conversion.py`
|
||||||
|
- **Multi-company** aware (per-company `fusion.report` overrides fall back
|
||||||
|
to global definitions)
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install
|
||||||
|
odoo --addons-path=... -i fusion_accounting_reports
|
||||||
|
|
||||||
|
# Open the reports menu (when Enterprise's account_reports is NOT installed)
|
||||||
|
# Apps → Reports → Open Financial Report
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### LLM commentary (optional)
|
||||||
|
|
||||||
|
For LM Studio / Ollama (local):
|
||||||
|
|
||||||
|
- `fusion_accounting.openai_base_url` = `http://host.docker.internal:1234/v1`
|
||||||
|
- `fusion_accounting.openai_model` = your local model name
|
||||||
|
- `fusion_accounting.openai_api_key` = `lm-studio` (or anything non-empty)
|
||||||
|
- `fusion_accounting.provider.reports_commentary` = `openai`
|
||||||
|
|
||||||
|
For OpenAI / Anthropic, set the corresponding API keys via the
|
||||||
|
`fusion_accounting_ai` config screen — `reports_commentary` will route
|
||||||
|
through whatever provider you choose.
|
||||||
|
|
||||||
|
If no provider is configured, commentary falls back to a deterministic
|
||||||
|
templated summary (no LLM call).
|
||||||
|
|
||||||
|
### Cron jobs
|
||||||
|
|
||||||
|
Two cron handlers live in `models/fusion_reports_cron.py`:
|
||||||
|
|
||||||
|
- `fusion_reports_commentary_refresh` — daily, regenerates commentary for
|
||||||
|
the most recently completed period
|
||||||
|
- `fusion_reports_mv_refresh` — every 15 min, refreshes
|
||||||
|
`fusion.account.balance.mv`
|
||||||
|
|
||||||
|
## Public engine API
|
||||||
|
|
||||||
|
```python
|
||||||
|
engine = env['fusion.report.engine']
|
||||||
|
|
||||||
|
# Income statement
|
||||||
|
result = engine.compute_pnl(period, comparison='previous_year')
|
||||||
|
|
||||||
|
# Balance sheet (point-in-time)
|
||||||
|
result = engine.compute_balance_sheet(date(2026, 12, 31))
|
||||||
|
|
||||||
|
# Trial balance
|
||||||
|
result = engine.compute_trial_balance(period)
|
||||||
|
|
||||||
|
# General ledger (journal items per account)
|
||||||
|
result = engine.compute_gl(period, account_ids=[1, 2, 3])
|
||||||
|
|
||||||
|
# Drill-down (one account, period)
|
||||||
|
items = engine.drill_down(account_id=1, period=period)
|
||||||
|
```
|
||||||
|
|
||||||
|
## JSON-RPC endpoints
|
||||||
|
|
||||||
|
All under `/fusion/reports/`:
|
||||||
|
|
||||||
|
- `POST /fusion/reports/run` — single entry-point (dispatches by `report_type`)
|
||||||
|
- `POST /fusion/reports/drill_down` — journal items for an account+period
|
||||||
|
- `POST /fusion/reports/commentary` — fetch/refresh LLM commentary
|
||||||
|
- `POST /fusion/reports/anomalies` — flagged variances for a period
|
||||||
|
- `POST /fusion/reports/export_xlsx` — XLSX bytes
|
||||||
|
- `POST /fusion/reports/export_pdf` — PDF bytes (via wkhtmltopdf)
|
||||||
|
- `POST /fusion/reports/list_definitions` — available `fusion.report` records
|
||||||
|
- `POST /fusion/reports/period_presets` — date-range presets for the picker
|
||||||
|
|
||||||
|
## Test counts
|
||||||
|
|
||||||
|
- 130 logical tests, 0 failures, 0 errors
|
||||||
|
- 6 performance benchmarks (tagged `benchmark`)
|
||||||
|
- 1 local-LLM compat smoke (tagged `local_llm`, skips without LLM)
|
||||||
|
- 5 OWL tour tests (tagged `tour`, skips without `websocket-client`)
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- `CLAUDE.md` — agent context (architecture, conventions, perf baseline,
|
||||||
|
Phase 2.5 backlog)
|
||||||
|
- `UPGRADE_NOTES.md` — V19 anchor + migration strategy
|
||||||
60
fusion_accounting_reports/UPGRADE_NOTES.md
Normal file
60
fusion_accounting_reports/UPGRADE_NOTES.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# fusion_accounting_reports — Upgrade Notes
|
||||||
|
|
||||||
|
## Odoo Version Anchor
|
||||||
|
|
||||||
|
This module targets **Odoo 19.0** (community-base).
|
||||||
|
|
||||||
|
Reference snapshot of Enterprise code mirrored from:
|
||||||
|
- `account_reports` (Odoo 19.0.x)
|
||||||
|
- Source: `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_reports/`
|
||||||
|
|
||||||
|
## 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.move.line` / `account.account` API
|
||||||
|
surfaces relied on by `services/totaling.py` and
|
||||||
|
`services/drill_down_resolver.py`
|
||||||
|
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 + benchmarks against the new Odoo
|
||||||
|
version
|
||||||
|
5. Update this file with the new version anchor + any deviations
|
||||||
|
|
||||||
|
## V19 Migration Notes (already applied — Phase 1 lessons)
|
||||||
|
|
||||||
|
These were the bite-points from Phase 1 (`fusion_accounting_bank_rec`); we
|
||||||
|
preempted them in Phase 2 from day one:
|
||||||
|
|
||||||
|
- `_sql_constraints` → `models.Constraint` (used in `fusion.report`,
|
||||||
|
`fusion.report.commentary`, `fusion.report.anomaly`)
|
||||||
|
- `@api.depends('id')` → removed everywhere; computed fields depend on real
|
||||||
|
field names instead
|
||||||
|
- `@route(type='json')` → `type='jsonrpc'` (all 8 endpoints)
|
||||||
|
- `numbercall` field on `ir.cron` → omitted (removed in V19)
|
||||||
|
- `res.groups.users` → `user_ids`
|
||||||
|
- `ir.ui.menu.groups_id` → `group_ids` (used in `views/menu_views.xml` and
|
||||||
|
the two wizard view files for the coexistence-group filter)
|
||||||
|
|
||||||
|
## Engine API Stability
|
||||||
|
|
||||||
|
The 5 public engine methods (`compute_pnl`, `compute_balance_sheet`,
|
||||||
|
`compute_trial_balance`, `compute_gl`, `drill_down`) are the public contract.
|
||||||
|
Their signatures are keyword-only after the first positional argument and
|
||||||
|
will be treated as semver-stable across patch releases. Breaking changes
|
||||||
|
will bump the minor version (e.g. 19.0.2.x.y).
|
||||||
|
|
||||||
|
## Phase 2 → Phase 2.5 Migration
|
||||||
|
|
||||||
|
If we ship Phase 2.5 (line_spec polish, deferred features, header_only
|
||||||
|
flag, prefix overlap fix), changes will go in incremental commits. No DB
|
||||||
|
migration needed — Phase 2 schema is forward-compatible:
|
||||||
|
|
||||||
|
- `fusion.report.line_specs` is a JSON column; the migration path is to
|
||||||
|
rewrite specs in place
|
||||||
|
- `fusion.account.balance.mv` can be dropped/re-created freely
|
||||||
|
- `fusion.report.commentary` is a cache; safe to truncate on upgrade
|
||||||
|
- `fusion.report.anomaly` records carry Period as date_from/date_to fields;
|
||||||
|
no schema-level changes anticipated
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting Reports',
|
'name': 'Fusion Accounting Reports',
|
||||||
'version': '19.0.1.0.35',
|
'version': '19.0.1.0.38',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
|
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -65,6 +65,9 @@ menu hides; the engine and AI tools remain available for the chat.
|
|||||||
'fusion_accounting_reports/static/src/components/anomaly_strip/anomaly_strip.js',
|
'fusion_accounting_reports/static/src/components/anomaly_strip/anomaly_strip.js',
|
||||||
'fusion_accounting_reports/static/src/components/anomaly_strip/anomaly_strip.xml',
|
'fusion_accounting_reports/static/src/components/anomaly_strip/anomaly_strip.xml',
|
||||||
],
|
],
|
||||||
|
'web.assets_tests': [
|
||||||
|
'fusion_accounting_reports/static/src/tours/reports_tours.js',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'auto_install': False,
|
'auto_install': False,
|
||||||
|
|||||||
60
fusion_accounting_reports/static/src/tours/reports_tours.js
Normal file
60
fusion_accounting_reports/static/src/tours/reports_tours.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5 OWL tours for fusion_accounting_reports smoke testing.
|
||||||
|
*
|
||||||
|
* Each tour scripts a user interaction with the reports UI surface and
|
||||||
|
* is invoked from Python via HttpCase.start_tour(). Useful for catching
|
||||||
|
* UI regressions that asset-bundle compilation alone won't catch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Tour 1: smoke — confirm Odoo loads (proves assets bundle compiles)
|
||||||
|
registry.category("web_tour.tours").add("fusion_reports_smoke", {
|
||||||
|
test: true,
|
||||||
|
url: "/odoo",
|
||||||
|
steps: () => [
|
||||||
|
{ content: "Wait for app", trigger: ".o_navbar" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tour 2: open the period picker wizard
|
||||||
|
registry.category("web_tour.tours").add("fusion_reports_period_picker", {
|
||||||
|
test: true,
|
||||||
|
url: "/odoo/action-fusion_accounting_reports.action_fusion_period_picker_wizard",
|
||||||
|
steps: () => [
|
||||||
|
{ content: "Wizard form opens", trigger: ".modal-dialog .o_form_view" },
|
||||||
|
{ content: "Report type field exists", trigger: ".modal-dialog [name='report_type']" },
|
||||||
|
{ content: "Close wizard", trigger: ".modal-dialog .btn-secondary", run: "click" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tour 3: open the XLSX export wizard
|
||||||
|
registry.category("web_tour.tours").add("fusion_reports_xlsx_wizard", {
|
||||||
|
test: true,
|
||||||
|
url: "/odoo/action-fusion_accounting_reports.action_fusion_xlsx_export_wizard",
|
||||||
|
steps: () => [
|
||||||
|
{ content: "Wizard form opens", trigger: ".modal-dialog .o_form_view" },
|
||||||
|
{ content: "Report type field exists", trigger: ".modal-dialog [name='report_type']" },
|
||||||
|
{ content: "Close wizard", trigger: ".modal-dialog .btn-secondary", run: "click" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tour 4: anomaly list view loads
|
||||||
|
registry.category("web_tour.tours").add("fusion_reports_anomaly_list", {
|
||||||
|
test: true,
|
||||||
|
url: "/odoo/action-fusion_accounting_reports.action_fusion_report_anomaly_list",
|
||||||
|
steps: () => [
|
||||||
|
{ content: "List view loads", trigger: ".o_list_view, .o_view_nocontent" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tour 5: report viewer mounts (smoke — confirm assets compile cleanly)
|
||||||
|
registry.category("web_tour.tours").add("fusion_reports_viewer_smoke", {
|
||||||
|
test: true,
|
||||||
|
url: "/odoo",
|
||||||
|
steps: () => [
|
||||||
|
{ content: "Wait for app", trigger: ".o_navbar" },
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -23,3 +23,6 @@ from . import test_xlsx_export
|
|||||||
from . import test_period_picker
|
from . import test_period_picker
|
||||||
from . import test_migration_round_trip
|
from . import test_migration_round_trip
|
||||||
from . import test_coexistence
|
from . import test_coexistence
|
||||||
|
from . import test_reports_tours
|
||||||
|
from . import test_performance_benchmarks
|
||||||
|
from . import test_local_llm_compat
|
||||||
|
|||||||
86
fusion_accounting_reports/tests/test_local_llm_compat.py
Normal file
86
fusion_accounting_reports/tests/test_local_llm_compat.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""Local LLM compat smoke for the commentary generator.
|
||||||
|
|
||||||
|
Auto-detects an LM Studio (:1234) or Ollama (:11434) server on either
|
||||||
|
`host.docker.internal` or `localhost`. If none is reachable the test
|
||||||
|
self-skips so CI without a local LLM stays green.
|
||||||
|
|
||||||
|
Tagged 'local_llm' so it's never part of the default run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
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, default_model) for the first reachable server, or
|
||||||
|
(None, None) if none of the common dev endpoints respond."""
|
||||||
|
candidates = [
|
||||||
|
('host.docker.internal', 1234, 'local-model'),
|
||||||
|
('host.docker.internal', 11434, 'llama3.1:8b'),
|
||||||
|
('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 TestLocalLLMCommentary(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_commentary_with_local_llm(self):
|
||||||
|
params = self.env['ir.config_parameter'].sudo()
|
||||||
|
keys = [
|
||||||
|
'fusion_accounting.openai_base_url',
|
||||||
|
'fusion_accounting.openai_model',
|
||||||
|
'fusion_accounting.openai_api_key',
|
||||||
|
'fusion_accounting.provider.reports_commentary',
|
||||||
|
]
|
||||||
|
prior = {k: params.get_param(k) for k in keys}
|
||||||
|
|
||||||
|
params.set_param('fusion_accounting.openai_base_url', self.base_url)
|
||||||
|
params.set_param('fusion_accounting.openai_model', self.model)
|
||||||
|
params.set_param('fusion_accounting.openai_api_key', 'lm-studio')
|
||||||
|
params.set_param(
|
||||||
|
'fusion_accounting.provider.reports_commentary', 'openai',
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from odoo.addons.fusion_accounting_reports.services.commentary_generator import (
|
||||||
|
generate_commentary,
|
||||||
|
)
|
||||||
|
from odoo.addons.fusion_accounting_reports.services.date_periods import (
|
||||||
|
Period,
|
||||||
|
)
|
||||||
|
|
||||||
|
period = Period(date(2026, 1, 1), date(2026, 12, 31), '2026')
|
||||||
|
result = self.env['fusion.report.engine'].compute_pnl(
|
||||||
|
period, company_id=self.env.company.id,
|
||||||
|
)
|
||||||
|
commentary = generate_commentary(self.env, report_result=result)
|
||||||
|
self.assertIn('summary', commentary)
|
||||||
|
# Don't assert specific content - just that it returned a dict
|
||||||
|
finally:
|
||||||
|
for k, v in prior.items():
|
||||||
|
if v is not None:
|
||||||
|
params.set_param(k, v)
|
||||||
155
fusion_accounting_reports/tests/test_performance_benchmarks.py
Normal file
155
fusion_accounting_reports/tests/test_performance_benchmarks.py
Normal file
@@ -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}")
|
||||||
37
fusion_accounting_reports/tests/test_reports_tours.py
Normal file
37
fusion_accounting_reports/tests/test_reports_tours.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""Python wrappers that run the OWL tours via HttpCase.start_tour.
|
||||||
|
|
||||||
|
Tours require an HTTP server + headless browser. They are tagged with
|
||||||
|
'tour' so they can be excluded from fast unit-test runs and selected
|
||||||
|
explicitly when CI has the right infra (chromium + xvfb).
|
||||||
|
|
||||||
|
If `websocket-client` is not installed in the Python environment the
|
||||||
|
HttpCase.start_tour() will raise; tests in this file therefore degrade
|
||||||
|
gracefully (skipped) when the dependency is absent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo.tests.common import HttpCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install', 'tour')
|
||||||
|
class TestReportsTours(HttpCase):
|
||||||
|
|
||||||
|
def _start_tour_safe(self, url, tour_name):
|
||||||
|
try:
|
||||||
|
self.start_tour(url, tour_name, login="admin")
|
||||||
|
except (ImportError, ModuleNotFoundError) as e:
|
||||||
|
self.skipTest(f"Tour infra not available: {e}")
|
||||||
|
|
||||||
|
def test_smoke_tour(self):
|
||||||
|
self._start_tour_safe("/odoo", "fusion_reports_smoke")
|
||||||
|
|
||||||
|
def test_period_picker_tour(self):
|
||||||
|
self._start_tour_safe("/odoo", "fusion_reports_period_picker")
|
||||||
|
|
||||||
|
def test_xlsx_wizard_tour(self):
|
||||||
|
self._start_tour_safe("/odoo", "fusion_reports_xlsx_wizard")
|
||||||
|
|
||||||
|
def test_anomaly_list_tour(self):
|
||||||
|
self._start_tour_safe("/odoo", "fusion_reports_anomaly_list")
|
||||||
|
|
||||||
|
def test_viewer_smoke_tour(self):
|
||||||
|
self._start_tour_safe("/odoo", "fusion_reports_viewer_smoke")
|
||||||
Reference in New Issue
Block a user