Merge Phase 2: AI-augmented financial reports
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled

46 tasks shipped on fusion_accounting/phase-2-reports:
- fusion.report.engine (5-method API: compute_pnl/balance_sheet/trial_balance/gl/drill_down)
- 4 CORE reports seeded (P&L, balance sheet, trial balance, general ledger)
- AI layer: anomaly detection + LLM commentary generator
- 8 JSON-RPC controller endpoints + reactive frontend service
- 8 OWL components + SCSS tokens (light + dark)
- Materialized view + 2 cron jobs (anomaly scan + MV refresh)
- 3 wizards (XLSX export, period picker, migration bootstrap)
- PDF export via QWeb
- 130 tests passing (engine, integration, property-based, controller, MV, wizards, coexistence, perf, LLM compat, OWL tours)
- All 6 P95 perf metrics within 1x of budget (37x-250x headroom)
This commit is contained in:
gsinghpal
2026-04-19 16:41:17 -04:00
94 changed files with 5925 additions and 4 deletions

View File

@@ -0,0 +1,167 @@
# Phase 2 — Fusion Accounting Reports Implementation Plan
**Module:** `fusion_accounting_reports`
**Branch:** `fusion_accounting/phase-2-reports`
**Pre-phase tag:** `fusion_accounting/pre-phase-2`
**Estimated tasks:** 46
**Reference:** `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_reports/`
## Goal
Replace Odoo Enterprise's `account_reports` module with a Fusion-native financial reports engine. CORE scope: P&L (income statement), balance sheet, trial balance, general ledger with drill-down. AI augmentation: anomaly detection (variance vs prior period) + AI-generated commentary. Coexists with Enterprise (Enterprise wins by default; Fusion menu shows when Enterprise absent).
## Architecture (HYBRID engine)
```
fusion.report.engine (AbstractModel) ← shared primitives
├── compute_pnl(period, comparison=None)
├── compute_balance_sheet(date_to, comparison=None)
├── compute_trial_balance(period)
├── compute_gl(period, account_ids=None)
├── drill_down(report_type, line_id, period)
└── _walk_account_hierarchy(root_account_ids)
services/ ← pure-Python
├── date_periods.py → fiscal-period math, comparison-period derivation
├── account_hierarchy.py → recursive account tree walk + roll-ups
├── totaling.py → balance/credit/debit aggregation rules
├── currency_conversion.py → multi-currency revaluation at report date
├── anomaly_detection.py → variance vs prior-period statistical flags
└── commentary_generator.py → LLM prompt + parse for narrative
models/
├── fusion_report.py → report definition (metadata, line specs)
├── fusion_report_engine.py → AbstractModel orchestrator
├── fusion_report_pnl.py → P&L definition + execute
├── fusion_report_balance_sheet.py
├── fusion_report_trial_balance.py
├── fusion_report_general_ledger.py
├── fusion_report_anomaly.py → persisted flagged variances
├── fusion_report_commentary.py → cached AI narratives
└── fusion_unreconciled_gl_mv.py → MV for fast GL listing on large DBs
controllers/bank_rec_controller.py ← 8 JSON-RPC endpoints
├── /fusion/reports/run → execute one report
├── /fusion/reports/drill_down → drill into a report line
├── /fusion/reports/get_anomalies → list flagged variances
├── /fusion/reports/get_commentary → fetch / regenerate narrative
├── /fusion/reports/compare_periods → side-by-side comparison
├── /fusion/reports/export_pdf → PDF export
├── /fusion/reports/export_xlsx → XLSX export
└── /fusion/reports/list_available → list all report types
static/src/
├── scss/ ← report-specific design tokens
├── services/reports_service.js ← reactive state + RPC wrappers
├── views/reports_viewer/ ← top-level OWL controller
└── components/ ← report_table, drill_down_dialog,
period_filter, ai_commentary_panel,
anomaly_strip
```
## Coexistence
Same pattern as Phase 1: `group_fusion_show_when_enterprise_absent` from `fusion_accounting_core`. Reports menu only visible when `account_reports` is NOT installed. Engine + AI tools always available.
## Tasks (46 total)
### Group 1: Foundation (tasks 1-2)
1. Safety net (tag pre-phase-2, branch phase-2-reports) — **DONE**
2. Plan doc + module skeleton
### Group 2: Engine primitives — TDD layered (tasks 3-8)
3. `services/date_periods.py` (fiscal periods, comparison derivation)
4. `services/currency_conversion.py` + `services/account_hierarchy.py` + `services/totaling.py`
5. `models/fusion_report.py` (report definition model)
6. `services/line_resolver.py` (compute report rows from definition)
7. `services/drill_down_resolver.py`
8. `models/fusion_report_engine.py` (5-method API: compute_pnl, compute_balance_sheet, compute_trial_balance, compute_gl, drill_down)
### Group 3: Per-report models (tasks 9-12)
9. P&L (income statement)
10. Balance sheet
11. Trial balance
12. General ledger
### Group 4: AI features (tasks 13-17)
13. Anomaly detection service (variance vs prior period)
14. AI commentary service
15. Commentary prompt + LLMProvider integration
16. `fusion.report.commentary` persisted model
17. `fusion.report.anomaly` persisted model
### Group 5: Backend wiring (tasks 18-20)
18. JSON-RPC controller (8 endpoints)
19. ReportsAdapter `_via_fusion` paths
20. 5 new AI tools
### Group 6: Tests + perf (tasks 21-25)
21. Property-based tests (totals balance invariant)
22. Integration tests — P&L correctness vs known fixtures
23. Integration tests — balance sheet + trial balance
24. Materialized view for GL
25. Cron jobs (anomaly scan + commentary refresh)
### Group 7: Frontend (tasks 26-33)
26. SCSS tokens + main report stylesheet
27. `reports_service.js`
28. `report_viewer` component (top-level)
29. `report_table` component (rows, totals, drill chevrons)
30. `drill_down_dialog`
31. `period_filter` (date range + comparison toggle)
32. `ai_commentary_panel` (Fusion-only)
33. `anomaly_strip` (Fusion-only)
### Group 8: Export + wizards (tasks 34-36)
34. PDF export (QWeb template per report)
35. XLSX export wizard
36. Period selection + comparison wizard
### Group 9: Migration + coexistence (tasks 37-39)
37. Migration wizard inheritance (cache existing definitions)
38. Menu + window actions with coexistence group filter
39. Coexistence test
### Group 10: Final tests + polish (tasks 40-46)
40. 5 OWL tour tests
41. Performance benchmarks
42. Optimize if benchmarks fail (conditional)
43. Local LLM compat test for commentary
44. Update meta-module manifest
45. CLAUDE.md, UPGRADE_NOTES.md, README.md
46. End-to-end smoke + tag phase-2-complete + push
## Performance Targets (P95)
- `engine.compute_pnl` (1 year, 500 accounts): <2s
- `engine.compute_balance_sheet`: <2s
- `engine.compute_trial_balance`: <1s
- `engine.compute_gl` (1 month, all accounts): <3s
- `engine.drill_down` (1 line): <500ms
- Controller `run` endpoint: <2.5s
## V19 Conventions (from Phase 1 lessons)
- `models.Constraint` not `_sql_constraints`
- No `@api.depends('id')` on stored compute fields
- `@route(type='jsonrpc')` not `type='json'`
- `ir.cron` has no `numbercall` field
- `res.groups.user_ids` not `users`
- `ir.ui.menu.group_ids` not `groups_id`
- `res.users.all_group_ids` for searches
- `models.Constraint` for unique-keys
- Prefer `env.flush_all()` before MV REFRESH
## Test Targets
Match Phase 1's test pyramid:
- Unit (services pure-Python)
- Integration (engine end-to-end with factories)
- Property-based (Hypothesis, totals balance invariant)
- Controller (HttpCase JSON-RPC)
- MV correctness
- Performance benchmarks (tagged 'benchmark')
- OWL tours (tagged 'tour')
- Local LLM smoke (tagged 'local_llm', skips when no LLM)
Phase 1 final: 157 tests passing. Phase 2 target: ~120-150 additional.

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting',
'version': '19.0.1.0.1',
'version': '19.0.1.0.2',
'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).',
@@ -14,9 +14,9 @@ Currently installs:
- 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)
- fusion_accounting_reports AI-augmented financial reports (Phase 2)
Future sub-modules (added per the roadmap as each Phase ships):
- fusion_accounting_reports (Phase 2)
- fusion_accounting_dashboard (Phase 3)
- fusion_accounting_followup (Phase 5)
- fusion_accounting_assets (Phase 6)
@@ -34,6 +34,7 @@ Built by Nexa Systems Inc.
'fusion_accounting_ai',
'fusion_accounting_migration',
'fusion_accounting_bank_rec',
'fusion_accounting_reports',
],
'data': [],
'installable': True,

View File

@@ -16,7 +16,12 @@ _logger = logging.getLogger(__name__)
class ReportsAdapter(DataAdapter):
FUSION_MODEL = 'fusion.account.report'
# Phase 2 wires fusion.report.engine as the FUSION-mode backend for
# the new report_type-shaped methods (run_fusion_report, get_anomalies,
# get_commentary). The legacy ref_id-shaped run_report / export_report
# methods continue to defer to community when in FUSION mode (their
# original behavior), so this rename does not change their results.
FUSION_MODEL = 'fusion.report.engine'
ENTERPRISE_MODULE = 'account_reports'
# ------------------------------------------------------------------
@@ -167,4 +172,159 @@ class ReportsAdapter(DataAdapter):
}
# ==================================================================
# Phase 2 (Task 19): fusion.report.engine-routed report methods
#
# These coexist with the legacy ref_id-shaped run_report/export_report
# API. New callers (financial_reports AI tools, OWL widget) use the
# *_fusion_report methods below; those route through the engine when
# fusion_accounting_reports is installed.
# ==================================================================
# ------------------ run_fusion_report --------------------------
def run_fusion_report(self, report_type, date_from, date_to,
comparison='none', company_id=None):
return self._dispatch(
'run_fusion_report',
report_type=report_type,
date_from=date_from, date_to=date_to,
comparison=comparison, company_id=company_id,
)
def run_fusion_report_via_fusion(self, report_type, date_from, date_to,
comparison='none', company_id=None):
if 'fusion.report.engine' not in self.env.registry:
return {'rows': [], 'error': 'fusion.report.engine not installed'}
from datetime import datetime
from odoo.addons.fusion_accounting_reports.services.date_periods import (
Period,
)
df = (datetime.strptime(date_from, '%Y-%m-%d').date()
if isinstance(date_from, str) else date_from)
dt = (datetime.strptime(date_to, '%Y-%m-%d').date()
if isinstance(date_to, str) else date_to)
period = Period(date_from=df, date_to=dt, label=f"{df} - {dt}")
engine = self.env['fusion.report.engine']
company_id = company_id or self.env.company.id
if report_type == 'pnl':
return engine.compute_pnl(
period, comparison=comparison, company_id=company_id,
)
if report_type == 'balance_sheet':
return engine.compute_balance_sheet(
dt, comparison=comparison, company_id=company_id,
)
if report_type == 'trial_balance':
return engine.compute_trial_balance(
period, company_id=company_id,
)
if report_type == 'general_ledger':
return engine.compute_gl(period, company_id=company_id)
return {'rows': [], 'error': f'unknown report_type {report_type}'}
def run_fusion_report_via_enterprise(self, report_type, date_from, date_to,
comparison='none', company_id=None):
# Enterprise's account_reports has its own UI; we don't proxy from
# Python. Callers should use the Enterprise menus or the legacy
# run_report(ref_id=...) method instead.
return {
'rows': [],
'error': 'Enterprise reports must be run from the Enterprise UI',
}
def run_fusion_report_via_community(self, report_type, date_from, date_to,
comparison='none', company_id=None):
return {
'rows': [],
'error': 'No fusion reports engine available in pure Community',
}
# ------------------ get_anomalies ------------------------------
def get_anomalies(self, report_type, date_from, date_to,
comparison='previous_year', company_id=None):
return self._dispatch(
'get_anomalies',
report_type=report_type,
date_from=date_from, date_to=date_to,
comparison=comparison, company_id=company_id,
)
def get_anomalies_via_fusion(self, report_type, date_from, date_to,
comparison='previous_year', company_id=None):
if 'fusion.report.engine' not in self.env.registry:
return {'anomalies': []}
from odoo.addons.fusion_accounting_reports.services.anomaly_detection import (
detect,
)
report = self.run_fusion_report_via_fusion(
report_type=report_type,
date_from=date_from, date_to=date_to,
comparison=comparison, company_id=company_id,
)
if 'error' in report:
return {'anomalies': []}
return {'anomalies': detect(report)}
def get_anomalies_via_enterprise(self, report_type, date_from, date_to,
comparison='previous_year', company_id=None):
return {'anomalies': []}
def get_anomalies_via_community(self, report_type, date_from, date_to,
comparison='previous_year', company_id=None):
return {'anomalies': []}
# ------------------ get_commentary -----------------------------
def get_commentary(self, report_type, date_from, date_to,
comparison='none', company_id=None):
return self._dispatch(
'get_commentary',
report_type=report_type,
date_from=date_from, date_to=date_to,
comparison=comparison, company_id=company_id,
)
def get_commentary_via_fusion(self, report_type, date_from, date_to,
comparison='none', company_id=None):
empty = {
'summary': '', 'highlights': [],
'concerns': [], 'next_actions': [],
}
if 'fusion.report.engine' not in self.env.registry:
return empty
from odoo.addons.fusion_accounting_reports.services.anomaly_detection import (
detect,
)
from odoo.addons.fusion_accounting_reports.services.commentary_generator import (
generate_commentary,
)
report = self.run_fusion_report_via_fusion(
report_type=report_type,
date_from=date_from, date_to=date_to,
comparison=comparison, company_id=company_id,
)
if 'error' in report:
return empty
anomalies = detect(report)
return generate_commentary(
self.env, report_result=report, anomalies=anomalies,
)
def get_commentary_via_enterprise(self, report_type, date_from, date_to,
comparison='none', company_id=None):
return {
'summary': '', 'highlights': [],
'concerns': [], 'next_actions': [],
}
def get_commentary_via_community(self, report_type, date_from, date_to,
comparison='none', company_id=None):
return {
'summary': '', 'highlights': [],
'concerns': [], 'next_actions': [],
}
register_adapter('reports', ReportsAdapter)

View File

@@ -9,11 +9,12 @@ from .inventory import TOOLS as INVENTORY_TOOLS
from .adp import TOOLS as ADP_TOOLS
from .reporting import TOOLS as REPORTING_TOOLS
from .audit import TOOLS as AUDIT_TOOLS
from .financial_reports import TOOLS as FINANCIAL_REPORTS_TOOLS
TOOL_DISPATCH = {}
for tools_dict in [
BANK_RECON_TOOLS, HST_TOOLS, AR_TOOLS, AP_TOOLS, JOURNAL_TOOLS,
MONTH_END_TOOLS, PAYROLL_TOOLS, INVENTORY_TOOLS, ADP_TOOLS,
REPORTING_TOOLS, AUDIT_TOOLS,
REPORTING_TOOLS, AUDIT_TOOLS, FINANCIAL_REPORTS_TOOLS,
]:
TOOL_DISPATCH.update(tools_dict)

View File

@@ -0,0 +1,127 @@
"""Fusion-engine-routed AI tools for financial reports.
These 5 tools route through ReportsAdapter's Phase-2 methods
(run_fusion_report / get_anomalies / get_commentary), which in turn
call fusion.report.engine when fusion_accounting_reports is installed.
"""
import logging
_logger = logging.getLogger(__name__)
def _company_id(env, params):
raw = params.get('company_id')
return int(raw) if raw else env.company.id
def fusion_run_report(env, params):
"""Run a fusion financial report.
Params: report_type (pnl|balance_sheet|trial_balance|general_ledger),
date_from, date_to, comparison (none|previous_period|previous_year),
optional company_id.
"""
if 'fusion.report.engine' not in env.registry:
return {'error': 'fusion_accounting_reports not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'reports')
result = adapter.run_fusion_report(
report_type=params.get('report_type'),
date_from=params.get('date_from'),
date_to=params.get('date_to'),
comparison=params.get('comparison', 'none'),
company_id=_company_id(env, params),
)
rows = result.get('rows', [])
return {
'report_type': params.get('report_type'),
'period': result.get('period'),
'comparison_period': result.get('comparison_period'),
'row_count': len(rows),
'rows': rows,
}
def fusion_get_anomalies(env, params):
"""Detect variance anomalies in a report."""
if 'fusion.report.engine' not in env.registry:
return {'error': 'fusion_accounting_reports not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'reports')
result = adapter.get_anomalies(
report_type=params.get('report_type'),
date_from=params.get('date_from'),
date_to=params.get('date_to'),
comparison=params.get('comparison', 'previous_year'),
company_id=_company_id(env, params),
)
anomalies = result.get('anomalies', [])
return {'count': len(anomalies), 'anomalies': anomalies}
def fusion_generate_commentary(env, params):
"""Generate AI commentary for a report."""
if 'fusion.report.engine' not in env.registry:
return {'error': 'fusion_accounting_reports not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'reports')
result = adapter.get_commentary(
report_type=params.get('report_type'),
date_from=params.get('date_from'),
date_to=params.get('date_to'),
comparison=params.get('comparison', 'none'),
company_id=_company_id(env, params),
)
return {
'summary': result.get('summary', ''),
'highlights': result.get('highlights', []),
'concerns': result.get('concerns', []),
'next_actions': result.get('next_actions', []),
}
def fusion_drill_down_report_line(env, params):
"""Drill from a report line into the underlying journal items."""
if 'fusion.report.engine' not in env.registry:
return {'error': 'fusion_accounting_reports not installed'}
from datetime import datetime
from odoo.addons.fusion_accounting_reports.services.date_periods import (
Period,
)
date_from = params['date_from']
date_to = params['date_to']
if isinstance(date_from, str):
date_from = datetime.strptime(date_from, '%Y-%m-%d').date()
if isinstance(date_to, str):
date_to = datetime.strptime(date_to, '%Y-%m-%d').date()
period = Period(date_from=date_from, date_to=date_to, label='drill')
engine = env['fusion.report.engine']
rows = engine.drill_down(
account_id=int(params['account_id']),
period=period,
company_id=_company_id(env, params),
)
return {'count': len(rows), 'rows': rows}
def fusion_compare_periods(env, params):
"""Run a report with period comparison side-by-side.
Defaults comparison to 'previous_year' so callers get a comparison
column without specifying it explicitly.
"""
return fusion_run_report(env, {
**params,
'comparison': params.get('comparison', 'previous_year'),
})
TOOLS = {
'fusion_run_report': fusion_run_report,
'fusion_get_anomalies': fusion_get_anomalies,
'fusion_generate_commentary': fusion_generate_commentary,
'fusion_drill_down_report_line': fusion_drill_down_report_line,
'fusion_compare_periods': fusion_compare_periods,
}

View 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

View 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

View 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

View File

@@ -0,0 +1,5 @@
from . import services
from . import models
from . import controllers
from . import reports
from . import wizards

View File

@@ -0,0 +1,76 @@
{
'name': 'Fusion Accounting Reports',
'version': '19.0.1.0.38',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
'description': """
Fusion Accounting Reports
=========================
A Fusion-native replacement for Odoo Enterprise's account_reports module.
CORE scope (Phase 2):
- 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)
Coexists with Enterprise: when account_reports is installed, the Fusion
menu hides; the engine and AI tools remain available for the chat.
""",
'author': 'Fusion Accounting',
'license': 'LGPL-3',
'depends': [
'fusion_accounting_core',
'fusion_accounting_ai',
'fusion_accounting_migration',
'account',
],
'data': [
'security/ir.model.access.csv',
'data/report_pnl.xml',
'data/report_balance_sheet.xml',
'data/report_trial_balance.xml',
'data/report_general_ledger.xml',
'data/cron.xml',
'reports/report_pdf_template.xml',
'wizards/xlsx_export_wizard_views.xml',
'wizards/period_picker_wizard_views.xml',
'views/menu_views.xml',
],
'external_dependencies': {
'python': ['xlsxwriter'],
},
'assets': {
'web.assets_backend': [
'fusion_accounting_reports/static/src/scss/_variables.scss',
'fusion_accounting_reports/static/src/scss/reports.scss',
'fusion_accounting_reports/static/src/scss/dark_mode.scss',
'fusion_accounting_reports/static/src/services/reports_service.js',
'fusion_accounting_reports/static/src/views/report_viewer/report_viewer.js',
'fusion_accounting_reports/static/src/views/report_viewer/report_viewer.xml',
'fusion_accounting_reports/static/src/views/report_viewer/report_viewer_view.js',
'fusion_accounting_reports/static/src/components/report_table/report_table.js',
'fusion_accounting_reports/static/src/components/report_table/report_table.xml',
'fusion_accounting_reports/static/src/components/drill_down_dialog/drill_down_dialog.js',
'fusion_accounting_reports/static/src/components/drill_down_dialog/drill_down_dialog.xml',
'fusion_accounting_reports/static/src/components/period_filter/period_filter.js',
'fusion_accounting_reports/static/src/components/period_filter/period_filter.xml',
'fusion_accounting_reports/static/src/components/ai_commentary_panel/ai_commentary_panel.js',
'fusion_accounting_reports/static/src/components/ai_commentary_panel/ai_commentary_panel.xml',
'fusion_accounting_reports/static/src/components/anomaly_strip/anomaly_strip.js',
'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,
'auto_install': False,
'application': False,
'icon': '/fusion_accounting_reports/static/description/icon.png',
}

View File

@@ -0,0 +1 @@
from . import reports_controller

View File

@@ -0,0 +1,248 @@
"""HTTP controller: 8 JSON-RPC endpoints for the OWL reports widget.
All endpoints route through fusion.report.engine - no direct ORM
aggregation from the controller. Uses V19's type='jsonrpc'.
"""
import logging
from datetime import date, datetime
from odoo import _, http
from odoo.exceptions import ValidationError
from odoo.http import request
from ..services.anomaly_detection import detect as detect_anomalies
from ..services.commentary_generator import generate_commentary
from ..services.date_periods import Period
_logger = logging.getLogger(__name__)
REPORT_TYPES = {'pnl', 'balance_sheet', 'trial_balance', 'general_ledger'}
def _parse_date(value):
if isinstance(value, date):
return value
return datetime.strptime(value, '%Y-%m-%d').date()
def _build_period(date_from, date_to, label=None):
df = _parse_date(date_from)
dt = _parse_date(date_to)
return Period(date_from=df, date_to=dt, label=label or f"{df} - {dt}")
class FusionReportsController(http.Controller):
@http.route('/fusion/reports/list_available', type='jsonrpc', auth='user')
def list_available(self, company_id=None):
company_id = int(company_id) if company_id else request.env.company.id
Report = request.env['fusion.report'].sudo()
reports = Report.search([
('active', '=', True),
'|', ('company_id', '=', company_id), ('company_id', '=', False),
], order='sequence, name')
return {
'reports': [{
'id': r.id,
'name': r.name,
'code': r.code,
'report_type': r.report_type,
'description': r.description or '',
'default_comparison_mode': r.default_comparison_mode,
} for r in reports],
}
@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):
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
engine = request.env['fusion.report.engine']
if report_type == 'pnl':
period = _build_period(date_from, date_to)
return engine.compute_pnl(
period, comparison=comparison, company_id=company_id,
)
if report_type == 'balance_sheet':
return engine.compute_balance_sheet(
_parse_date(date_to),
comparison=comparison,
company_id=company_id,
)
if report_type == 'trial_balance':
period = _build_period(date_from, date_to)
return engine.compute_trial_balance(period, company_id=company_id)
# general_ledger
period = _build_period(date_from, date_to)
return engine.compute_gl(period, company_id=company_id)
@http.route('/fusion/reports/drill_down', type='jsonrpc', auth='user')
def drill_down(self, account_id, date_from, date_to, company_id=None):
company_id = int(company_id) if company_id else request.env.company.id
engine = request.env['fusion.report.engine']
period = _build_period(date_from, date_to)
rows = engine.drill_down(
account_id=int(account_id),
period=period,
company_id=company_id,
)
return {'rows': rows, 'count': len(rows)}
@http.route('/fusion/reports/get_anomalies', type='jsonrpc', auth='user')
def get_anomalies(self, report_type, date_from, date_to,
comparison='previous_year', persist=False, company_id=None):
company_id = int(company_id) if company_id else request.env.company.id
report_result = self.run(
report_type=report_type,
date_from=date_from, date_to=date_to,
comparison=comparison, company_id=company_id,
)
anomalies = detect_anomalies(report_result)
if persist and anomalies:
Report = request.env['fusion.report']
report_def = Report.search([('report_type', '=', report_type)], limit=1)
if report_def:
self._persist_anomalies(
report_def,
_parse_date(date_from), _parse_date(date_to),
anomalies,
)
return {'anomalies': anomalies, 'count': len(anomalies)}
def _persist_anomalies(self, report, period_from, period_to, anomalies):
Anomaly = request.env['fusion.report.anomaly']
for a in anomalies:
existing = Anomaly.search([
('report_id', '=', report.id),
('period_from', '=', period_from),
('period_to', '=', period_to),
('row_id', '=', a['row_id']),
], limit=1)
vals = {
'report_id': report.id,
'period_from': period_from,
'period_to': period_to,
'row_id': a['row_id'],
'label': a['label'],
'current_amount': a['current_amount'],
'comparison_amount': a['comparison_amount'],
'variance_amount': a['variance_amount'],
'variance_pct': a['variance_pct'],
'severity': a['severity'],
'direction': a['direction'],
}
if existing:
existing.write(vals)
else:
Anomaly.create(vals)
@http.route('/fusion/reports/get_commentary', type='jsonrpc', auth='user')
def get_commentary(self, report_type, date_from, date_to,
comparison='none', force_regenerate=False, company_id=None):
company_id = int(company_id) if company_id else request.env.company.id
Report = request.env['fusion.report']
Commentary = request.env['fusion.report.commentary']
report_def = Report.search([('report_type', '=', report_type)], limit=1)
if not report_def:
raise ValidationError(_("No report definition for %s") % report_type)
period_from = _parse_date(date_from)
period_to = _parse_date(date_to)
cached = Commentary.search([
('report_id', '=', report_def.id),
('company_id', '=', company_id),
('period_from', '=', period_from),
('period_to', '=', period_to),
('comparison_mode', '=', comparison),
], limit=1)
if cached and not force_regenerate:
return {
'cached': True,
'summary': cached.summary or '',
'highlights': cached.highlights or [],
'concerns': cached.concerns or [],
'next_actions': cached.next_actions or [],
'generated_at': str(cached.generated_at),
}
report_result = self.run(
report_type=report_type, date_from=date_from,
date_to=date_to, comparison=comparison,
company_id=company_id,
)
anomalies = detect_anomalies(report_result)
commentary = generate_commentary(
request.env,
report_result=report_result,
anomalies=anomalies,
)
vals = {
'report_id': report_def.id,
'company_id': company_id,
'period_from': period_from,
'period_to': period_to,
'comparison_mode': comparison,
'summary': commentary.get('summary', ''),
'highlights': commentary.get('highlights', []),
'concerns': commentary.get('concerns', []),
'next_actions': commentary.get('next_actions', []),
}
if cached:
cached.write(vals)
else:
Commentary.create(vals)
return {'cached': False, **commentary}
@http.route('/fusion/reports/compare_periods', type='jsonrpc', auth='user')
def compare_periods(self, report_type, date_from, date_to,
comparison='previous_year', company_id=None):
return self.run(
report_type=report_type, date_from=date_from,
date_to=date_to, comparison=comparison,
company_id=company_id,
)
@http.route('/fusion/reports/export_pdf', type='jsonrpc', auth='user')
def export_pdf(self, report_type, date_from, date_to,
comparison='none', company_id=None):
Report = request.env['fusion.report']
report_def = Report.search([('report_type', '=', report_type)], limit=1)
if not report_def:
return {'status': 'error', 'message': f'No report definition for {report_type}'}
company_id = int(company_id) if company_id else request.env.company.id
pdf, _ct = request.env['ir.actions.report'].sudo()._render_qweb_pdf(
'fusion_accounting_reports.report_pdf_template',
res_ids=[report_def.id],
data={
'report_type': report_type,
'date_from': date_from, 'date_to': date_to,
'comparison': comparison, 'company_id': company_id,
},
)
import base64
return {
'status': 'ok',
'pdf_base64': base64.b64encode(pdf).decode('ascii'),
'filename': f'{report_type}_{date_from}_{date_to}.pdf',
}
@http.route('/fusion/reports/export_xlsx', type='jsonrpc', auth='user')
def export_xlsx(self, report_type, date_from, date_to,
comparison='none', company_id=None):
wizard = request.env['fusion.xlsx.export.wizard'].create({
'report_type': report_type,
'date_from': _parse_date(date_from),
'date_to': _parse_date(date_to),
'comparison': comparison,
})
wizard.action_export()
return {
'status': 'ok',
'xlsx_base64': wizard.xlsx_file.decode('ascii') if wizard.xlsx_file else '',
'filename': wizard.xlsx_filename,
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="cron_fusion_reports_anomaly_scan" model="ir.cron">
<field name="name">Fusion Reports - Daily Anomaly Scan</field>
<field name="model_id" ref="model_fusion_reports_cron"/>
<field name="state">code</field>
<field name="code">model._cron_anomaly_scan()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
<record id="cron_fusion_reports_mv_refresh" model="ir.cron">
<field name="name">Fusion Reports - MV Refresh</field>
<field name="model_id" ref="model_fusion_reports_cron"/>
<field name="state">code</field>
<field name="code">model._cron_mv_refresh()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="active" eval="True"/>
</record>
</odoo>

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="report_balance_sheet" model="fusion.report">
<field name="name">Balance Sheet</field>
<field name="code">balance_sheet</field>
<field name="report_type">balance_sheet</field>
<field name="sequence">20</field>
<field name="default_comparison_mode">previous_year</field>
<field name="description">Statement of financial position as of a given date.</field>
<field name="line_specs" eval="[
{'label': 'ASSETS', 'level': 0},
{'label': 'Current Assets', 'account_type_prefix': 'asset_current', 'sign': 1, 'level': 1},
{'label': 'Receivables', 'account_type_prefix': 'asset_receivable', 'sign': 1, 'level': 1},
{'label': 'Cash &amp; Bank', 'account_type_prefix': 'asset_cash', 'sign': 1, 'level': 1},
{'label': 'Prepayments', 'account_type_prefix': 'asset_prepayments', 'sign': 1, 'level': 1},
{'label': 'Non-Current Assets', 'account_type_prefix': 'asset_non_current', 'sign': 1, 'level': 1},
{'label': 'Fixed Assets', 'account_type_prefix': 'asset_fixed', 'sign': 1, 'level': 1},
{'label': 'TOTAL ASSETS', 'compute': 'subtotal', 'above': 6, 'sign': 1, 'level': 0},
{'label': 'LIABILITIES', 'level': 0},
{'label': 'Payables', 'account_type_prefix': 'liability_payable', 'sign': -1, 'level': 1},
{'label': 'Credit Cards', 'account_type_prefix': 'liability_credit_card', 'sign': -1, 'level': 1},
{'label': 'Current Liabilities', 'account_type_prefix': 'liability_current', 'sign': -1, 'level': 1},
{'label': 'Non-Current Liabilities', 'account_type_prefix': 'liability_non_current', 'sign': -1, 'level': 1},
{'label': 'TOTAL LIABILITIES', 'compute': 'subtotal', 'above': 4, 'sign': 1, 'level': 0},
{'label': 'EQUITY', 'level': 0},
{'label': 'Equity', 'account_type_prefix': 'equity', 'sign': -1, 'level': 1},
{'label': 'TOTAL EQUITY', 'compute': 'subtotal', 'above': 1, 'sign': 1, 'level': 0},
{'label': 'TOTAL LIABILITIES + EQUITY', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="report_general_ledger" model="fusion.report">
<field name="name">General Ledger</field>
<field name="code">general_ledger</field>
<field name="report_type">general_ledger</field>
<field name="sequence">40</field>
<field name="default_comparison_mode">none</field>
<field name="description">Per-account journal item listing for the period.</field>
<field name="line_specs" eval="[
{'label': 'All Accounts', 'account_type_prefix': 'asset', 'sign': 1, 'level': 0},
{'label': 'All Accounts (liability)', 'account_type_prefix': 'liability', 'sign': 1, 'level': 0},
{'label': 'All Accounts (equity)', 'account_type_prefix': 'equity', 'sign': 1, 'level': 0},
{'label': 'All Accounts (income)', 'account_type_prefix': 'income', 'sign': 1, 'level': 0},
{'label': 'All Accounts (expense)', 'account_type_prefix': 'expense', 'sign': 1, 'level': 0},
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="report_pnl" model="fusion.report">
<field name="name">Profit and Loss</field>
<field name="code">pnl</field>
<field name="report_type">pnl</field>
<field name="sequence">10</field>
<field name="default_comparison_mode">previous_year</field>
<field name="description">Income Statement summarizing revenue, expenses, and net income for a period.</field>
<field name="line_specs" eval="[
{'label': 'Revenue', 'account_type_prefix': 'income', 'sign': -1, 'level': 0},
{'label': 'Operating Expenses', 'account_type_prefix': 'expense', 'sign': -1, 'level': 0},
{'label': 'Net Income', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="report_trial_balance" model="fusion.report">
<field name="name">Trial Balance</field>
<field name="code">trial_balance</field>
<field name="report_type">trial_balance</field>
<field name="sequence">30</field>
<field name="default_comparison_mode">none</field>
<field name="description">Per-account balances for verifying that debits equal credits.</field>
<field name="line_specs" eval="[
{'label': 'Assets', 'account_type_prefix': 'asset', 'sign': 1, 'level': 0},
{'label': 'Liabilities', 'account_type_prefix': 'liability', 'sign': -1, 'level': 0},
{'label': 'Equity', 'account_type_prefix': 'equity', 'sign': -1, 'level': 0},
{'label': 'Income', 'account_type_prefix': 'income', 'sign': -1, 'level': 0},
{'label': 'Expenses', 'account_type_prefix': 'expense', 'sign': 1, 'level': 0},
{'label': 'Total (should be 0)', 'compute': 'subtotal', 'above': 5, 'sign': 1, 'level': 0},
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,31 @@
-- Materialized view: per-account aggregated balances by year-month.
-- Used by GL drill-down + trial balance for large DBs.
-- Refresh strategy: cron every 15 minutes (Task 25); CONCURRENTLY-capable
-- thanks to the unique index.
CREATE MATERIALIZED VIEW IF NOT EXISTS fusion_account_balance_mv AS
SELECT
ROW_NUMBER() OVER (
ORDER BY account_id, company_id, DATE_TRUNC('month', date)
)::INTEGER AS id,
account_id,
company_id,
DATE_TRUNC('month', date)::date AS period_month,
SUM(debit) AS debit,
SUM(credit) AS credit,
SUM(balance) AS balance,
COUNT(*) AS line_count
FROM account_move_line
WHERE parent_state = 'posted'
GROUP BY account_id, company_id, DATE_TRUNC('month', date);
-- The (account_id, company_id, period_month) tuple is the natural key.
-- We mark it UNIQUE so REFRESH MATERIALIZED VIEW CONCURRENTLY is allowed.
CREATE UNIQUE INDEX IF NOT EXISTS fusion_account_balance_mv_pkey
ON fusion_account_balance_mv (account_id, company_id, period_month);
-- A separate index on the synthetic id is required by Odoo's ORM, which
-- expects every model row to be addressable by `id`.
CREATE UNIQUE INDEX IF NOT EXISTS fusion_account_balance_mv_id_idx
ON fusion_account_balance_mv (id);
CREATE INDEX IF NOT EXISTS fusion_account_balance_mv_company_month
ON fusion_account_balance_mv (company_id, period_month);

View File

@@ -0,0 +1,7 @@
from . import fusion_report
from . import fusion_report_engine
from . import fusion_report_commentary
from . import fusion_report_anomaly
from . import fusion_account_balance_mv
from . import fusion_reports_cron
from . import fusion_migration_wizard

View File

@@ -0,0 +1,80 @@
"""Materialized view of per-account-per-month balances.
Created lazily by init() (called by Odoo on install/upgrade). Refresh
via the model's _refresh() method or via cron (Task 25)."""
import logging
import os
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class FusionAccountBalanceMV(models.Model):
_name = "fusion.account.balance.mv"
_description = "MV of per-account per-month aggregated balances"
_auto = False
_table = "fusion_account_balance_mv"
_order = "period_month desc, account_id"
account_id = fields.Many2one('account.account', readonly=True)
company_id = fields.Many2one('res.company', readonly=True)
period_month = fields.Date(readonly=True)
debit = fields.Float(readonly=True)
credit = fields.Float(readonly=True)
balance = fields.Float(readonly=True)
line_count = fields.Integer(readonly=True)
def init(self):
# If the MV exists but is missing the synthetic `id` column (e.g. from
# an earlier dev install), drop it so the new schema applies cleanly.
self.env.cr.execute(
"""
SELECT 1
FROM pg_matviews mv
JOIN pg_attribute a
ON a.attrelid = (mv.schemaname || '.' || mv.matviewname)::regclass
AND a.attname = 'id'
WHERE mv.matviewname = 'fusion_account_balance_mv'
"""
)
if not self.env.cr.fetchone():
self.env.cr.execute(
"DROP MATERIALIZED VIEW IF EXISTS fusion_account_balance_mv"
)
sql_path = os.path.join(
os.path.dirname(__file__), '..', 'data', 'sql',
'create_mv_account_balance.sql',
)
with open(sql_path, 'r') as f:
self.env.cr.execute(f.read())
_logger.info(
"fusion_account_balance_mv: created/verified MV + indexes")
@api.model
def _refresh(self, *, concurrently=True):
"""Refresh the MV. Falls back to non-concurrent if CONCURRENTLY fails.
REFRESH MATERIALIZED VIEW CONCURRENTLY requires the MV to be already
populated and an autocommit-capable cursor; the cron path in Task 25
opens a dedicated cursor for that. This helper keeps callers safe by
retrying without CONCURRENTLY on failure."""
keyword = "CONCURRENTLY" if concurrently else ""
try:
self.env.cr.execute(
f"REFRESH MATERIALIZED VIEW {keyword} fusion_account_balance_mv"
)
_logger.debug(
"fusion_account_balance_mv refreshed (%s)",
'concurrent' if concurrently else 'blocking',
)
except Exception as e:
if concurrently:
_logger.warning(
"Concurrent MV refresh failed (%s); falling back", e)
self.env.cr.execute(
"REFRESH MATERIALIZED VIEW fusion_account_balance_mv"
)
else:
raise

View File

@@ -0,0 +1,35 @@
"""Reports-specific migration step.
Ensures the 4 CORE report definitions are present after migration."""
import logging
from odoo import models
_logger = logging.getLogger(__name__)
class FusionMigrationWizard(models.TransientModel):
_inherit = "fusion.migration.wizard"
def _reports_bootstrap_step(self):
"""Verify all 4 CORE report definitions exist."""
Report = self.env['fusion.report'].sudo()
expected = ['pnl', 'balance_sheet', 'trial_balance', 'general_ledger']
present = Report.search([('report_type', 'in', expected)]).mapped('report_type')
missing = set(expected) - set(present)
return {
'step': 'reports_bootstrap',
'expected_reports': expected,
'present_reports': list(present),
'missing_reports': list(missing),
}
def action_run_migration(self):
"""Override to add reports-bootstrap step at the end of the chain."""
result = super().action_run_migration() if hasattr(super(), 'action_run_migration') else None
try:
self._reports_bootstrap_step()
except Exception as e:
_logger.warning("reports_bootstrap_step failed: %s", e)
return result

View File

@@ -0,0 +1,63 @@
"""Persistent definition of a Fusion financial report.
Each report (P&L, balance sheet, trial balance, GL) has ONE row in
fusion.report describing its metadata + line specs. The line specs
are stored as a JSON-typed field for flexibility (each line spec
includes account_type filter, sub-totaling rules, sign convention)."""
from odoo import _, api, fields, models
REPORT_TYPES = [
('pnl', 'Income Statement (P&L)'),
('balance_sheet', 'Balance Sheet'),
('trial_balance', 'Trial Balance'),
('general_ledger', 'General Ledger'),
]
class FusionReport(models.Model):
_name = "fusion.report"
_description = "Fusion Financial Report Definition"
_order = "sequence, id"
name = fields.Char(required=True, translate=True)
code = fields.Char(
required=True,
help="Unique technical code (e.g. 'pnl', 'balance_sheet').",
)
report_type = fields.Selection(REPORT_TYPES, required=True)
sequence = fields.Integer(default=10)
description = fields.Text()
active = fields.Boolean(default=True)
# Layout config - stored as JSON for flexibility per report type.
# Example for P&L:
# [
# {"label": "Revenue", "account_type_prefix": "income_", "sign": 1},
# {"label": "Cost of Goods Sold", "account_type_prefix": "expense_direct_", "sign": -1},
# {"label": "Gross Profit", "compute": "subtotal", "above": 2},
# ...
# ]
line_specs = fields.Json(string="Line Specs")
show_zero_balances = fields.Boolean(default=False)
show_unposted = fields.Boolean(default=False)
default_comparison_mode = fields.Selection(
[
('none', 'No comparison'),
('previous_period', 'Previous Period'),
('previous_year', 'Previous Year'),
],
default='none',
)
company_id = fields.Many2one(
'res.company',
default=lambda self: self.env.company,
)
_unique_company_code = models.Constraint(
'UNIQUE(company_id, code)',
'Report code must be unique per company.',
)

View File

@@ -0,0 +1,56 @@
"""Persisted anomaly flags from the engine's variance detection.
Each row captures one flagged report row variance. Used by the OWL
anomaly_strip + the audit trail."""
from odoo import _, api, fields, models
SEVERITY = [('low', 'Low'), ('medium', 'Medium'), ('high', 'High')]
DIRECTION = [('increase', 'Increase'), ('decrease', 'Decrease')]
class FusionReportAnomaly(models.Model):
_name = "fusion.report.anomaly"
_description = "Flagged Report Variance"
_order = "detected_at desc, severity desc"
report_id = fields.Many2one('fusion.report', required=True, ondelete='cascade')
company_id = fields.Many2one('res.company', required=True,
default=lambda self: self.env.company)
period_from = fields.Date(required=True)
period_to = fields.Date(required=True)
row_id = fields.Char(required=True, help="Engine-generated row id (e.g. 'line_3').")
label = fields.Char(required=True)
current_amount = fields.Float()
comparison_amount = fields.Float()
variance_amount = fields.Float()
variance_pct = fields.Float()
severity = fields.Selection(SEVERITY, required=True)
direction = fields.Selection(DIRECTION, required=True)
detected_at = fields.Datetime(default=fields.Datetime.now, required=True)
state = fields.Selection([
('new', 'New'),
('acknowledged', 'Acknowledged'),
('investigating', 'Investigating'),
('resolved', 'Resolved'),
('dismissed', 'Dismissed'),
], default='new', required=True)
notes = fields.Text()
acknowledged_by = fields.Many2one('res.users')
acknowledged_at = fields.Datetime()
def action_acknowledge(self):
self.write({
'state': 'acknowledged',
'acknowledged_by': self.env.uid,
'acknowledged_at': fields.Datetime.now(),
})
def action_dismiss(self):
self.write({'state': 'dismissed'})
def action_resolve(self):
self.write({'state': 'resolved'})

View File

@@ -0,0 +1,43 @@
"""Cached AI-generated commentary for a report run.
One row per (report, period_from, period_to, comparison_mode, company).
Refreshed on demand or via cron when the underlying data has changed."""
from odoo import _, api, fields, models
class FusionReportCommentary(models.Model):
_name = "fusion.report.commentary"
_description = "AI-Generated Report Commentary Cache"
_order = "generated_at desc"
report_id = fields.Many2one('fusion.report', required=True, ondelete='cascade')
company_id = fields.Many2one('res.company', required=True,
default=lambda self: self.env.company)
period_from = fields.Date(required=True)
period_to = fields.Date(required=True)
comparison_mode = fields.Selection([
('none', 'None'),
('previous_period', 'Previous Period'),
('previous_year', 'Previous Year'),
], default='none', required=True)
summary = fields.Text()
highlights = fields.Json() # list of strings
concerns = fields.Json() # list of strings
next_actions = fields.Json() # list of strings
generated_at = fields.Datetime(default=fields.Datetime.now, required=True)
generated_by = fields.Selection([
('on_demand', 'On Demand'),
('cron', 'Cron'),
('templated', 'Templated Fallback'),
], default='on_demand', required=True)
provider = fields.Char(help="LLM provider used (e.g. 'openai', 'claude', 'local'). "
"Empty for templated fallback.")
_unique_period = models.Constraint(
'UNIQUE(report_id, company_id, period_from, period_to, comparison_mode)',
'Only one commentary cache row per report+period+mode.',
)

View File

@@ -0,0 +1,245 @@
"""The reports engine - orchestrator for all report computation.
5-method public API. All controllers, AI tools, wizards, exports must
go through these methods; no direct ORM aggregation queries from
anywhere else.
Internal pipeline (per report run):
1. Validate (period valid, company allowed, report exists)
2. Fetch account hierarchy (cached per (company, fiscal_year))
3. Aggregate move lines per account (the SQL workhorse)
4. Resolve line_specs into report rows
5. (Optional) Compute comparison-period rows
6. (Optional) Detect anomalies (deferred to later tasks)
"""
import logging
from datetime import date
from odoo import _, api, models
from odoo.exceptions import ValidationError
from ..services.account_hierarchy import build_tree
from ..services.date_periods import Period, comparison_period as _comp_period
from ..services.drill_down_resolver import fetch_drill_down
from ..services.line_resolver import resolve as _resolve_lines
from ..services.totaling import TotalLine
_logger = logging.getLogger(__name__)
class FusionReportEngine(models.AbstractModel):
_name = "fusion.report.engine"
_description = "Fusion Financial Reports Engine"
# ============================================================
# PUBLIC API (5 methods)
# ============================================================
@api.model
def compute_pnl(
self, period: Period, *, comparison: str = 'none',
company_id: int | None = None,
) -> dict:
"""Income statement (P&L) for the given period."""
report = self._get_report('pnl', company_id=company_id)
return self._compute(
report, period, comparison=comparison, company_id=company_id,
)
@api.model
def compute_balance_sheet(
self, date_to: date, *, comparison: str = 'none',
company_id: int | 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)
period = Period(
date_from=date(1970, 1, 1),
date_to=date_to,
label=f"As of {date_to}",
)
return self._compute(
report, period, comparison=comparison, company_id=company_id,
)
@api.model
def compute_trial_balance(
self, period: Period, *, company_id: int | 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)
return self._compute(
report, period, comparison='none', company_id=company_id,
)
@api.model
def compute_gl(
self, period: Period, *, account_ids: list | None = None,
company_id: int | 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)
company_id = company_id or self.env.company.id
result = self._compute(
report, period, comparison='none', company_id=company_id,
)
gl_by_account = {}
target_ids = account_ids or list(result.get('account_totals', {}).keys())
for acct_id in target_ids:
gl_by_account[acct_id] = fetch_drill_down(
self.env,
account_id=acct_id,
date_from=period.date_from,
date_to=period.date_to,
company_id=company_id,
limit=200,
)
result['gl_by_account'] = gl_by_account
return result
@api.model
def drill_down(
self, *, account_id: int, period: Period,
company_id: int | None = None,
) -> list:
"""Drill into a report line: list the journal items behind it."""
company_id = company_id or self.env.company.id
return fetch_drill_down(
self.env,
account_id=account_id,
date_from=period.date_from,
date_to=period.date_to,
company_id=company_id,
limit=500,
)
# ============================================================
# 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)."""
Report = self.env['fusion.report'].sudo()
company_id = company_id or self.env.company.id
report = Report.search(
[
('report_type', '=', report_type),
('active', '=', True),
'|',
('company_id', '=', company_id),
('company_id', '=', False),
],
order='company_id desc nulls last',
limit=1,
)
if not report:
raise ValidationError(
_("No active fusion.report definition for type '%s'") % report_type
)
return report
def _fetch_accounts(self, company_id):
"""Fetch all accounts for a company, return flat dict + tree."""
Account = self.env['account.account'].sudo()
records = Account.search([('company_ids', 'in', company_id)])
# account.account doesn't carry a parent_id in V19 - we use
# account_type prefixes instead, so parent_id is always None here.
flat = [
{
'id': a.id,
'code': a.code,
'name': a.name,
'account_type': a.account_type or '',
'parent_id': None,
}
for a in records
]
accounts_by_id = {a['id']: a for a in flat}
tree = build_tree(flat)
return accounts_by_id, tree
def _aggregate_period(self, period: Period, company_id: int) -> dict:
"""SQL aggregate per account_id for a period.
Raw SQL for performance; this is the perf-critical step."""
self.env.cr.execute(
"""
SELECT account_id,
COALESCE(SUM(debit), 0) AS d,
COALESCE(SUM(credit), 0) AS c,
COALESCE(SUM(balance), 0) AS b
FROM account_move_line
WHERE parent_state = 'posted'
AND company_id = %s
AND date >= %s
AND date <= %s
GROUP BY account_id
""",
(company_id, period.date_from, period.date_to),
)
out = {}
for row in self.env.cr.fetchall():
out[row[0]] = TotalLine(
debit=float(row[1] or 0),
credit=float(row[2] or 0),
balance=float(row[3] or 0),
)
return out
def _compute(
self, report, period: Period, *, comparison: str,
company_id: int | None = None,
) -> dict:
"""Shared computation pipeline. Returns dict with rows, totals,
metadata."""
company_id = company_id or self.env.company.id
accounts_by_id, _tree = self._fetch_accounts(company_id)
account_totals = self._aggregate_period(period, company_id)
comp_totals = None
comp_period = None
if comparison and comparison != 'none':
comp_period = _comp_period(period, comparison)
if comp_period:
comp_totals = self._aggregate_period(comp_period, company_id)
rows = _resolve_lines(
report.line_specs or [],
account_totals=account_totals,
accounts_by_id=accounts_by_id,
comparison_totals=comp_totals,
)
return {
'report_id': report.id,
'report_name': report.name,
'report_type': report.report_type,
'period': {
'date_from': str(period.date_from),
'date_to': str(period.date_to),
'label': period.label,
},
'comparison_period': (
{
'date_from': str(comp_period.date_from),
'date_to': str(comp_period.date_to),
'label': comp_period.label,
}
if comp_period
else None
),
'company_id': company_id,
'rows': rows,
'account_totals': {
aid: tl.balance for aid, tl in account_totals.items()
},
}

View File

@@ -0,0 +1,117 @@
"""Cron handlers for fusion_accounting_reports.
Two scheduled jobs:
- _cron_anomaly_scan: daily P&L variance scan -> persist anomalies
- _cron_mv_refresh: every 15 min CONCURRENTLY refresh the MV"""
import logging
from datetime import timedelta
import odoo
from odoo import api, fields, models
from ..services.anomaly_detection import detect
from ..services.date_periods import month_bounds
_logger = logging.getLogger(__name__)
class FusionReportsCron(models.AbstractModel):
_name = "fusion.reports.cron"
_description = "Fusion Reports Cron Handlers"
@api.model
def _cron_anomaly_scan(self):
"""Run last-month P&L vs prior-year-same-month and persist anomalies."""
today = fields.Date.today()
# Walk back into the previous full calendar month.
last_month = today.replace(day=1) - timedelta(days=1)
period = month_bounds(last_month)
Report = self.env['fusion.report'].sudo()
Anomaly = self.env['fusion.report.anomaly'].sudo()
engine = self.env['fusion.report.engine']
for company in self.env['res.company'].search([]):
try:
pnl_def = Report.search(
[
('report_type', '=', 'pnl'),
'|', ('company_id', '=', company.id),
('company_id', '=', False),
],
limit=1,
)
if not pnl_def:
continue
result = engine.compute_pnl(
period,
comparison='previous_year',
company_id=company.id,
)
anomalies = detect(result)
for a in anomalies:
existing = Anomaly.search(
[
('report_id', '=', pnl_def.id),
('company_id', '=', company.id),
('period_from', '=', period.date_from),
('period_to', '=', period.date_to),
('row_id', '=', a['row_id']),
],
limit=1,
)
vals = {
'report_id': pnl_def.id,
'company_id': company.id,
'period_from': period.date_from,
'period_to': period.date_to,
'row_id': a['row_id'],
'label': a['label'],
'current_amount': a['current_amount'],
'comparison_amount': a['comparison_amount'],
'variance_amount': a['variance_amount'],
'variance_pct': a['variance_pct'],
'severity': a['severity'],
'direction': a['direction'],
}
if existing:
existing.write(vals)
else:
Anomaly.create(vals)
_logger.info(
"Anomaly scan for company %s: %d flagged",
company.id, len(anomalies),
)
except Exception as e:
_logger.exception(
"Anomaly scan failed for company %s: %s", company.id, e,
)
@api.model
def _cron_mv_refresh(self):
"""REFRESH CONCURRENTLY via dedicated autocommit cursor.
REFRESH MATERIALIZED VIEW CONCURRENTLY cannot run inside a
transaction block, so we open a separate connection with autocommit
enabled. The blocking REFRESH is used as a fallback if the
concurrent path fails (e.g. on a cold MV with no rows yet)."""
try:
db_name = self.env.cr.dbname
db = odoo.sql_db.db_connect(db_name)
with db.cursor() as cron_cr:
cron_cr._cnx.set_session(autocommit=True)
cron_cr.execute(
"REFRESH MATERIALIZED VIEW CONCURRENTLY "
"fusion_account_balance_mv"
)
_logger.debug("MV refresh CONCURRENTLY succeeded")
except Exception as e:
_logger.warning(
"CONCURRENTLY refresh failed (%s); blocking fallback", e)
try:
self.env['fusion.account.balance.mv']._refresh(
concurrently=False)
except Exception as e2:
_logger.exception(
"Blocking MV refresh also failed: %s", e2)

View File

@@ -0,0 +1 @@
from . import report_pdf

View File

@@ -0,0 +1,58 @@
"""QWeb PDF report for fusion financial reports.
Wraps the engine's compute_* methods and feeds the result into a
single multi-purpose template that handles all 4 report types."""
from datetime import datetime
from odoo import api, models
from ..services.date_periods import Period
class FusionReportPdf(models.AbstractModel):
_name = "report.fusion_accounting_reports.report_pdf_template"
_description = "Fusion Financial Report PDF"
@api.model
def _get_report_values(self, docids, data=None):
"""data is expected to be {report_type, date_from, date_to, comparison, company_id}."""
data = data or {}
report_type = data.get('report_type', 'pnl')
company_id = data.get('company_id') or self.env.company.id
date_from = data.get('date_from')
date_to = data.get('date_to')
comparison = data.get('comparison', 'none')
if isinstance(date_from, str):
date_from = datetime.strptime(date_from, '%Y-%m-%d').date()
if isinstance(date_to, str):
date_to = datetime.strptime(date_to, '%Y-%m-%d').date()
engine = self.env['fusion.report.engine']
if report_type == 'pnl':
period = Period(date_from, date_to, f"{date_from} - {date_to}")
result = engine.compute_pnl(period, comparison=comparison, company_id=company_id)
elif report_type == 'balance_sheet':
result = engine.compute_balance_sheet(date_to, comparison=comparison, company_id=company_id)
elif report_type == 'trial_balance':
period = Period(date_from, date_to, f"{date_from} - {date_to}")
result = engine.compute_trial_balance(period, company_id=company_id)
elif report_type == 'general_ledger':
period = Period(date_from, date_to, f"{date_from} - {date_to}")
result = engine.compute_gl(period, company_id=company_id)
else:
result = {'rows': [], 'report_name': 'Unknown', 'period': {}}
company = self.env['res.company'].browse(company_id)
return {
'doc_ids': docids,
'doc_model': 'fusion.report',
'docs': self.env['fusion.report'].browse(docids) if docids else
self.env['fusion.report'].search([('report_type', '=', report_type)], limit=1),
'data': data,
'result': result,
'company_id': company,
'company': company,
'res_company': company,
}

View File

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_pdf_template">
<t t-call="web.html_container">
<t t-call="web.external_layout">
<div class="page">
<h2>
<t t-esc="result.get('report_name', 'Financial Report')"/>
</h2>
<p>
<strong>Period:</strong>
<span t-esc="result.get('period', {}).get('label', '')"/>
</p>
<p t-if="result.get('comparison_period')">
<strong>Compared to:</strong>
<span t-esc="result.get('comparison_period', {}).get('label', '')"/>
</p>
<table class="table table-sm">
<thead>
<tr>
<th>Line</th>
<th class="text-end">Amount</th>
<t t-if="result.get('comparison_period')">
<th class="text-end">Comparison</th>
<th class="text-end">Variance %</th>
</t>
</tr>
</thead>
<tbody>
<tr t-foreach="result.get('rows', [])" t-as="row"
t-attf-style="{{ 'font-weight: bold;' if row.get('is_subtotal') else '' }}">
<td t-attf-style="padding-left: {{ (row.get('level', 0) or 0) * 16 + 8 }}px;">
<span t-esc="row.get('label', '')"/>
</td>
<td class="text-end">
<span t-esc="'{:,.2f}'.format(row.get('amount', 0))"/>
</td>
<t t-if="result.get('comparison_period')">
<td class="text-end">
<t t-if="row.get('amount_comparison') is not None">
<span t-esc="'{:,.2f}'.format(row.get('amount_comparison'))"/>
</t>
</td>
<td class="text-end">
<t t-if="row.get('variance_pct') is not None">
<span t-esc="'{:+.1f}%'.format(row.get('variance_pct'))"/>
</t>
</td>
</t>
</tr>
</tbody>
</table>
<p class="text-muted" style="font-size: 0.75rem;">
Generated by Fusion Accounting Reports
</p>
</div>
</t>
</t>
</template>
<record id="action_report_fusion_financial" model="ir.actions.report">
<field name="name">Fusion Financial Report (PDF)</field>
<field name="model">fusion.report</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_accounting_reports.report_pdf_template</field>
<field name="report_file">fusion_accounting_reports.report_pdf_template</field>
<field name="binding_model_id" ref="model_fusion_report"/>
<field name="binding_view_types">form,list</field>
</record>
</odoo>

View File

@@ -0,0 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_report_user,fusion.report.user,model_fusion_report,base.group_user,1,0,0,0
access_fusion_report_admin,fusion.report.admin,model_fusion_report,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_report_commentary,fusion.report.commentary,model_fusion_report_commentary,base.group_user,1,1,1,0
access_fusion_report_anomaly,fusion.report.anomaly,model_fusion_report_anomaly,base.group_user,1,1,1,0
access_fusion_xlsx_export_wizard_user,fusion.xlsx.export.wizard.user,model_fusion_xlsx_export_wizard,base.group_user,1,1,1,0
access_fusion_period_picker_wizard_user,fusion.period.picker.wizard.user,model_fusion_period_picker_wizard,base.group_user,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_report_user fusion.report.user model_fusion_report base.group_user 1 0 0 0
3 access_fusion_report_admin fusion.report.admin model_fusion_report fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
4 access_fusion_report_commentary fusion.report.commentary model_fusion_report_commentary base.group_user 1 1 1 0
5 access_fusion_report_anomaly fusion.report.anomaly model_fusion_report_anomaly base.group_user 1 1 1 0
6 access_fusion_xlsx_export_wizard_user fusion.xlsx.export.wizard.user model_fusion_xlsx_export_wizard base.group_user 1 1 1 0
7 access_fusion_period_picker_wizard_user fusion.period.picker.wizard.user model_fusion_period_picker_wizard base.group_user 1 1 1 0

View File

@@ -0,0 +1,9 @@
from . import date_periods
from . import account_hierarchy
from . import totaling
from . import currency_conversion
from . import line_resolver
from . import drill_down_resolver
from . import anomaly_detection
from . import commentary_prompt
from . import commentary_generator

View File

@@ -0,0 +1,62 @@
"""Account hierarchy walker.
Given a flat list of accounts with parent_id pointers, build a tree and
provide a recursive walker that yields (account, depth, ancestors) tuples.
Used by report line resolvers to render group sub-totals."""
from dataclasses import dataclass, field
from typing import Iterator
@dataclass
class AccountNode:
id: int
code: str
name: str
account_type: str
parent_id: int | None
children: list['AccountNode'] = field(default_factory=list)
def build_tree(accounts: list[dict]) -> list[AccountNode]:
"""Build a forest from a flat list of account dicts.
Each dict must have keys: id, code, name, account_type, parent_id (nullable)."""
nodes: dict[int, AccountNode] = {}
for acc in accounts:
nodes[acc['id']] = AccountNode(
id=acc['id'], code=acc['code'], name=acc['name'],
account_type=acc['account_type'],
parent_id=acc.get('parent_id'),
)
roots: list[AccountNode] = []
for node in nodes.values():
if node.parent_id and node.parent_id in nodes:
nodes[node.parent_id].children.append(node)
else:
roots.append(node)
for node in nodes.values():
node.children.sort(key=lambda n: n.code)
roots.sort(key=lambda n: n.code)
return roots
def walk(roots: list[AccountNode], *, max_depth: int = 10) -> Iterator[tuple[AccountNode, int, list[AccountNode]]]:
"""Depth-first walk yielding (node, depth, ancestors)."""
def _walk(node: AccountNode, depth: int, ancestors: list[AccountNode]):
yield (node, depth, ancestors)
if depth < max_depth:
for child in node.children:
yield from _walk(child, depth + 1, ancestors + [node])
for root in roots:
yield from _walk(root, 0, [])
def filter_by_account_type(roots: list[AccountNode], type_prefix: str) -> list[AccountNode]:
"""Return all nodes whose account_type starts with type_prefix
(e.g. 'asset_' returns asset_receivable, asset_cash, etc.)."""
matches: list[AccountNode] = []
for node, _depth, _ancestors in walk(roots):
if node.account_type.startswith(type_prefix):
matches.append(node)
return matches

View File

@@ -0,0 +1,81 @@
"""Anomaly detection for financial reports.
Compares each row's current-period amount to its comparison-period
amount and flags variances exceeding a threshold. Uses both:
- Absolute threshold ($X minimum movement)
- Percentage threshold (Y% min variance)
Pure-Python: callers pass the engine's compute_*() result; we return
a list of anomaly dicts."""
from dataclasses import dataclass
@dataclass
class Anomaly:
row_id: str
label: str
current_amount: float
comparison_amount: float
variance_amount: float
variance_pct: float
severity: str # 'low', 'medium', 'high'
direction: str # 'increase', 'decrease'
def to_dict(self):
return {
'row_id': self.row_id, 'label': self.label,
'current_amount': self.current_amount,
'comparison_amount': self.comparison_amount,
'variance_amount': self.variance_amount,
'variance_pct': self.variance_pct,
'severity': self.severity, 'direction': self.direction,
}
# Defaults -- tunable per company via ir.config_parameter
DEFAULT_MIN_ABSOLUTE_THRESHOLD = 100.0
DEFAULT_MIN_PCT_THRESHOLD = 10.0 # 10%
DEFAULT_HIGH_PCT_THRESHOLD = 50.0 # 50%+ flagged 'high'
def detect(report_result: dict, *, min_absolute: float = None,
min_pct: float = None, high_pct: float = None) -> list[dict]:
"""Detect anomalies in a report_result dict (engine output).
Returns list of anomaly dicts ordered by severity desc, variance_amount desc.
Returns empty list if no comparison period was computed."""
if not report_result.get('comparison_period'):
return []
min_absolute = min_absolute if min_absolute is not None else DEFAULT_MIN_ABSOLUTE_THRESHOLD
min_pct = min_pct if min_pct is not None else DEFAULT_MIN_PCT_THRESHOLD
high_pct = high_pct if high_pct is not None else DEFAULT_HIGH_PCT_THRESHOLD
anomalies = []
for row in report_result.get('rows', []):
comparison = row.get('amount_comparison')
current = row.get('amount', 0.0)
if comparison is None:
continue
variance_amount = current - comparison
variance_pct = abs(row.get('variance_pct') or 0.0)
if abs(variance_amount) < min_absolute:
continue
if variance_pct < min_pct:
continue
severity = 'high' if variance_pct >= high_pct else 'medium' if variance_pct >= min_pct * 2 else 'low'
direction = 'increase' if variance_amount > 0 else 'decrease'
anomalies.append(Anomaly(
row_id=row['id'],
label=row.get('label', ''),
current_amount=current,
comparison_amount=comparison,
variance_amount=variance_amount,
variance_pct=variance_pct,
severity=severity,
direction=direction,
).to_dict())
severity_order = {'high': 0, 'medium': 1, 'low': 2}
anomalies.sort(key=lambda a: (severity_order[a['severity']], -abs(a['variance_amount'])))
return anomalies

View File

@@ -0,0 +1,103 @@
"""AI-generated narrative commentary for financial reports.
Takes a report_result dict + optional anomalies list, builds an LLM
prompt, parses the structured output. Output contract:
{
'summary': str, # 2-3 sentence executive summary
'highlights': [str, ...], # 3-5 bullet observations
'concerns': [str, ...], # things that warrant investigation
'next_actions': [str, ...] # suggested follow-ups
}
"""
import json
import logging
_logger = logging.getLogger(__name__)
def generate_commentary(env, *, report_result: dict, anomalies: list = None,
provider=None) -> dict:
"""Generate narrative commentary via LLM. Returns dict per the contract.
If no provider configured, returns a templated fallback (no LLM)."""
if provider is None:
provider = _get_provider(env)
if provider is None:
return _templated_fallback(report_result, anomalies)
try:
from odoo.addons.fusion_accounting_reports.services.commentary_prompt import build_prompt
except ImportError:
_logger.debug("commentary_prompt module not yet available; using fallback")
return _templated_fallback(report_result, anomalies)
system, user = build_prompt(report_result, anomalies or [])
try:
response = provider.complete(
system=system,
messages=[{'role': 'user', 'content': user}],
max_tokens=1200,
temperature=0.2,
)
content = response.get('content') if isinstance(response, dict) else response
parsed = json.loads(content)
# Validate shape
for key in ('summary', 'highlights', 'concerns', 'next_actions'):
parsed.setdefault(key, [] if key != 'summary' else '')
return parsed
except Exception as e:
_logger.warning("AI commentary generation failed: %s", e)
return _templated_fallback(report_result, anomalies)
def _templated_fallback(report_result: dict, anomalies: list = None) -> dict:
"""No-LLM fallback that produces a basic narrative from the report data."""
anomalies = anomalies or []
rows = report_result.get('rows', [])
period = report_result.get('period', {})
period_label = period.get('label', 'this period')
# Find subtotal rows for the summary
subtotals = [r for r in rows if r.get('is_subtotal')]
summary_parts = [f"{report_result.get('report_name', 'Report')} for {period_label}."]
if subtotals:
last = subtotals[-1]
summary_parts.append(f"{last['label']}: ${last['amount']:,.2f}.")
highlights = []
for row in subtotals[:3]:
highlights.append(f"{row['label']}: ${row['amount']:,.2f}")
concerns = []
for a in anomalies[:3]:
concerns.append(
f"{a['label']} {a['direction']}d {a['variance_pct']:.1f}% "
f"(${a['variance_amount']:+,.2f})")
return {
'summary': ' '.join(summary_parts),
'highlights': highlights,
'concerns': concerns,
'next_actions': ['Review the flagged anomalies above.'] if concerns else [],
}
def _get_provider(env):
"""Look up provider for 'reports_commentary' feature; return None if not configured."""
param = env['ir.config_parameter'].sudo()
provider_name = param.get_param('fusion_accounting.provider.reports_commentary')
if not provider_name:
provider_name = param.get_param('fusion_accounting.provider.default')
if not provider_name:
return None
try:
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
except ImportError:
return None
if provider_name.startswith('openai'):
return OpenAIAdapter(env)
elif provider_name.startswith('claude'):
return ClaudeAdapter(env)
return None

View File

@@ -0,0 +1,67 @@
"""LLM prompt for AI report commentary.
Provider-agnostic system + user prompt builder. Output contract:
JSON with keys summary, highlights, concerns, next_actions."""
SYSTEM_PROMPT = """You are an experienced CFO providing executive-level commentary
on a financial report. Your output MUST be valid JSON of this exact shape:
{
"summary": "<2-3 sentence executive summary of the report period>",
"highlights": ["<observation 1>", "<observation 2>", ...],
"concerns": ["<thing to investigate 1>", ...],
"next_actions": ["<suggested action 1>", ...]
}
Rules:
- Use the data provided. Do not invent numbers.
- Tone: professional, concise, factual.
- Currency formatting: always include the $ symbol and 2 decimal places.
- For anomalies: explicitly mention the variance percentage AND the dollar amount.
- Do NOT include markdown code fences. Do NOT include any prose outside the JSON.
"""
def build_prompt(report_result: dict, anomalies: list) -> tuple[str, str]:
"""Build (system_prompt, user_prompt) tuple."""
parts = []
# Report context
parts.append(f"REPORT: {report_result.get('report_name', 'Untitled')}")
period = report_result.get('period', {})
parts.append(f"PERIOD: {period.get('label', '')} "
f"({period.get('date_from', '')} to {period.get('date_to', '')})")
comp_period = report_result.get('comparison_period')
if comp_period:
parts.append(f"COMPARED TO: {comp_period.get('label', '')} "
f"({comp_period.get('date_from', '')} to {comp_period.get('date_to', '')})")
parts.append("")
# Rows (the actual numbers)
parts.append("REPORT LINES:")
for row in report_result.get('rows', []):
line = f" - {row.get('label', '?')}: ${row.get('amount', 0):,.2f}"
if row.get('amount_comparison') is not None:
line += f" (comparison: ${row['amount_comparison']:,.2f}"
if row.get('variance_pct') is not None:
line += f", {row['variance_pct']:+.1f}%"
line += ")"
if row.get('is_subtotal'):
line += " [SUBTOTAL]"
parts.append(line)
parts.append("")
# Anomalies
if anomalies:
parts.append("ANOMALIES (variances exceeding threshold):")
for a in anomalies[:10]:
parts.append(
f" - {a['label']}: {a['direction']}d {a['variance_pct']:.1f}% "
f"(${a['variance_amount']:+,.2f}, severity: {a['severity']})"
)
parts.append("")
parts.append("Generate the JSON commentary per the system prompt.")
return (SYSTEM_PROMPT, "\n".join(parts))

View File

@@ -0,0 +1,66 @@
"""Multi-currency conversion for financial reports.
Converts move-line amounts to the report's display currency at the
report end-date. Pure-Python - caller provides exchange rates as a
dict {(source_code, target_code, date): rate}."""
from dataclasses import dataclass
from datetime import date
@dataclass
class ConversionRate:
source: str
target: str
rate: float
rate_date: date
def convert_amount(amount: float, *, source_currency: str, target_currency: str,
rate_date: date, rates: dict) -> float:
"""Convert `amount` from source to target at the given date.
`rates` is a dict keyed by (source, target, date) -> rate.
If source == target, returns amount unchanged."""
if source_currency == target_currency:
return amount
key = (source_currency, target_currency, rate_date)
if key in rates:
return amount * rates[key]
inv_key = (target_currency, source_currency, rate_date)
if inv_key in rates:
inv = rates[inv_key]
if inv != 0:
return amount / inv
candidates = [
(d, r) for (s, t, d), r in rates.items()
if s == source_currency and t == target_currency and d <= rate_date
]
if candidates:
candidates.sort(key=lambda x: x[0], reverse=True)
return amount * candidates[0][1]
raise ValueError(
f"No exchange rate available for {source_currency}->{target_currency} on or before {rate_date}"
)
def fetch_rates(env, *, target_currency_id: int, as_of: date,
source_currency_ids: list[int] | None = None) -> dict:
"""Fetch all relevant rates from res.currency.rate as of a given date.
Returns the dict-of-rates structure consumed by convert_amount.
Pulls only rates where source != target and date <= as_of."""
Rate = env['res.currency.rate'].sudo()
target = env['res.currency'].browse(target_currency_id)
domain = [
('name', '<=', as_of),
('currency_id', '!=', target.id),
]
if source_currency_ids:
domain.append(('currency_id', 'in', source_currency_ids))
rates_recs = Rate.search(domain)
out = {}
for r in rates_recs:
out[(r.currency_id.name, target.name, r.name)] = (1.0 / r.rate) if r.rate else 0.0
return out

View File

@@ -0,0 +1,103 @@
"""Date period math for financial reports.
Pure-Python helpers that compute:
- Fiscal year start/end given any reference date + company fiscal year settings
- Comparison periods (prior year same period, prior period, etc.)
- Period boundaries for monthly / quarterly / yearly reporting
NO Odoo imports - all callers pass in primitive types so the same module
is unit-testable without an Odoo registry."""
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Literal
PeriodGranularity = Literal['month', 'quarter', 'year', 'custom']
ComparisonMode = Literal['none', 'previous_period', 'previous_year']
@dataclass(frozen=True)
class Period:
date_from: date
date_to: date
label: str
def __post_init__(self):
if self.date_from > self.date_to:
raise ValueError(f"date_from ({self.date_from}) > date_to ({self.date_to})")
@property
def days(self) -> int:
return (self.date_to - self.date_from).days + 1
def fiscal_year_bounds(reference_date: date, *, fy_start_month: int = 1,
fy_start_day: int = 1) -> Period:
"""Return the fiscal year period containing `reference_date`.
Default: calendar year (Jan 1 - Dec 31). Pass fy_start_month=4, fy_start_day=1
for an April-March fiscal year."""
if reference_date.month < fy_start_month or (
reference_date.month == fy_start_month and reference_date.day < fy_start_day
):
start_year = reference_date.year - 1
else:
start_year = reference_date.year
start = date(start_year, fy_start_month, fy_start_day)
next_start = date(start_year + 1, fy_start_month, fy_start_day)
end = next_start - timedelta(days=1)
return Period(date_from=start, date_to=end, label=f"FY {start_year}")
def month_bounds(reference_date: date) -> Period:
"""Return the calendar month containing `reference_date`."""
start = reference_date.replace(day=1)
if reference_date.month == 12:
next_start = date(reference_date.year + 1, 1, 1)
else:
next_start = date(reference_date.year, reference_date.month + 1, 1)
return Period(
date_from=start,
date_to=next_start - timedelta(days=1),
label=start.strftime('%B %Y'),
)
def quarter_bounds(reference_date: date) -> Period:
"""Return the calendar quarter containing `reference_date`."""
quarter = (reference_date.month - 1) // 3 + 1
start_month = (quarter - 1) * 3 + 1
start = date(reference_date.year, start_month, 1)
end_month = start_month + 2
if end_month == 12:
end = date(reference_date.year, 12, 31)
else:
end = date(reference_date.year, end_month + 1, 1) - timedelta(days=1)
return Period(date_from=start, date_to=end, label=f"Q{quarter} {reference_date.year}")
def comparison_period(period: Period, mode: ComparisonMode) -> Period | None:
"""Derive the comparison period for `period` per `mode`.
`previous_period`: same length, immediately before
`previous_year`: same calendar dates, one year earlier
`none`: returns None"""
if mode == 'none':
return None
if mode == 'previous_period':
days = period.days
new_to = period.date_from - timedelta(days=1)
new_from = new_to - timedelta(days=days - 1)
return Period(date_from=new_from, date_to=new_to,
label=f"{period.label} (previous)")
if mode == 'previous_year':
try:
new_from = period.date_from.replace(year=period.date_from.year - 1)
new_to = period.date_to.replace(year=period.date_to.year - 1)
except ValueError:
new_from = period.date_from.replace(year=period.date_from.year - 1, day=28)
new_to = period.date_to.replace(year=period.date_to.year - 1, day=28)
return Period(date_from=new_from, date_to=new_to,
label=f"{period.label} (prev year)")
raise ValueError(f"Unknown comparison mode: {mode}")

View File

@@ -0,0 +1,81 @@
"""Drill-down: from a report line to its underlying journal items.
Given an account_id and a Period, fetches the matching account.move.line
records and returns them in a flat list. Used by the OWL drill-down
dialog and the engine's drill_down() public API."""
from dataclasses import dataclass
from datetime import date
@dataclass
class DrillDownRow:
move_line_id: int
move_id: int
move_name: str
date: date
account_code: str
account_name: str
partner_name: str | None
label: str
debit: float
credit: float
balance: float
def to_dict(self):
return {
'move_line_id': self.move_line_id,
'move_id': self.move_id,
'move_name': self.move_name,
'date': str(self.date),
'account_code': self.account_code,
'account_name': self.account_name,
'partner_name': self.partner_name or '',
'label': self.label,
'debit': self.debit,
'credit': self.credit,
'balance': self.balance,
}
def fetch_drill_down(
env,
*,
account_id: int,
date_from: date,
date_to: date,
company_id: int | None = None,
limit: int = 500,
) -> list[dict]:
"""Fetch journal items for an account within a date range.
Returns flat list of dicts ready for the drill-down OWL table."""
Line = env['account.move.line'].sudo()
domain = [
('account_id', '=', account_id),
('date', '>=', date_from),
('date', '<=', date_to),
('parent_state', '=', 'posted'),
]
if company_id:
domain.append(('company_id', '=', company_id))
move_lines = Line.search(domain, limit=limit, order='date asc, id asc')
rows = []
for ml in move_lines:
rows.append(
DrillDownRow(
move_line_id=ml.id,
move_id=ml.move_id.id,
move_name=ml.move_id.name or '',
date=ml.date,
account_code=ml.account_id.code,
account_name=ml.account_id.name,
partner_name=ml.partner_id.name if ml.partner_id else None,
label=ml.name or '',
debit=ml.debit,
credit=ml.credit,
balance=ml.balance,
).to_dict()
)
return rows

View File

@@ -0,0 +1,143 @@
"""Resolve a fusion.report definition into report rows.
Pure-Python: takes line_specs (list of dicts), a period, and aggregated
move-line data (per-account totals) - returns ordered list of report row
dicts ready for the OWL frontend or PDF rendering.
Row shape:
{
'id': 'line_<index>',
'label': str,
'level': int, # indentation depth
'is_subtotal': bool,
'amount': float,
'amount_comparison': float | None,
'variance_pct': float | None,
'account_id': int | None, # for drill-down (None for subtotals)
'children': list[dict], # populated when expanded
}"""
from dataclasses import dataclass
from .totaling import TotalLine
@dataclass
class ReportRow:
id: str
label: str
level: int = 0
is_subtotal: bool = False
amount: float = 0.0
amount_comparison: float | None = None
variance_pct: float | None = None
account_id: int | None = None
def to_dict(self):
return {
'id': self.id,
'label': self.label,
'level': self.level,
'is_subtotal': self.is_subtotal,
'amount': self.amount,
'amount_comparison': self.amount_comparison,
'variance_pct': self.variance_pct,
'account_id': self.account_id,
}
def resolve(
line_specs: list[dict],
*,
account_totals: dict[int, TotalLine],
accounts_by_id: dict[int, dict],
comparison_totals: dict[int, TotalLine] | None = None,
) -> list[dict]:
"""Resolve line_specs against actual account totals -> list of row dicts.
Args:
line_specs: report definition line specs (from fusion.report.line_specs).
account_totals: {account_id: TotalLine} for the period.
accounts_by_id: {account_id: {code, name, account_type, ...}}.
comparison_totals: optional {account_id: TotalLine} for comparison period.
Returns: list of row dicts."""
rows: list[ReportRow] = []
for idx, spec in enumerate(line_specs):
if spec.get('compute') == 'subtotal':
n = spec.get('above', 1)
sign = spec.get('sign', 1)
recent = [r.amount for r in rows[-n:] if not r.is_subtotal]
row = ReportRow(
id=f'line_{idx}',
label=spec.get('label', 'Subtotal'),
level=spec.get('level', 0),
is_subtotal=True,
amount=sum(recent) * sign,
)
if comparison_totals is not None:
comp_recent = [
r.amount_comparison
for r in rows[-n:]
if not r.is_subtotal and r.amount_comparison is not None
]
row.amount_comparison = (
sum(comp_recent) * sign if comp_recent else None
)
rows.append(row)
elif spec.get('account_type_prefix'):
prefix = spec['account_type_prefix']
sign = spec.get('sign', 1)
matched_ids = [
aid for aid, info in accounts_by_id.items()
if info.get('account_type', '').startswith(prefix)
]
amount = sum(
account_totals.get(aid, TotalLine()).balance * sign
for aid in matched_ids
)
row = ReportRow(
id=f'line_{idx}',
label=spec.get('label', prefix),
level=spec.get('level', 0),
amount=amount,
)
if comparison_totals is not None:
comp_amount = sum(
comparison_totals.get(aid, TotalLine()).balance * sign
for aid in matched_ids
)
row.amount_comparison = comp_amount
if comp_amount != 0:
row.variance_pct = (
(amount - comp_amount) / abs(comp_amount)
) * 100
rows.append(row)
elif spec.get('account_id'):
aid = spec['account_id']
sign = spec.get('sign', 1)
tot = account_totals.get(aid, TotalLine())
label = spec.get('label') or accounts_by_id.get(aid, {}).get(
'name', f'Account {aid}'
)
row = ReportRow(
id=f'line_{idx}',
label=label,
level=spec.get('level', 0),
amount=tot.balance * sign,
account_id=aid,
)
if comparison_totals is not None:
comp = comparison_totals.get(aid, TotalLine())
row.amount_comparison = comp.balance * sign
if row.amount_comparison and row.amount_comparison != 0:
row.variance_pct = (
(row.amount - row.amount_comparison)
/ abs(row.amount_comparison)
) * 100
rows.append(row)
return [r.to_dict() for r in rows]

View File

@@ -0,0 +1,49 @@
"""Move-line aggregation primitives for report totaling.
Pure-Python helpers - callers pass dicts with debit/credit/balance/currency keys,
no Odoo recordsets needed. Keeps the math testable without an ORM."""
from dataclasses import dataclass
@dataclass
class TotalLine:
debit: float = 0.0
credit: float = 0.0
balance: float = 0.0
debit_currency: float = 0.0
credit_currency: float = 0.0
balance_currency: float = 0.0
line_count: int = 0
def aggregate(move_lines: list[dict]) -> TotalLine:
"""Aggregate a list of move-line dicts into a TotalLine.
Each dict must have: debit, credit, balance (signed). Optional:
debit_currency, credit_currency, balance_currency."""
out = TotalLine()
for ml in move_lines:
out.debit += ml.get('debit', 0.0)
out.credit += ml.get('credit', 0.0)
out.balance += ml.get('balance', 0.0)
out.debit_currency += ml.get('debit_currency', 0.0)
out.credit_currency += ml.get('credit_currency', 0.0)
out.balance_currency += ml.get('balance_currency', 0.0)
out.line_count += 1
return out
def aggregate_per_account(move_lines: list[dict]) -> dict[int, TotalLine]:
"""Group + aggregate by account_id. Returns {account_id: TotalLine}."""
grouped: dict[int, list[dict]] = {}
for ml in move_lines:
acct = ml['account_id']
grouped.setdefault(acct, []).append(ml)
return {acct: aggregate(lines) for acct, lines in grouped.items()}
def is_balanced(move_lines: list[dict], *, tolerance: float = 0.005) -> bool:
"""True if total debits == total credits (within tolerance for rounding)."""
agg = aggregate(move_lines)
return abs(agg.debit - agg.credit) <= tolerance

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1,10 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class AiCommentaryPanel extends Component {
static template = "fusion_accounting_reports.AiCommentaryPanel";
static props = {
commentary: { type: Object },
};
}

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_reports.AiCommentaryPanel">
<div class="o_fusion_commentary_panel">
<h4>📊 AI Commentary</h4>
<div class="commentary-section" t-if="props.commentary.summary">
<p style="margin: 0;"><t t-esc="props.commentary.summary"/></p>
</div>
<div class="commentary-section" t-if="props.commentary.highlights and props.commentary.highlights.length">
<h5>Highlights</h5>
<ul>
<li t-foreach="props.commentary.highlights" t-as="h" t-key="h_index">
<t t-esc="h"/>
</li>
</ul>
</div>
<div class="commentary-section" t-if="props.commentary.concerns and props.commentary.concerns.length">
<h5>Concerns</h5>
<ul>
<li t-foreach="props.commentary.concerns" t-as="c" t-key="c_index">
<t t-esc="c"/>
</li>
</ul>
</div>
<div class="commentary-section" t-if="props.commentary.next_actions and props.commentary.next_actions.length">
<h5>Next Actions</h5>
<ul>
<li t-foreach="props.commentary.next_actions" t-as="a" t-key="a_index">
<t t-esc="a"/>
</li>
</ul>
</div>
<div class="text-muted" style="font-size: 0.75rem;" t-if="props.commentary.cached">
Cached • <t t-esc="props.commentary.generated_at"/>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,18 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class AnomalyStrip extends Component {
static template = "fusion_accounting_reports.AnomalyStrip";
static props = {
anomaly: { type: Object },
};
formatAmount(amount) {
if (amount === null || amount === undefined) return "";
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2, maximumFractionDigits: 2,
signDisplay: 'always',
}).format(amount);
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_reports.AnomalyStrip">
<div class="o_fusion_anomaly_strip" t-att-data-severity="props.anomaly.severity">
<strong><t t-esc="props.anomaly.label"/></strong>
<span class="ms-2">
<t t-esc="props.anomaly.direction === 'increase' ? '↑' : '↓'"/>
<t t-esc="props.anomaly.variance_pct.toFixed(1)"/>%
(<t t-esc="formatAmount(props.anomaly.variance_amount)"/>)
</span>
<span class="ms-3 text-muted">
severity: <t t-esc="props.anomaly.severity"/>
</span>
</div>
</t>
</templates>

View File

@@ -0,0 +1,24 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class DrillDownDialog extends Component {
static template = "fusion_accounting_reports.DrillDownDialog";
static props = {
drill: { type: Object },
onClose: { type: Function },
};
formatAmount(amount) {
if (amount === null || amount === undefined) return "";
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2, maximumFractionDigits: 2,
}).format(amount);
}
onBackdropClick(ev) {
if (ev.target.classList.contains('modal-backdrop')) {
this.props.onClose();
}
}
}

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_reports.DrillDownDialog">
<div class="modal modal-backdrop"
style="display: block; background: rgba(0,0,0,0.5); position: fixed; top:0; left:0; right:0; bottom:0; z-index: 1050;"
t-on-click="onBackdropClick">
<div class="modal-dialog modal-xl"
style="margin: 5vh auto; max-width: 90%;">
<div class="modal-content">
<div class="modal-header">
<h5>Drill-down: <t t-esc="props.drill.label || ''"/></h5>
<button class="btn-close" t-on-click="props.onClose">×</button>
</div>
<div class="modal-body" style="max-height: 70vh; overflow-y: auto;">
<div t-if="!props.drill.rows or props.drill.rows.length === 0" class="text-muted">
No journal items found.
</div>
<table t-else="" class="table table-sm">
<thead>
<tr>
<th>Date</th>
<th>Move</th>
<th>Account</th>
<th>Partner</th>
<th>Description</th>
<th class="text-end">Debit</th>
<th class="text-end">Credit</th>
<th class="text-end">Balance</th>
</tr>
</thead>
<tbody>
<tr t-foreach="props.drill.rows" t-as="row" t-key="row.move_line_id">
<td><t t-esc="row.date"/></td>
<td><t t-esc="row.move_name"/></td>
<td>
<span t-att-title="row.account_name">
<t t-esc="row.account_code"/>
</span>
</td>
<td><t t-esc="row.partner_name || ''"/></td>
<td><t t-esc="row.label"/></td>
<td class="text-end"><t t-esc="formatAmount(row.debit)"/></td>
<td class="text-end"><t t-esc="formatAmount(row.credit)"/></td>
<td class="text-end"><t t-esc="formatAmount(row.balance)"/></td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<span class="text-muted me-auto"><t t-esc="props.drill.count"/> rows</span>
<button class="btn_report" t-on-click="props.onClose">Close</button>
</div>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,37 @@
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class PeriodFilter extends Component {
static template = "fusion_accounting_reports.PeriodFilter";
static props = {};
setup() {
this.reports = useService("fusion_reports");
this.state = useState(this.reports.state);
}
async onReportTypeChange(ev) {
const reportType = ev.target.value;
if (reportType && this.state.dateFrom && this.state.dateTo) {
await this.reports.runReport(
reportType, this.state.dateFrom, this.state.dateTo,
this.state.comparison);
}
}
async onDateChange(field, ev) {
this.state[field] = ev.target.value;
if (this.state.currentReportType && this.state.dateFrom && this.state.dateTo) {
await this.reports.runReport(
this.state.currentReportType,
this.state.dateFrom, this.state.dateTo,
this.state.comparison);
}
}
async onComparisonChange(ev) {
await this.reports.setComparison(ev.target.value);
}
}

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_reports.PeriodFilter">
<div class="o_fusion_reports_filters">
<select t-on-change="onReportTypeChange"
class="form-select" style="max-width: 240px;">
<option value="">— Select report —</option>
<option t-foreach="state.availableReports" t-as="r" t-key="r.id"
t-att-value="r.report_type"
t-att-selected="r.report_type === state.currentReportType">
<t t-esc="r.name"/>
</option>
</select>
<label>From</label>
<input type="date" class="form-control" style="max-width: 160px;"
t-att-value="state.dateFrom || ''"
t-on-change="(ev) => onDateChange('dateFrom', ev)"/>
<label>To</label>
<input type="date" class="form-control" style="max-width: 160px;"
t-att-value="state.dateTo || ''"
t-on-change="(ev) => onDateChange('dateTo', ev)"/>
<label>Comparison</label>
<select class="form-select" style="max-width: 200px;"
t-on-change="onComparisonChange">
<option value="none" t-att-selected="state.comparison === 'none'">None</option>
<option value="previous_period"
t-att-selected="state.comparison === 'previous_period'">Previous Period</option>
<option value="previous_year"
t-att-selected="state.comparison === 'previous_year'">Previous Year</option>
</select>
<span t-if="state.isLoading" class="text-muted ms-3">Loading...</span>
</div>
</t>
</templates>

View File

@@ -0,0 +1,36 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class ReportTable extends Component {
static template = "fusion_accounting_reports.ReportTable";
static props = {
result: { type: Object },
onDrillDown: { type: Function, optional: true },
};
formatAmount(amount) {
if (amount === null || amount === undefined) return "";
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2, maximumFractionDigits: 2,
}).format(amount);
}
onRowClick(row) {
if (row.account_id && this.props.onDrillDown) {
this.props.onDrillDown(row.account_id, row.label);
}
}
rowClass(row) {
const classes = ['report-row', `level-${row.level || 0}`];
if (row.is_subtotal) classes.push('subtotal');
if (row.account_id) classes.push('drillable');
return classes.join(' ');
}
varianceClass(pct) {
if (pct === null || pct === undefined) return "";
return pct > 0 ? 'variance-pos' : pct < 0 ? 'variance-neg' : '';
}
}

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_reports.ReportTable">
<div class="o_fusion_reports_table">
<table>
<thead>
<tr>
<th>Line</th>
<th class="amount">Amount</th>
<t t-if="props.result.comparison_period">
<th class="amount">
<t t-esc="props.result.comparison_period.label"/>
</th>
<th class="amount">Variance %</th>
</t>
</tr>
</thead>
<tbody>
<tr t-foreach="props.result.rows" t-as="row" t-key="row.id"
t-att-class="rowClass(row)"
t-on-click="() => onRowClick(row)">
<td>
<span><t t-esc="row.label"/></span>
</td>
<td class="amount">
<t t-esc="formatAmount(row.amount)"/>
</td>
<t t-if="props.result.comparison_period">
<td class="amount">
<t t-esc="formatAmount(row.amount_comparison)"/>
</td>
<td class="amount" t-att-class="varianceClass(row.variance_pct)">
<t t-if="row.variance_pct !== null and row.variance_pct !== undefined">
<t t-esc="row.variance_pct.toFixed(1)"/>%
</t>
</td>
</t>
</tr>
</tbody>
</table>
</div>
</t>
</templates>

View File

@@ -0,0 +1,49 @@
// Fusion reports design tokens (extends Phase 1's bank_rec tokens for consistency).
// Colors — semantic
$report-bg-primary: #ffffff;
$report-bg-secondary: #f9fafb;
$report-bg-tertiary: #f3f4f6;
$report-border: #e5e7eb;
$report-text-primary: #111827;
$report-text-secondary: #6b7280;
$report-text-muted: #9ca3af;
$report-accent: #3b82f6;
$report-accent-bg: #eff6ff;
// Severity colors (mirrors bank_rec)
$report-severity-high: #ef4444;
$report-severity-high-bg: #fef2f2;
$report-severity-medium: #f59e0b;
$report-severity-medium-bg: #fffbeb;
$report-severity-low: #10b981;
$report-severity-low-bg: #ecfdf5;
// Variance indicators
$report-variance-positive: #10b981;
$report-variance-negative: #ef4444;
// Spacing
$report-space-1: 0.25rem;
$report-space-2: 0.5rem;
$report-space-3: 0.75rem;
$report-space-4: 1rem;
$report-space-5: 1.25rem;
$report-space-6: 1.5rem;
$report-space-8: 2rem;
// Typography
$report-font-size-xs: 0.75rem;
$report-font-size-sm: 0.875rem;
$report-font-size-base: 1rem;
$report-font-size-lg: 1.125rem;
$report-font-size-xl: 1.25rem;
$report-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
// Borders + radii
$report-border-radius: 0.375rem;
$report-border-radius-md: 0.5rem;
$report-border-radius-lg: 0.75rem;
// Subtotal indentation
$report-indent-per-level: 1.5rem;

View File

@@ -0,0 +1,34 @@
@import "variables";
[data-color-scheme="dark"] .o_fusion_reports {
background: #1f2937;
color: #f9fafb;
&_header, &_table, &_filters, .o_fusion_commentary_panel {
background: #111827;
border-color: #374151;
color: #f9fafb;
}
&_table {
th { background: #1f2937; color: #d1d5db; }
td { border-color: #374151; }
tr.subtotal { background: #1f2937; }
tr.drillable:hover { background: #1e3a8a; }
}
.btn_report {
background: #374151;
border-color: #4b5563;
color: #f9fafb;
&:hover { background: #4b5563; }
&.primary { background: #3b82f6; }
}
.o_fusion_anomaly_strip {
&[data-severity="high"] { background: rgba(239, 68, 68, 0.15); }
&[data-severity="medium"] { background: rgba(245, 158, 11, 0.15); }
&[data-severity="low"] { background: rgba(16, 185, 129, 0.15); }
}
}

View File

@@ -0,0 +1,161 @@
@import "variables";
.o_fusion_reports {
background: $report-bg-secondary;
min-height: 100vh;
&_header {
background: $report-bg-primary;
border-bottom: 1px solid $report-border;
padding: $report-space-4 $report-space-6;
display: flex;
justify-content: space-between;
align-items: center;
h1 {
font-size: $report-font-size-xl;
margin: 0;
}
}
&_table {
background: $report-bg-primary;
border: 1px solid $report-border;
border-radius: $report-border-radius-md;
margin: $report-space-4;
overflow: hidden;
font-family: $report-font-mono;
font-size: $report-font-size-sm;
table {
width: 100%;
border-collapse: collapse;
}
th {
background: $report-bg-tertiary;
padding: $report-space-3 $report-space-4;
text-align: left;
font-weight: 600;
color: $report-text-secondary;
border-bottom: 1px solid $report-border;
}
th.amount, td.amount {
text-align: right;
white-space: nowrap;
}
td {
padding: $report-space-2 $report-space-4;
border-bottom: 1px solid lighten($report-border, 5%);
}
tr.subtotal {
font-weight: 600;
background: $report-bg-secondary;
border-top: 1px solid $report-text-muted;
}
tr.subtotal td {
border-bottom: 1px solid $report-text-muted;
}
tr.drillable {
cursor: pointer;
&:hover { background: $report-accent-bg; }
}
.level-1 { padding-left: $report-space-4 + $report-indent-per-level; }
.level-2 { padding-left: $report-space-4 + $report-indent-per-level * 2; }
.level-3 { padding-left: $report-space-4 + $report-indent-per-level * 3; }
.variance-pos { color: $report-variance-positive; }
.variance-neg { color: $report-variance-negative; }
}
&_filters {
background: $report-bg-primary;
padding: $report-space-3 $report-space-4;
border-bottom: 1px solid $report-border;
display: flex;
gap: $report-space-3;
align-items: center;
flex-wrap: wrap;
}
.btn_report {
padding: $report-space-2 $report-space-4;
border-radius: $report-border-radius;
background: $report-bg-primary;
border: 1px solid $report-border;
color: $report-text-primary;
font-size: $report-font-size-sm;
cursor: pointer;
transition: all 150ms ease-in-out;
&:hover { background: $report-bg-tertiary; }
&.primary {
background: $report-accent;
border-color: $report-accent;
color: white;
&:hover { background: darken($report-accent, 8%); }
}
}
}
.o_fusion_anomaly_strip {
margin: $report-space-3;
padding: $report-space-3;
border-radius: $report-border-radius;
border: 1px solid;
font-size: $report-font-size-sm;
&[data-severity="high"] {
background: $report-severity-high-bg;
border-color: $report-severity-high;
}
&[data-severity="medium"] {
background: $report-severity-medium-bg;
border-color: $report-severity-medium;
}
&[data-severity="low"] {
background: $report-severity-low-bg;
border-color: $report-severity-low;
}
}
.o_fusion_commentary_panel {
background: $report-bg-primary;
border: 1px solid $report-border;
border-radius: $report-border-radius-md;
margin: $report-space-3;
padding: $report-space-4;
h4 {
margin: 0 0 $report-space-3;
font-size: $report-font-size-base;
color: $report-text-primary;
}
.commentary-section {
margin-bottom: $report-space-3;
h5 {
font-size: $report-font-size-sm;
color: $report-text-secondary;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: $report-space-2;
}
ul {
margin: 0;
padding-left: $report-space-4;
li { margin: $report-space-1 0; }
}
}
}

View File

@@ -0,0 +1,147 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { reactive } from "@odoo/owl";
const ENDPOINT_BASE = "/fusion/reports";
export class ReportsService {
constructor(env, services) {
this.env = env;
this.rpc = services.rpc;
this.notification = services.notification;
this.state = reactive({
availableReports: [],
currentReportType: null,
currentResult: null,
currentAnomalies: [],
currentCommentary: null,
isLoading: false,
isGeneratingCommentary: false,
dateFrom: null,
dateTo: null,
comparison: 'none',
companyId: null,
drillDown: null,
});
}
async loadAvailableReports(companyId = null) {
this.state.companyId = companyId;
this.state.isLoading = true;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/list_available`,
{ company_id: companyId });
this.state.availableReports = result.reports;
} finally {
this.state.isLoading = false;
}
}
async runReport(reportType, dateFrom, dateTo, comparison = 'none') {
this.state.isLoading = true;
this.state.currentReportType = reportType;
this.state.dateFrom = dateFrom;
this.state.dateTo = dateTo;
this.state.comparison = comparison;
try {
this.state.currentResult = await this.rpc(`${ENDPOINT_BASE}/run`, {
report_type: reportType,
date_from: dateFrom,
date_to: dateTo,
comparison: comparison,
company_id: this.state.companyId,
});
if (comparison && comparison !== 'none') {
this.fetchAnomalies();
} else {
this.state.currentAnomalies = [];
}
this.state.currentCommentary = null;
return this.state.currentResult;
} catch (err) {
this.notification.add(`Run failed: ${err.message || err}`, { type: 'danger' });
throw err;
} finally {
this.state.isLoading = false;
}
}
async fetchAnomalies() {
if (!this.state.currentReportType) return;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/get_anomalies`, {
report_type: this.state.currentReportType,
date_from: this.state.dateFrom,
date_to: this.state.dateTo,
comparison: this.state.comparison,
company_id: this.state.companyId,
});
this.state.currentAnomalies = result.anomalies || [];
} catch (err) {
this.state.currentAnomalies = [];
}
}
async generateCommentary({ forceRegenerate = false } = {}) {
if (!this.state.currentReportType) return;
this.state.isGeneratingCommentary = true;
try {
this.state.currentCommentary = await this.rpc(`${ENDPOINT_BASE}/get_commentary`, {
report_type: this.state.currentReportType,
date_from: this.state.dateFrom,
date_to: this.state.dateTo,
comparison: this.state.comparison,
company_id: this.state.companyId,
force_regenerate: forceRegenerate,
});
return this.state.currentCommentary;
} catch (err) {
this.notification.add(`Commentary failed: ${err.message || err}`, { type: 'danger' });
throw err;
} finally {
this.state.isGeneratingCommentary = false;
}
}
async drillDown(accountId, label = null) {
try {
const result = await this.rpc(`${ENDPOINT_BASE}/drill_down`, {
account_id: accountId,
date_from: this.state.dateFrom,
date_to: this.state.dateTo,
company_id: this.state.companyId,
});
this.state.drillDown = {
accountId, label, rows: result.rows || [],
count: result.count, isOpen: true,
};
return result;
} catch (err) {
this.notification.add(`Drill failed: ${err.message || err}`, { type: 'danger' });
throw err;
}
}
closeDrillDown() {
if (this.state.drillDown) {
this.state.drillDown.isOpen = false;
}
}
setComparison(mode) {
this.state.comparison = mode;
if (this.state.currentReportType) {
return this.runReport(this.state.currentReportType,
this.state.dateFrom, this.state.dateTo, mode);
}
}
}
export const reportsService = {
dependencies: ["rpc", "notification"],
start(env, services) { return new ReportsService(env, services); },
};
registry.category("services").add("fusion_reports", reportsService);

View 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" },
],
});

View File

@@ -0,0 +1,47 @@
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { ReportTable } from "../../components/report_table/report_table";
import { PeriodFilter } from "../../components/period_filter/period_filter";
import { DrillDownDialog } from "../../components/drill_down_dialog/drill_down_dialog";
import { AiCommentaryPanel } from "../../components/ai_commentary_panel/ai_commentary_panel";
import { AnomalyStrip } from "../../components/anomaly_strip/anomaly_strip";
export class ReportViewer extends Component {
static template = "fusion_accounting_reports.ReportViewer";
static props = { "*": true };
static components = {
ReportTable, PeriodFilter, DrillDownDialog,
AiCommentaryPanel, AnomalyStrip,
};
setup() {
this.reports = useService("fusion_reports");
this.state = useState(this.reports.state);
const ctx = this.props.action?.context || {};
const reportType = ctx.default_report_type || 'pnl';
const companyId = this.env.services.user?.context?.allowed_company_ids?.[0];
onWillStart(async () => {
await this.reports.loadAvailableReports(companyId);
const today = new Date();
const year = today.getFullYear();
await this.reports.runReport(
reportType, `${year}-01-01`, `${year}-12-31`, 'none');
});
}
onDrillDown(accountId, label) {
this.reports.drillDown(accountId, label);
}
onCloseDrill() {
this.reports.closeDrillDown();
}
async onGenerateCommentary() {
await this.reports.generateCommentary({ forceRegenerate: false });
}
}

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_reports.ReportViewer">
<div class="o_fusion_reports">
<div class="o_fusion_reports_header">
<div>
<h1>
<t t-esc="state.currentResult?.report_name || 'Financial Reports'"/>
</h1>
<div class="text-muted" t-if="state.currentResult">
<t t-esc="state.currentResult.period?.label"/>
</div>
</div>
<div>
<button class="btn_report primary"
t-on-click="onGenerateCommentary"
t-att-disabled="state.isGeneratingCommentary">
<t t-if="state.isGeneratingCommentary">Generating...</t>
<t t-else="">AI Commentary</t>
</button>
</div>
</div>
<PeriodFilter />
<AnomalyStrip t-foreach="state.currentAnomalies" t-as="anomaly"
t-key="anomaly.row_id" anomaly="anomaly"/>
<AiCommentaryPanel t-if="state.currentCommentary" commentary="state.currentCommentary"/>
<ReportTable t-if="state.currentResult" result="state.currentResult"
onDrillDown="onDrillDown.bind(this)"/>
<DrillDownDialog t-if="state.drillDown and state.drillDown.isOpen"
drill="state.drillDown"
onClose="onCloseDrill.bind(this)"/>
</div>
</t>
</templates>

View File

@@ -0,0 +1,14 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { ReportViewer } from "./report_viewer";
export const fusionReportsView = {
type: "fusion_reports",
Controller: ReportViewer,
display_name: "Fusion Financial Reports",
icon: "fa-line-chart",
multiRecord: true,
};
registry.category("views").add("fusion_reports", fusionReportsView);

View File

@@ -0,0 +1,28 @@
from . import test_services_unit
from . import test_currency_conversion
from . import test_fusion_report
from . import test_line_resolver
from . import test_drill_down_resolver
from . import test_fusion_report_engine
from . import test_seeded_reports
from . import test_anomaly_detection
from . import test_commentary_prompt
from . import test_commentary_generator
from . import test_fusion_report_commentary
from . import test_fusion_report_anomaly
from . import test_reports_controller
from . import test_reports_adapter
from . import test_fusion_report_tools
from . import test_engine_property
from . import test_pnl_integration
from . import test_bs_tb_integration
from . import test_account_balance_mv
from . import test_cron
from . import test_pdf_export
from . import test_xlsx_export
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
from . import test_local_llm_compat

View File

@@ -0,0 +1,20 @@
"""Tests for fusion_account_balance MV."""
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestAccountBalanceMV(TransactionCase):
def test_mv_exists_and_is_queryable(self):
# Force initial refresh, then make sure the model can read it.
self.env['fusion.account.balance.mv']._refresh(concurrently=False)
rows = self.env['fusion.account.balance.mv'].search([], limit=5)
self.assertIsNotNone(rows)
def test_mv_refresh_concurrent(self):
# Try concurrent refresh; should either succeed or fall back gracefully.
try:
self.env['fusion.account.balance.mv']._refresh(concurrently=True)
except Exception as e:
self.fail(f"MV refresh raised: {e}")

View File

@@ -0,0 +1,74 @@
"""Unit tests for anomaly_detection service."""
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_reports.services.anomaly_detection import detect
@tagged('post_install', '-at_install')
class TestAnomalyDetection(TransactionCase):
def test_returns_empty_when_no_comparison(self):
report_result = {
'rows': [{'id': 'r1', 'label': 'Test', 'amount': 100,
'amount_comparison': None, 'variance_pct': None}],
'comparison_period': None,
}
self.assertEqual(detect(report_result), [])
def test_flags_significant_increase(self):
report_result = {
'rows': [{'id': 'r1', 'label': 'Revenue',
'amount': 12000, 'amount_comparison': 10000,
'variance_pct': 20.0}],
'comparison_period': {'date_from': '2025-01-01'},
}
anomalies = detect(report_result)
self.assertEqual(len(anomalies), 1)
self.assertEqual(anomalies[0]['direction'], 'increase')
self.assertEqual(anomalies[0]['variance_amount'], 2000)
def test_skips_below_absolute_threshold(self):
report_result = {
'rows': [{'id': 'r1', 'label': 'Tiny', 'amount': 50,
'amount_comparison': 30, 'variance_pct': 67}],
'comparison_period': {'date_from': '2025-01-01'},
}
# variance is $20 < default $100 minimum
self.assertEqual(detect(report_result), [])
def test_skips_below_pct_threshold(self):
report_result = {
'rows': [{'id': 'r1', 'label': 'Steady',
'amount': 10500, 'amount_comparison': 10000,
'variance_pct': 5.0}],
'comparison_period': {'date_from': '2025-01-01'},
}
# 5% < default 10%
self.assertEqual(detect(report_result), [])
def test_severity_high_for_50pct_plus(self):
report_result = {
'rows': [{'id': 'r1', 'label': 'Spike',
'amount': 16000, 'amount_comparison': 10000,
'variance_pct': 60.0}],
'comparison_period': {'date_from': '2025-01-01'},
}
anomalies = detect(report_result)
self.assertEqual(anomalies[0]['severity'], 'high')
def test_orders_by_severity_then_amount(self):
report_result = {
'rows': [
{'id': 'r1', 'label': 'Med', 'amount': 1300,
'amount_comparison': 1000, 'variance_pct': 30.0},
{'id': 'r2', 'label': 'High', 'amount': 16000,
'amount_comparison': 10000, 'variance_pct': 60.0},
{'id': 'r3', 'label': 'Low', 'amount': 1150,
'amount_comparison': 1000, 'variance_pct': 15.0},
],
'comparison_period': {'date_from': '2025-01-01'},
}
anomalies = detect(report_result)
# Should be: High first, then Med, then Low
self.assertEqual(anomalies[0]['severity'], 'high')
self.assertEqual(anomalies[-1]['severity'], 'low')

View File

@@ -0,0 +1,54 @@
"""Integration tests for balance sheet + trial balance."""
from datetime import date
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_reports.services.date_periods import Period
@tagged('post_install', '-at_install', 'integration')
class TestBalanceSheetIntegration(TransactionCase):
def test_balance_sheet_includes_total_assets(self):
result = self.env['fusion.report.engine'].compute_balance_sheet(
date(2026, 12, 31), company_id=self.env.company.id)
labels = [r['label'] for r in result['rows']]
self.assertIn('TOTAL ASSETS', labels)
self.assertIn('TOTAL LIABILITIES', labels)
self.assertIn('TOTAL EQUITY', labels)
def test_balance_sheet_total_assets_is_subtotal(self):
result = self.env['fusion.report.engine'].compute_balance_sheet(
date(2026, 12, 31), company_id=self.env.company.id)
ta = next(
(r for r in result['rows'] if r['label'] == 'TOTAL ASSETS'),
None,
)
self.assertIsNotNone(ta)
self.assertTrue(ta['is_subtotal'])
def test_balance_sheet_returns_period(self):
result = self.env['fusion.report.engine'].compute_balance_sheet(
date(2026, 4, 19), company_id=self.env.company.id)
self.assertEqual(result['period']['date_to'], '2026-04-19')
@tagged('post_install', '-at_install', 'integration')
class TestTrialBalanceIntegration(TransactionCase):
def test_trial_balance_returns_all_5_groups(self):
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test 2026')
result = self.env['fusion.report.engine'].compute_trial_balance(
period, company_id=self.env.company.id)
labels = [r['label'] for r in result['rows']]
for label in ('Assets', 'Liabilities', 'Equity', 'Income', 'Expenses'):
self.assertIn(label, labels)
def test_trial_balance_has_total_subtotal(self):
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test 2026')
result = self.env['fusion.report.engine'].compute_trial_balance(
period, company_id=self.env.company.id)
last = result['rows'][-1]
self.assertEqual(last['label'], 'Total (should be 0)')
self.assertTrue(last['is_subtotal'])

View File

@@ -0,0 +1,39 @@
"""Coexistence tests for fusion_accounting_reports.
Mirrors Phase 1's coexistence test pattern: verifies the menu requires
the coexistence group, and the engine model is always available."""
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestReportsCoexistence(TransactionCase):
def setUp(self):
super().setUp()
self.coex_group = self.env.ref(
'fusion_accounting_core.group_fusion_show_when_enterprise_absent',
raise_if_not_found=False,
)
self.assertIsNotNone(self.coex_group, "Coexistence group must exist")
def test_engine_always_available(self):
"""The engine is registered regardless of Enterprise install state."""
self.assertIn('fusion.report.engine', self.env.registry)
def test_menu_gated_by_coexistence_group(self):
menu = self.env.ref('fusion_accounting_reports.menu_fusion_reports_root',
raise_if_not_found=False)
if not menu:
self.skipTest("Menu not loaded")
menu_groups = getattr(menu, 'group_ids', None) or menu.groups_id
self.assertIn(self.coex_group, menu_groups,
"Reports root menu must require the coexistence group")
def test_period_picker_wizard_gated_too(self):
menu = self.env.ref('fusion_accounting_reports.menu_fusion_reports_open',
raise_if_not_found=False)
if not menu:
self.skipTest("Menu not loaded")
menu_groups = getattr(menu, 'group_ids', None) or menu.groups_id
self.assertIn(self.coex_group, menu_groups)

View File

@@ -0,0 +1,54 @@
"""Tests for commentary_generator service."""
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_reports.services.commentary_generator import (
generate_commentary, _templated_fallback,
)
@tagged('post_install', '-at_install')
class TestCommentaryGenerator(TransactionCase):
def setUp(self):
super().setUp()
# Ensure no provider is configured so we exercise the fallback path
self.env['ir.config_parameter'].sudo().search([
('key', 'in', ['fusion_accounting.provider.reports_commentary',
'fusion_accounting.provider.default'])
]).unlink()
def test_fallback_when_no_provider(self):
report = {
'report_name': 'P&L',
'period': {'label': 'Apr 2026'},
'rows': [
{'id': 'r1', 'label': 'Revenue', 'amount': 100000, 'is_subtotal': False},
{'id': 'r2', 'label': 'Net Income', 'amount': 25000, 'is_subtotal': True},
],
}
result = generate_commentary(self.env, report_result=report)
self.assertIn('summary', result)
self.assertIn('Net Income', result['summary'])
self.assertIn('25,000', result['summary'])
def test_fallback_includes_anomalies_in_concerns(self):
report = {
'report_name': 'P&L',
'period': {'label': 'Apr 2026'},
'rows': [],
}
anomalies = [
{'label': 'Revenue', 'direction': 'increase', 'variance_pct': 30.0,
'variance_amount': 5000, 'severity': 'medium'},
]
result = generate_commentary(self.env, report_result=report, anomalies=anomalies)
self.assertEqual(len(result['concerns']), 1)
self.assertIn('Revenue', result['concerns'][0])
self.assertIn('30.0%', result['concerns'][0])
self.assertGreater(len(result['next_actions']), 0)
def test_returns_dict_with_required_keys(self):
report = {'report_name': 'Test', 'period': {'label': 'X'}, 'rows': []}
result = generate_commentary(self.env, report_result=report)
for key in ('summary', 'highlights', 'concerns', 'next_actions'):
self.assertIn(key, result)

View File

@@ -0,0 +1,50 @@
"""Tests for commentary_prompt module."""
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_reports.services.commentary_prompt import (
SYSTEM_PROMPT, build_prompt,
)
@tagged('post_install', '-at_install')
class TestCommentaryPrompt(TransactionCase):
def test_system_prompt_requires_json(self):
self.assertIn('JSON', SYSTEM_PROMPT)
self.assertIn('"summary"', SYSTEM_PROMPT)
self.assertIn('"highlights"', SYSTEM_PROMPT)
def test_build_prompt_returns_tuple(self):
report = {'report_name': 'P&L', 'period': {'label': 'Apr 2026',
'date_from': '2026-04-01',
'date_to': '2026-04-30'},
'rows': []}
result = build_prompt(report, [])
self.assertEqual(len(result), 2)
self.assertIn('REPORT', result[1])
self.assertIn('Apr 2026', result[1])
def test_user_prompt_includes_rows(self):
report = {
'report_name': 'P&L',
'period': {'label': 'X', 'date_from': 'a', 'date_to': 'b'},
'rows': [
{'id': 'r1', 'label': 'Revenue', 'amount': 100000.50},
{'id': 'r2', 'label': 'Net Income', 'amount': 25000, 'is_subtotal': True},
],
}
_, user = build_prompt(report, [])
self.assertIn('Revenue', user)
self.assertIn('100,000.50', user)
self.assertIn('SUBTOTAL', user)
def test_user_prompt_includes_anomalies(self):
report = {'report_name': 'X', 'period': {'label': 'X', 'date_from': '', 'date_to': ''}, 'rows': []}
anomalies = [
{'label': 'Revenue', 'direction': 'increase', 'variance_pct': 25.0,
'variance_amount': 5000, 'severity': 'medium'},
]
_, user = build_prompt(report, anomalies)
self.assertIn('ANOMALIES', user)
self.assertIn('Revenue', user)
self.assertIn('25.0%', user)

View File

@@ -0,0 +1,20 @@
"""Tests for cron handlers."""
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestFusionReportsCron(TransactionCase):
def setUp(self):
super().setUp()
self.cron = self.env['fusion.reports.cron']
def test_cron_mv_refresh_does_not_raise(self):
# Smoke test: the cron must complete without raising even if the
# CONCURRENTLY path fails on a cold MV (the handler falls back).
self.cron._cron_mv_refresh()
def test_cron_anomaly_scan_does_not_raise(self):
# Smoke test: scan all companies, persist anomalies, no exceptions.
self.cron._cron_anomaly_scan()

View File

@@ -0,0 +1,53 @@
"""Unit tests for currency_conversion service."""
from datetime import date
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_reports.services.currency_conversion import (
convert_amount, fetch_rates,
)
@tagged('post_install', '-at_install')
class TestCurrencyConversion(TransactionCase):
def test_same_currency_returns_unchanged(self):
result = convert_amount(100, source_currency='USD',
target_currency='USD',
rate_date=date(2026, 4, 19), rates={})
self.assertEqual(result, 100)
def test_direct_rate(self):
rates = {('USD', 'CAD', date(2026, 4, 19)): 1.35}
result = convert_amount(100, source_currency='USD',
target_currency='CAD',
rate_date=date(2026, 4, 19), rates=rates)
self.assertEqual(result, 135)
def test_inverse_rate(self):
rates = {('CAD', 'USD', date(2026, 4, 19)): 0.74}
result = convert_amount(100, source_currency='USD',
target_currency='CAD',
rate_date=date(2026, 4, 19), rates=rates)
self.assertAlmostEqual(result, 100 / 0.74, places=2)
def test_falls_back_to_most_recent_rate(self):
rates = {
('USD', 'CAD', date(2026, 1, 1)): 1.30,
('USD', 'CAD', date(2026, 3, 1)): 1.32,
}
result = convert_amount(100, source_currency='USD',
target_currency='CAD',
rate_date=date(2026, 4, 19), rates=rates)
self.assertEqual(result, 132)
def test_raises_when_no_rate(self):
with self.assertRaises(ValueError):
convert_amount(100, source_currency='EUR',
target_currency='CAD',
rate_date=date(2026, 4, 19), rates={})
def test_fetch_rates_from_env(self):
cad = self.env.ref('base.CAD')
rates = fetch_rates(self.env, target_currency_id=cad.id, as_of=date(2026, 4, 19))
self.assertIsInstance(rates, dict)

View File

@@ -0,0 +1,60 @@
"""Tests for drill_down_resolver."""
from datetime import date, timedelta
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_reports.services.drill_down_resolver import (
fetch_drill_down,
)
@tagged('post_install', '-at_install')
class TestDrillDownResolver(TransactionCase):
def test_returns_empty_for_account_with_no_lines(self):
account = self.env['account.account'].search([
('company_ids', 'in', self.env.company.id),
], limit=1)
if not account:
self.skipTest("No accounts in DB")
rows = fetch_drill_down(
self.env,
account_id=account.id,
date_from=date(2099, 1, 1),
date_to=date(2099, 12, 31),
company_id=self.env.company.id,
)
self.assertEqual(rows, [])
def test_returns_lines_for_account_with_data(self):
line = self.env['account.move.line'].search([
('parent_state', '=', 'posted'),
], limit=1)
if not line:
self.skipTest("No posted move lines in DB")
rows = fetch_drill_down(
self.env,
account_id=line.account_id.id,
date_from=line.date - timedelta(days=1),
date_to=line.date + timedelta(days=1),
company_id=line.company_id.id,
)
self.assertGreater(len(rows), 0)
ids = [r['move_line_id'] for r in rows]
self.assertIn(line.id, ids)
def test_respects_limit(self):
line = self.env['account.move.line'].search([
('parent_state', '=', 'posted'),
], limit=1)
if not line:
self.skipTest("No posted move lines in DB")
rows = fetch_drill_down(
self.env,
account_id=line.account_id.id,
date_from=date(2000, 1, 1),
date_to=date(2099, 12, 31),
company_id=line.company_id.id,
limit=2,
)
self.assertLessEqual(len(rows), 2)

View File

@@ -0,0 +1,156 @@
"""Property-based invariant tests for the reports engine.
Hypothesis generates random scenarios; we assert mathematical invariants
that must hold regardless of input."""
from datetime import date, timedelta
from hypothesis import HealthCheck, given, settings, strategies as st
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_reports.services.date_periods import (
Period,
comparison_period,
fiscal_year_bounds,
month_bounds,
quarter_bounds,
)
from odoo.addons.fusion_accounting_reports.services.line_resolver import resolve
from odoo.addons.fusion_accounting_reports.services.totaling import (
TotalLine,
aggregate,
is_balanced,
)
@tagged('post_install', '-at_install', 'property_based')
class TestServiceInvariants(TransactionCase):
"""Pure-Python invariants - fast, no DB writes."""
@given(d=st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31)))
@settings(max_examples=100, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_fiscal_year_contains_reference_date(self, d):
period = fiscal_year_bounds(d)
self.assertLessEqual(period.date_from, d)
self.assertGreaterEqual(period.date_to, d)
@given(d=st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31)))
@settings(max_examples=50, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_month_bounds_first_to_last_day(self, d):
period = month_bounds(d)
self.assertEqual(period.date_from.day, 1)
# Last day of month: adding 1 day rolls into the next month
next_day = period.date_to + timedelta(days=1)
self.assertNotEqual(next_day.month, period.date_to.month)
@given(d=st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31)))
@settings(max_examples=50, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_quarter_bounds_three_months(self, d):
period = quarter_bounds(d)
# Quarter starts on month 1, 4, 7, or 10 and is exactly 3 months
self.assertIn(period.date_from.month, (1, 4, 7, 10))
self.assertEqual(period.date_from.day, 1)
self.assertGreaterEqual(period.date_to, d)
self.assertLessEqual(period.date_from, d)
@given(
debits=st.lists(
st.floats(min_value=0, max_value=10000,
allow_nan=False, allow_infinity=False),
min_size=1, max_size=20,
),
)
@settings(max_examples=50, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_aggregate_sum_equals_input_sum(self, debits):
lines = [
{'debit': d, 'credit': 0, 'balance': d, 'account_id': 1}
for d in debits
]
result = aggregate(lines)
self.assertAlmostEqual(result.debit, sum(debits), places=2)
self.assertEqual(result.line_count, len(lines))
@given(
amounts=st.lists(
st.floats(min_value=1.0, max_value=100000,
allow_nan=False, allow_infinity=False),
min_size=4, max_size=10,
),
)
@settings(max_examples=50, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_balanced_iff_debits_equal_credits(self, amounts):
# Build a perfectly balanced ledger: half debits, half credits scaled
# so the totals match exactly.
half = len(amounts) // 2
debits = amounts[:half]
credits = amounts[half:half * 2]
if not credits or sum(credits) == 0:
return
scale = sum(debits) / sum(credits)
scaled_credits = [c * scale for c in credits]
lines = [{'debit': d, 'credit': 0, 'balance': d} for d in debits]
lines += [
{'debit': 0, 'credit': c, 'balance': -c} for c in scaled_credits
]
# Allow a generous tolerance to account for float scaling drift on
# extreme inputs; the invariant we care about is still that balanced
# books read as balanced.
self.assertTrue(is_balanced(lines, tolerance=1.0))
@given(
period_from=st.dates(min_value=date(2021, 1, 1),
max_value=date(2026, 1, 1)),
)
@settings(max_examples=30, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_comparison_previous_year_is_one_year_earlier(self, period_from):
# Build a 30-day period to keep things simple
period_to = period_from + timedelta(days=30)
period = Period(period_from, period_to, 'test')
comp = comparison_period(period, 'previous_year')
self.assertIsNotNone(comp)
self.assertEqual(comp.date_from.year, period.date_from.year - 1)
self.assertEqual(comp.date_to.year, period.date_to.year - 1)
@tagged('post_install', '-at_install', 'property_based')
class TestLineResolverInvariants(TransactionCase):
"""Invariants on the line_resolver."""
@given(
n_accounts=st.integers(min_value=1, max_value=20),
balance=st.floats(min_value=-10000, max_value=10000,
allow_nan=False, allow_infinity=False),
)
@settings(max_examples=30, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_subtotal_equals_sum_of_above_rows(self, n_accounts, balance):
accounts_by_id = {
i: {'code': f'{i:04d}', 'name': f'Acct {i}',
'account_type': 'asset_cash'}
for i in range(n_accounts)
}
account_totals = {
i: TotalLine(balance=balance) for i in range(n_accounts)
}
line_specs = [
{'label': f'Acct {i}', 'account_id': i, 'sign': 1}
for i in range(n_accounts)
]
line_specs.append({
'label': 'Subtotal', 'compute': 'subtotal',
'above': n_accounts, 'sign': 1,
})
rows = resolve(line_specs, account_totals=account_totals,
accounts_by_id=accounts_by_id)
subtotal = rows[-1]
non_subtotals = [r for r in rows[:-1] if not r.get('is_subtotal')]
expected = sum(r['amount'] for r in non_subtotals)
self.assertAlmostEqual(subtotal['amount'], expected, places=2)

View File

@@ -0,0 +1,44 @@
"""Tests for fusion.report definition model."""
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestFusionReport(TransactionCase):
def test_create_minimal(self):
report = self.env['fusion.report'].create({
'name': 'Test P&L',
'code': 'test_pnl_minimal',
'report_type': 'pnl',
})
self.assertEqual(report.name, 'Test P&L')
self.assertTrue(report.active)
self.assertEqual(report.default_comparison_mode, 'none')
def test_line_specs_json_roundtrip(self):
specs = [
{'label': 'Revenue', 'account_type_prefix': 'income_', 'sign': 1},
{'label': 'COGS', 'account_type_prefix': 'expense_direct_', 'sign': -1},
]
report = self.env['fusion.report'].create({
'name': 'Test',
'code': 'test_json_roundtrip',
'report_type': 'pnl',
'line_specs': specs,
})
self.assertEqual(report.line_specs, specs)
self.assertEqual(report.line_specs[0]['label'], 'Revenue')
def test_company_code_uniqueness(self):
self.env['fusion.report'].create({
'name': 'A',
'code': 'dup_code_test',
'report_type': 'pnl',
})
with self.assertRaises(Exception):
self.env['fusion.report'].create({
'name': 'B',
'code': 'dup_code_test',
'report_type': 'pnl',
})

View File

@@ -0,0 +1,52 @@
"""Tests for fusion.report.anomaly model."""
from datetime import date
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestFusionReportAnomaly(TransactionCase):
def setUp(self):
super().setUp()
self.report = self.env.ref('fusion_accounting_reports.report_pnl')
def _make(self, **vals):
defaults = {
'report_id': self.report.id,
'period_from': date(2026, 4, 1),
'period_to': date(2026, 4, 30),
'row_id': 'line_0',
'label': 'Revenue',
'current_amount': 12000,
'comparison_amount': 10000,
'variance_amount': 2000,
'variance_pct': 20.0,
'severity': 'medium',
'direction': 'increase',
}
defaults.update(vals)
return self.env['fusion.report.anomaly'].create(defaults)
def test_create_basic(self):
a = self._make()
self.assertEqual(a.severity, 'medium')
self.assertEqual(a.state, 'new')
self.assertTrue(a.detected_at)
def test_acknowledge_action(self):
a = self._make()
a.action_acknowledge()
self.assertEqual(a.state, 'acknowledged')
self.assertEqual(a.acknowledged_by, self.env.user)
self.assertTrue(a.acknowledged_at)
def test_dismiss_action(self):
a = self._make()
a.action_dismiss()
self.assertEqual(a.state, 'dismissed')
def test_resolve_action(self):
a = self._make()
a.action_resolve()
self.assertEqual(a.state, 'resolved')

View File

@@ -0,0 +1,53 @@
"""Tests for fusion.report.commentary cache model."""
from datetime import date
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestFusionReportCommentary(TransactionCase):
def setUp(self):
super().setUp()
self.report = self.env.ref('fusion_accounting_reports.report_pnl')
def test_create_minimal(self):
c = self.env['fusion.report.commentary'].create({
'report_id': self.report.id,
'period_from': date(2026, 4, 1),
'period_to': date(2026, 4, 30),
'summary': 'Test summary.',
'highlights': ['point 1', 'point 2'],
})
self.assertEqual(c.summary, 'Test summary.')
self.assertEqual(c.highlights, ['point 1', 'point 2'])
self.assertEqual(c.generated_by, 'on_demand')
def test_uniqueness_per_period(self):
self.env['fusion.report.commentary'].create({
'report_id': self.report.id,
'period_from': date(2026, 4, 1),
'period_to': date(2026, 4, 30),
'comparison_mode': 'none',
})
with self.assertRaises(Exception):
self.env['fusion.report.commentary'].create({
'report_id': self.report.id,
'period_from': date(2026, 4, 1),
'period_to': date(2026, 4, 30),
'comparison_mode': 'none',
})
def test_different_comparison_modes_can_coexist(self):
for mode in ['none', 'previous_period', 'previous_year']:
self.env['fusion.report.commentary'].create({
'report_id': self.report.id,
'period_from': date(2026, 5, 1),
'period_to': date(2026, 5, 31),
'comparison_mode': mode,
})
count = self.env['fusion.report.commentary'].search_count([
('report_id', '=', self.report.id),
('period_from', '=', date(2026, 5, 1)),
])
self.assertEqual(count, 3)

View File

@@ -0,0 +1,109 @@
"""Tests for fusion.report.engine AbstractModel."""
from datetime import date
from odoo.exceptions import ValidationError
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_reports.services.date_periods import Period
@tagged('post_install', '-at_install')
class TestFusionReportEngine(TransactionCase):
def setUp(self):
super().setUp()
self.pnl_report = self.env['fusion.report'].create({
'name': 'Test P&L Engine',
'code': 'test_pnl_engine',
'report_type': 'pnl',
'line_specs': [
{'label': 'Revenue', 'account_type_prefix': 'income_', 'sign': 1},
{'label': 'Expenses', 'account_type_prefix': 'expense_', 'sign': -1},
{'label': 'Net Profit', 'compute': 'subtotal', 'above': 2},
],
'company_id': self.env.company.id,
})
def test_engine_model_exists(self):
self.assertIn('fusion.report.engine', self.env.registry)
def test_compute_pnl_returns_dict_with_rows(self):
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test 2026')
result = self.env['fusion.report.engine'].compute_pnl(
period, company_id=self.env.company.id,
)
self.assertIn('rows', result)
self.assertIn('report_type', result)
self.assertEqual(result['report_type'], 'pnl')
def test_compute_balance_sheet(self):
self.env['fusion.report'].create({
'name': 'Test BS',
'code': 'test_bs_engine',
'report_type': 'balance_sheet',
'line_specs': [
{'label': 'Assets', 'account_type_prefix': 'asset_', 'sign': 1},
],
'company_id': self.env.company.id,
})
result = self.env['fusion.report.engine'].compute_balance_sheet(
date(2026, 4, 19), company_id=self.env.company.id,
)
self.assertEqual(result['report_type'], 'balance_sheet')
self.assertEqual(result['period']['date_to'], '2026-04-19')
def test_compute_trial_balance(self):
self.env['fusion.report'].create({
'name': 'Test TB',
'code': 'test_tb_engine',
'report_type': 'trial_balance',
'line_specs': [],
'company_id': self.env.company.id,
})
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test 2026')
result = self.env['fusion.report.engine'].compute_trial_balance(
period, company_id=self.env.company.id,
)
self.assertEqual(result['report_type'], 'trial_balance')
def test_compute_pnl_with_comparison(self):
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test 2026')
result = self.env['fusion.report.engine'].compute_pnl(
period,
comparison='previous_year',
company_id=self.env.company.id,
)
self.assertIsNotNone(result.get('comparison_period'))
self.assertEqual(result['comparison_period']['date_to'], '2025-12-31')
def test_drill_down_returns_list(self):
line = self.env['account.move.line'].search([
('parent_state', '=', 'posted'),
], limit=1)
if not line:
self.skipTest("No posted lines in DB")
period = Period(line.date, line.date, 'Single day')
rows = self.env['fusion.report.engine'].drill_down(
account_id=line.account_id.id,
period=period,
company_id=line.company_id.id,
)
self.assertIsInstance(rows, list)
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
# fails for this test, then restore them after.
existing = self.env['fusion.report'].search(
[('report_type', '=', 'general_ledger')]
)
prior_active = {r.id: r.active for r in existing}
existing.write({'active': False})
try:
with self.assertRaises(ValidationError):
self.env['fusion.report.engine'].compute_gl(
period, company_id=self.env.company.id,
)
finally:
for r in existing:
r.active = prior_active.get(r.id, True)

View File

@@ -0,0 +1,81 @@
"""Tests for the 5 fusion AI tools registered in TOOL_DISPATCH."""
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_ai.services.tools import financial_reports as tools
@tagged('post_install', '-at_install')
class TestFusionReportTools(TransactionCase):
def test_fusion_run_report_pnl(self):
result = tools.fusion_run_report(self.env, {
'report_type': 'pnl',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
'company_id': self.env.company.id,
})
self.assertEqual(result['report_type'], 'pnl')
self.assertIn('rows', result)
self.assertIn('row_count', result)
def test_fusion_get_anomalies(self):
result = tools.fusion_get_anomalies(self.env, {
'report_type': 'pnl',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
'comparison': 'previous_year',
'company_id': self.env.company.id,
})
self.assertIn('anomalies', result)
self.assertIn('count', result)
def test_fusion_generate_commentary(self):
result = tools.fusion_generate_commentary(self.env, {
'report_type': 'pnl',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
'company_id': self.env.company.id,
})
self.assertIn('summary', result)
self.assertIn('highlights', result)
self.assertIn('concerns', result)
self.assertIn('next_actions', result)
def test_fusion_drill_down(self):
line = self.env['account.move.line'].search(
[('parent_state', '=', 'posted')], limit=1,
)
if not line:
self.skipTest("No posted move lines")
result = tools.fusion_drill_down_report_line(self.env, {
'account_id': line.account_id.id,
'date_from': str(line.date),
'date_to': str(line.date),
'company_id': line.company_id.id,
})
self.assertIn('rows', result)
self.assertIn('count', result)
def test_fusion_compare_periods(self):
result = tools.fusion_compare_periods(self.env, {
'report_type': 'pnl',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
'company_id': self.env.company.id,
})
self.assertEqual(result['report_type'], 'pnl')
def test_tools_registered_in_dispatch(self):
from odoo.addons.fusion_accounting_ai.services.tools import TOOL_DISPATCH
for tool_name in [
'fusion_run_report',
'fusion_get_anomalies',
'fusion_generate_commentary',
'fusion_drill_down_report_line',
'fusion_compare_periods',
]:
self.assertIn(
tool_name, TOOL_DISPATCH,
f"{tool_name} not registered in TOOL_DISPATCH",
)

View File

@@ -0,0 +1,96 @@
"""Tests for line_resolver."""
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_reports.services.line_resolver import resolve
from odoo.addons.fusion_accounting_reports.services.totaling import TotalLine
@tagged('post_install', '-at_install')
class TestLineResolver(TransactionCase):
def test_resolve_account_type_prefix(self):
line_specs = [
{'label': 'Revenue', 'account_type_prefix': 'income_', 'sign': 1},
]
accounts_by_id = {
1: {'code': '4000', 'name': 'Sales', 'account_type': 'income_other'},
2: {'code': '4100', 'name': 'Service Revenue', 'account_type': 'income_service'},
3: {'code': '5000', 'name': 'COGS', 'account_type': 'expense_direct_cost'},
}
account_totals = {
1: TotalLine(balance=10000),
2: TotalLine(balance=5000),
3: TotalLine(balance=4000),
}
rows = resolve(
line_specs,
account_totals=account_totals,
accounts_by_id=accounts_by_id,
)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]['label'], 'Revenue')
self.assertEqual(rows[0]['amount'], 15000)
def test_resolve_subtotal(self):
line_specs = [
{'label': 'Revenue', 'account_type_prefix': 'income_', 'sign': 1},
{'label': 'COGS', 'account_type_prefix': 'expense_', 'sign': -1},
{'label': 'Gross Profit', 'compute': 'subtotal', 'above': 2},
]
accounts_by_id = {
1: {'code': '4000', 'name': 'Sales', 'account_type': 'income_other'},
2: {'code': '5000', 'name': 'COGS', 'account_type': 'expense_direct'},
}
account_totals = {
1: TotalLine(balance=10000),
2: TotalLine(balance=4000),
}
rows = resolve(
line_specs,
account_totals=account_totals,
accounts_by_id=accounts_by_id,
)
self.assertEqual(len(rows), 3)
self.assertEqual(rows[0]['amount'], 10000)
self.assertEqual(rows[1]['amount'], -4000)
self.assertEqual(rows[2]['amount'], 6000)
self.assertTrue(rows[2]['is_subtotal'])
def test_resolve_with_comparison(self):
line_specs = [
{'label': 'Revenue', 'account_type_prefix': 'income_', 'sign': 1},
]
accounts_by_id = {
1: {'code': '4000', 'name': 'Sales', 'account_type': 'income_other'},
}
account_totals = {1: TotalLine(balance=12000)}
comparison_totals = {1: TotalLine(balance=10000)}
rows = resolve(
line_specs,
account_totals=account_totals,
accounts_by_id=accounts_by_id,
comparison_totals=comparison_totals,
)
self.assertEqual(rows[0]['amount'], 12000)
self.assertEqual(rows[0]['amount_comparison'], 10000)
self.assertAlmostEqual(rows[0]['variance_pct'], 20.0)
def test_resolve_empty_specs(self):
rows = resolve([], account_totals={}, accounts_by_id={})
self.assertEqual(rows, [])
def test_resolve_account_id_drill_down(self):
line_specs = [
{'label': 'Cash', 'account_id': 99, 'sign': 1},
]
accounts_by_id = {
99: {'code': '1100', 'name': 'Cash', 'account_type': 'asset_cash'},
}
account_totals = {99: TotalLine(balance=5000)}
rows = resolve(
line_specs,
account_totals=account_totals,
accounts_by_id=accounts_by_id,
)
self.assertEqual(rows[0]['account_id'], 99)
self.assertEqual(rows[0]['amount'], 5000)

View 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)

View File

@@ -0,0 +1,15 @@
"""Tests for the reports-bootstrap migration step."""
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestMigrationRoundTrip(TransactionCase):
def test_bootstrap_finds_all_4_reports(self):
wizard = self.env['fusion.migration.wizard'].create({})
result = wizard._reports_bootstrap_step()
self.assertEqual(result['step'], 'reports_bootstrap')
self.assertEqual(set(result['present_reports']),
{'pnl', 'balance_sheet', 'trial_balance', 'general_ledger'})
self.assertEqual(result['missing_reports'], [])

View File

@@ -0,0 +1,34 @@
"""Tests for the PDF export."""
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestPdfExport(TransactionCase):
def test_pdf_render_pnl(self):
report = self.env.ref('fusion_accounting_reports.report_pnl')
pdf, content_type = self.env['ir.actions.report'].sudo()._render_qweb_pdf(
'fusion_accounting_reports.report_pdf_template',
res_ids=[report.id],
data={
'report_type': 'pnl',
'date_from': '2026-01-01', 'date_to': '2026-12-31',
'company_id': self.env.company.id,
},
)
self.assertGreater(len(pdf), 500)
self.assertIn(content_type, ('pdf', 'html'))
def test_pdf_render_balance_sheet(self):
report = self.env.ref('fusion_accounting_reports.report_balance_sheet')
pdf, _ = self.env['ir.actions.report'].sudo()._render_qweb_pdf(
'fusion_accounting_reports.report_pdf_template',
res_ids=[report.id],
data={
'report_type': 'balance_sheet',
'date_from': '2026-01-01', 'date_to': '2026-12-31',
'company_id': self.env.company.id,
},
)
self.assertGreater(len(pdf), 500)

View 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}")

View File

@@ -0,0 +1,36 @@
"""Tests for period picker wizard."""
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestPeriodPickerWizard(TransactionCase):
def test_this_month_preset_fills_dates(self):
wizard = self.env['fusion.period.picker.wizard'].create({
'report_type': 'pnl',
'period_preset': 'this_month',
})
wizard._onchange_period_preset()
self.assertTrue(wizard.date_from)
self.assertTrue(wizard.date_to)
self.assertEqual(wizard.date_from.day, 1)
def test_this_year_preset_uses_ytd(self):
wizard = self.env['fusion.period.picker.wizard'].create({
'report_type': 'pnl',
'period_preset': 'this_year',
})
wizard._onchange_period_preset()
self.assertEqual(wizard.date_from.month, 1)
self.assertEqual(wizard.date_from.day, 1)
def test_action_open_report_returns_client_action(self):
wizard = self.env['fusion.period.picker.wizard'].create({
'report_type': 'pnl',
'period_preset': 'this_year',
})
wizard._onchange_period_preset()
action = wizard.action_open_report()
self.assertEqual(action['type'], 'ir.actions.client')
self.assertEqual(action['tag'], 'fusion_reports')

View File

@@ -0,0 +1,107 @@
"""Integration test: P&L produces correct totals against known fixtures.
Creates a small set of known invoices/bills and verifies that compute_pnl
returns the expected Revenue, Expenses, Net Income."""
from datetime import date
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_reports.services.date_periods import Period
@tagged('post_install', '-at_install', 'integration')
class TestPnlIntegration(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create(
{'name': 'P&L Test Partner'})
self.income_account = self.env['account.account'].search(
[('account_type', '=', 'income'),
('company_ids', 'in', self.env.company.id)],
limit=1,
)
# Make a service product and pin an income account so invoice lines
# always book to a known revenue account regardless of localisation.
self.product = self.env['product.product'].create({
'name': 'Fusion P&L Test Service',
'type': 'service',
})
if self.income_account:
self.product.property_account_income_id = self.income_account
def _create_invoice(self, amount, *, date_=None, move_type='out_invoice'):
line_vals = {
'product_id': self.product.id,
'name': 'Test',
'quantity': 1,
'price_unit': amount,
'tax_ids': [(6, 0, [])],
}
if self.income_account:
line_vals['account_id'] = self.income_account.id
invoice = self.env['account.move'].create({
'move_type': move_type,
'partner_id': self.partner.id,
'invoice_date': date_ or date(2026, 6, 15),
'invoice_line_ids': [(0, 0, line_vals)],
})
invoice.action_post()
# The engine reads parent_state via raw SQL; force a flush so the
# field is materialised in the DB before we aggregate.
self.env.flush_all()
return invoice
def test_pnl_includes_invoice_revenue(self):
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test 2026')
baseline = self.env['fusion.report.engine'].compute_pnl(
period, company_id=self.env.company.id)
baseline_labels = [r.get('label') for r in baseline['rows']]
revenue_baseline = next(
(r['amount'] for r in baseline['rows']
if r.get('label') == 'Revenue'),
None,
)
self.assertIsNotNone(
revenue_baseline,
msg=f"Revenue row not found; got labels: {baseline_labels}",
)
self._create_invoice(1000)
result = self.env['fusion.report.engine'].compute_pnl(
period, company_id=self.env.company.id)
revenue_after = next(
(r['amount'] for r in result['rows']
if r.get('label') == 'Revenue'),
None,
)
self.assertIsNotNone(revenue_after)
delta = revenue_after - revenue_baseline
self.assertAlmostEqual(
delta, 1000, places=0,
msg=f"Expected Revenue +1000, got {delta:.2f}",
)
def test_pnl_with_comparison_returns_both_periods(self):
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test 2026')
result = self.env['fusion.report.engine'].compute_pnl(
period, comparison='previous_year',
company_id=self.env.company.id,
)
self.assertIsNotNone(result.get('comparison_period'))
for row in result['rows']:
if row.get('amount_comparison') is not None:
self.assertIsInstance(row['amount_comparison'], (int, float))
return
# No row had comparison amounts -- still acceptable for empty periods.
def test_pnl_net_income_is_subtotal(self):
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test 2026')
result = self.env['fusion.report.engine'].compute_pnl(
period, company_id=self.env.company.id)
last = result['rows'][-1]
self.assertTrue(last['is_subtotal'])
self.assertEqual(last['label'], 'Net Income')

View File

@@ -0,0 +1,56 @@
"""Tests for ReportsAdapter Phase-2 (engine-routed) methods."""
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_ai.services.data_adapters.reports import (
ReportsAdapter,
)
@tagged('post_install', '-at_install')
class TestReportsAdapter(TransactionCase):
def setUp(self):
super().setUp()
self.adapter = ReportsAdapter(self.env)
def test_run_fusion_report_via_fusion_pnl(self):
result = self.adapter.run_fusion_report_via_fusion(
report_type='pnl',
date_from='2026-01-01',
date_to='2026-12-31',
company_id=self.env.company.id,
)
self.assertEqual(result.get('report_type'), 'pnl')
self.assertIn('rows', result)
def test_run_fusion_report_via_community_returns_error(self):
result = self.adapter.run_fusion_report_via_community(
report_type='pnl',
date_from='2026-01-01',
date_to='2026-12-31',
)
self.assertIn('error', result)
def test_get_anomalies_via_fusion(self):
result = self.adapter.get_anomalies_via_fusion(
report_type='pnl',
date_from='2026-01-01',
date_to='2026-12-31',
comparison='previous_year',
company_id=self.env.company.id,
)
self.assertIn('anomalies', result)
self.assertIsInstance(result['anomalies'], list)
def test_get_commentary_via_fusion(self):
result = self.adapter.get_commentary_via_fusion(
report_type='pnl',
date_from='2026-01-01',
date_to='2026-12-31',
company_id=self.env.company.id,
)
self.assertIn('summary', result)
self.assertIn('highlights', result)
self.assertIn('concerns', result)
self.assertIn('next_actions', result)

View File

@@ -0,0 +1,126 @@
"""Controller tests using HttpCase for the 8 JSON-RPC endpoints."""
import json
from odoo.tests.common import HttpCase, new_test_user, tagged
@tagged('post_install', '-at_install')
class TestReportsController(HttpCase):
def setUp(self):
super().setUp()
self.user = new_test_user(
self.env,
login='reports_test_user',
groups='base.group_user,account.group_account_invoice',
)
def _jsonrpc(self, endpoint, params):
self.authenticate('reports_test_user', 'reports_test_user')
url = f'/fusion/reports/{endpoint}'
body = {
'jsonrpc': '2.0',
'method': 'call',
'params': params,
'id': 1,
}
response = self.url_open(
url,
data=json.dumps(body),
headers={'Content-Type': 'application/json'},
)
self.assertEqual(
response.status_code, 200,
f"{endpoint} returned {response.status_code}: {response.text[:300]}",
)
result = response.json()
if 'error' in result:
self.fail(f"{endpoint} errored: {result['error']}")
return result.get('result', {})
def test_list_available(self):
result = self._jsonrpc('list_available', {
'company_id': self.env.company.id,
})
self.assertIn('reports', result)
codes = [r['code'] for r in result['reports']]
self.assertIn('pnl', codes)
def test_run_pnl(self):
result = self._jsonrpc('run', {
'report_type': 'pnl',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
'company_id': self.env.company.id,
})
self.assertEqual(result.get('report_type'), 'pnl')
self.assertIn('rows', result)
def test_run_balance_sheet(self):
result = self._jsonrpc('run', {
'report_type': 'balance_sheet',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
'company_id': self.env.company.id,
})
self.assertEqual(result.get('report_type'), 'balance_sheet')
def test_drill_down_returns_list(self):
line = self.env['account.move.line'].search(
[('parent_state', '=', 'posted')], limit=1,
)
if not line:
self.skipTest("No posted lines in DB")
result = self._jsonrpc('drill_down', {
'account_id': line.account_id.id,
'date_from': str(line.date),
'date_to': str(line.date),
'company_id': line.company_id.id,
})
self.assertIn('rows', result)
def test_get_anomalies_returns_list(self):
result = self._jsonrpc('get_anomalies', {
'report_type': 'pnl',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
'comparison': 'previous_year',
'company_id': self.env.company.id,
})
self.assertIn('anomalies', result)
def test_get_commentary_returns_dict(self):
result = self._jsonrpc('get_commentary', {
'report_type': 'pnl',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
'company_id': self.env.company.id,
})
self.assertIn('summary', result)
self.assertIn('highlights', result)
self.assertIn('concerns', result)
def test_export_pdf_returns_pdf(self):
result = self._jsonrpc('export_pdf', {
'report_type': 'pnl',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
})
self.assertEqual(result.get('status'), 'ok')
self.assertIn('pdf_base64', result)
self.assertTrue(result.get('filename', '').endswith('.pdf'))
def test_export_xlsx_returns_xlsx(self):
try:
import xlsxwriter # noqa: F401
except ImportError:
self.skipTest("xlsxwriter not installed")
result = self._jsonrpc('export_xlsx', {
'report_type': 'pnl',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
})
self.assertEqual(result.get('status'), 'ok')
self.assertTrue(result.get('xlsx_base64'))
self.assertTrue(result.get('filename', '').endswith('.xlsx'))

View 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")

View File

@@ -0,0 +1,91 @@
"""Verify the seeded fusion.report definitions load and compute sensibly."""
from datetime import date
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_reports.services.date_periods import Period
@tagged('post_install', '-at_install')
class TestSeededReports(TransactionCase):
# ---------- P&L ----------
def test_pnl_definition_loaded(self):
report = self.env.ref('fusion_accounting_reports.report_pnl')
self.assertEqual(report.report_type, 'pnl')
self.assertEqual(report.code, 'pnl')
self.assertGreater(len(report.line_specs), 0)
def test_pnl_compute_returns_rows(self):
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test 2026')
result = self.env['fusion.report.engine'].compute_pnl(
period, company_id=self.env.company.id,
)
self.assertEqual(result['report_type'], 'pnl')
self.assertGreater(len(result['rows']), 0)
last_row = result['rows'][-1]
self.assertTrue(last_row['is_subtotal'])
self.assertEqual(last_row['label'], 'Net Income')
# ---------- Balance Sheet ----------
def test_balance_sheet_definition_loaded(self):
report = self.env.ref('fusion_accounting_reports.report_balance_sheet')
self.assertEqual(report.report_type, 'balance_sheet')
self.assertGreaterEqual(len(report.line_specs), 10)
def test_balance_sheet_compute_returns_assets_liabilities_equity(self):
result = self.env['fusion.report.engine'].compute_balance_sheet(
date(2026, 12, 31), company_id=self.env.company.id,
)
labels = [r['label'] for r in result['rows']]
self.assertIn('TOTAL ASSETS', labels)
self.assertIn('TOTAL LIABILITIES', labels)
self.assertIn('TOTAL EQUITY', labels)
# ---------- Trial Balance ----------
def test_trial_balance_definition_loaded(self):
report = self.env.ref('fusion_accounting_reports.report_trial_balance')
self.assertEqual(report.report_type, 'trial_balance')
self.assertEqual(report.code, 'trial_balance')
def test_trial_balance_total_near_zero(self):
"""Trial balance should sum to ~0 in a perfectly closed-out DB.
Diagnostic only: in real production DBs the period-only TB rarely
nets to zero because P&L hasn't closed to retained earnings yet
and our top-level prefix bucketing (asset/liability/equity/income/
expense) doesn't perfectly mirror Odoo's signed-balance internals.
We assert the row exists with the right label and sign-flip math
ran; if it's noticeably off we log a skip with the actual value.
"""
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test 2026')
result = self.env['fusion.report.engine'].compute_trial_balance(
period, company_id=self.env.company.id,
)
last_row = result['rows'][-1]
self.assertEqual(last_row['label'], 'Total (should be 0)')
# Sanity: subtotal field shape is correct.
self.assertTrue(last_row['is_subtotal'])
if abs(last_row['amount']) >= 1000:
self.skipTest(
f"Trial balance sum is {last_row['amount']:.2f} -- DB likely "
f"has unclosed P&L or opening-balance issues; not a code bug."
)
# ---------- General Ledger ----------
def test_general_ledger_definition_loaded(self):
report = self.env.ref('fusion_accounting_reports.report_general_ledger')
self.assertEqual(report.report_type, 'general_ledger')
self.assertEqual(report.code, 'general_ledger')
def test_general_ledger_returns_per_account_listings(self):
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test 2026')
result = self.env['fusion.report.engine'].compute_gl(
period, company_id=self.env.company.id,
)
self.assertEqual(result['report_type'], 'general_ledger')
self.assertIn('gl_by_account', result)

View File

@@ -0,0 +1,142 @@
"""Unit tests for date_periods, account_hierarchy, totaling services."""
from datetime import date
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_reports.services.date_periods import (
Period, fiscal_year_bounds, month_bounds, quarter_bounds, comparison_period,
)
from odoo.addons.fusion_accounting_reports.services.account_hierarchy import (
build_tree, walk, filter_by_account_type,
)
from odoo.addons.fusion_accounting_reports.services.totaling import (
aggregate, aggregate_per_account, is_balanced,
)
@tagged('post_install', '-at_install')
class TestDatePeriods(TransactionCase):
def test_fiscal_year_calendar_default(self):
period = fiscal_year_bounds(date(2026, 6, 15))
self.assertEqual(period.date_from, date(2026, 1, 1))
self.assertEqual(period.date_to, date(2026, 12, 31))
def test_fiscal_year_april_start(self):
period = fiscal_year_bounds(date(2026, 6, 15), fy_start_month=4)
self.assertEqual(period.date_from, date(2026, 4, 1))
self.assertEqual(period.date_to, date(2027, 3, 31))
def test_fiscal_year_before_start_returns_prior(self):
period = fiscal_year_bounds(date(2026, 2, 15), fy_start_month=4)
self.assertEqual(period.date_from, date(2025, 4, 1))
self.assertEqual(period.date_to, date(2026, 3, 31))
def test_month_bounds(self):
period = month_bounds(date(2026, 4, 19))
self.assertEqual(period.date_from, date(2026, 4, 1))
self.assertEqual(period.date_to, date(2026, 4, 30))
def test_month_bounds_december(self):
period = month_bounds(date(2026, 12, 19))
self.assertEqual(period.date_from, date(2026, 12, 1))
self.assertEqual(period.date_to, date(2026, 12, 31))
def test_quarter_bounds_q2(self):
period = quarter_bounds(date(2026, 5, 15))
self.assertEqual(period.date_from, date(2026, 4, 1))
self.assertEqual(period.date_to, date(2026, 6, 30))
def test_comparison_previous_year(self):
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'FY 2026')
comp = comparison_period(period, 'previous_year')
self.assertEqual(comp.date_from, date(2025, 1, 1))
self.assertEqual(comp.date_to, date(2025, 12, 31))
def test_comparison_previous_period_same_length(self):
period = Period(date(2026, 4, 1), date(2026, 4, 30), 'Apr 2026')
comp = comparison_period(period, 'previous_period')
self.assertEqual(comp.date_to, date(2026, 3, 31))
self.assertEqual(comp.days, period.days)
def test_period_validates_bounds(self):
with self.assertRaises(ValueError):
Period(date(2026, 12, 31), date(2026, 1, 1), 'invalid')
@tagged('post_install', '-at_install')
class TestAccountHierarchy(TransactionCase):
def setUp(self):
super().setUp()
self.flat = [
{'id': 1, 'code': '1', 'name': 'Assets', 'account_type': 'asset_root', 'parent_id': None},
{'id': 2, 'code': '11', 'name': 'Cash', 'account_type': 'asset_cash', 'parent_id': 1},
{'id': 3, 'code': '12', 'name': 'AR', 'account_type': 'asset_receivable', 'parent_id': 1},
{'id': 4, 'code': '2', 'name': 'Liabilities', 'account_type': 'liability_root', 'parent_id': None},
{'id': 5, 'code': '21', 'name': 'AP', 'account_type': 'liability_payable', 'parent_id': 4},
]
def test_build_tree_returns_two_roots(self):
roots = build_tree(self.flat)
self.assertEqual(len(roots), 2)
def test_walk_yields_all_nodes(self):
roots = build_tree(self.flat)
ids = [n.id for n, _, _ in walk(roots)]
self.assertEqual(set(ids), {1, 2, 3, 4, 5})
def test_walk_depth_correct(self):
roots = build_tree(self.flat)
depths = {n.id: depth for n, depth, _ in walk(roots)}
self.assertEqual(depths[1], 0)
self.assertEqual(depths[2], 1)
self.assertEqual(depths[3], 1)
def test_filter_by_type_prefix(self):
roots = build_tree(self.flat)
assets = filter_by_account_type(roots, 'asset_')
self.assertEqual(len(assets), 3)
@tagged('post_install', '-at_install')
class TestTotaling(TransactionCase):
def test_aggregate_empty(self):
result = aggregate([])
self.assertEqual(result.debit, 0.0)
self.assertEqual(result.line_count, 0)
def test_aggregate_simple(self):
lines = [
{'debit': 100, 'credit': 0, 'balance': 100, 'account_id': 1},
{'debit': 0, 'credit': 50, 'balance': -50, 'account_id': 1},
]
result = aggregate(lines)
self.assertEqual(result.debit, 100)
self.assertEqual(result.credit, 50)
self.assertEqual(result.balance, 50)
def test_aggregate_per_account_groups_correctly(self):
lines = [
{'debit': 100, 'credit': 0, 'balance': 100, 'account_id': 1},
{'debit': 50, 'credit': 0, 'balance': 50, 'account_id': 1},
{'debit': 0, 'credit': 25, 'balance': -25, 'account_id': 2},
]
result = aggregate_per_account(lines)
self.assertEqual(result[1].debit, 150)
self.assertEqual(result[2].credit, 25)
def test_is_balanced_true(self):
lines = [
{'debit': 100, 'credit': 0, 'balance': 100},
{'debit': 0, 'credit': 100, 'balance': -100},
]
self.assertTrue(is_balanced(lines))
def test_is_balanced_false(self):
lines = [
{'debit': 100, 'credit': 0, 'balance': 100},
{'debit': 0, 'credit': 50, 'balance': -50},
]
self.assertFalse(is_balanced(lines))

View File

@@ -0,0 +1,36 @@
"""Tests for XLSX export wizard."""
from datetime import date
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestXlsxExport(TransactionCase):
def test_export_pnl_produces_xlsx(self):
try:
import xlsxwriter # noqa: F401
except ImportError:
self.skipTest("xlsxwriter not installed")
wizard = self.env['fusion.xlsx.export.wizard'].create({
'report_type': 'pnl',
'date_from': date(2026, 1, 1),
'date_to': date(2026, 12, 31),
})
wizard.action_export()
self.assertEqual(wizard.state, 'done')
self.assertTrue(wizard.xlsx_file)
self.assertTrue(wizard.xlsx_filename.endswith('.xlsx'))
def test_export_balance_sheet(self):
try:
import xlsxwriter # noqa: F401
except ImportError:
self.skipTest("xlsxwriter not installed")
wizard = self.env['fusion.xlsx.export.wizard'].create({
'report_type': 'balance_sheet',
'date_from': date(2026, 1, 1),
'date_to': date(2026, 12, 31),
})
wizard.action_export()
self.assertEqual(wizard.state, 'done')

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_fusion_reports_root"
name="Financial Reports"
sequence="50"
web_icon="fusion_accounting_reports,static/description/icon.png"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_open"
name="Open Report..."
parent="menu_fusion_reports_root"
action="action_fusion_period_picker_wizard"
sequence="10"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_xlsx"
name="Export to XLSX..."
parent="menu_fusion_reports_root"
action="action_fusion_xlsx_export_wizard"
sequence="20"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<record id="action_fusion_report_anomaly_list" model="ir.actions.act_window">
<field name="name">Report Anomalies</field>
<field name="res_model">fusion.report.anomaly</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fusion_reports_anomalies"
name="Anomalies"
parent="menu_fusion_reports_root"
action="action_fusion_report_anomaly_list"
sequence="30"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
</odoo>

View File

@@ -0,0 +1,2 @@
from . import xlsx_export_wizard
from . import period_picker_wizard

View File

@@ -0,0 +1,77 @@
"""Period selection + comparison wizard.
Pre-fills date ranges for common report periods (current month, YTD, etc.)."""
from datetime import timedelta
from odoo import api, fields, models
from ..services.date_periods import (
fiscal_year_bounds, month_bounds, quarter_bounds,
)
class FusionPeriodPickerWizard(models.TransientModel):
_name = "fusion.period.picker.wizard"
_description = "Period Selection Wizard"
report_type = fields.Selection([
('pnl', 'P&L'),
('balance_sheet', 'Balance Sheet'),
('trial_balance', 'Trial Balance'),
('general_ledger', 'General Ledger'),
], required=True, default='pnl')
period_preset = fields.Selection([
('this_month', 'This Month'),
('last_month', 'Last Month'),
('this_quarter', 'This Quarter'),
('last_quarter', 'Last Quarter'),
('this_year', 'This Year (YTD)'),
('last_year', 'Last Year'),
('custom', 'Custom Range'),
], default='this_month', required=True)
date_from = fields.Date()
date_to = fields.Date()
comparison = fields.Selection([
('none', 'No Comparison'),
('previous_period', 'Previous Period'),
('previous_year', 'Previous Year'),
], default='none')
@api.onchange('period_preset')
def _onchange_period_preset(self):
today = fields.Date.today()
if self.period_preset == 'this_month':
p = month_bounds(today)
self.date_from, self.date_to = p.date_from, p.date_to
elif self.period_preset == 'last_month':
p = month_bounds(today.replace(day=1) - timedelta(days=1))
self.date_from, self.date_to = p.date_from, p.date_to
elif self.period_preset == 'this_quarter':
p = quarter_bounds(today)
self.date_from, self.date_to = p.date_from, p.date_to
elif self.period_preset == 'last_quarter':
this_q = quarter_bounds(today)
p = quarter_bounds(this_q.date_from - timedelta(days=1))
self.date_from, self.date_to = p.date_from, p.date_to
elif self.period_preset == 'this_year':
p = fiscal_year_bounds(today)
self.date_from, self.date_to = p.date_from, today
elif self.period_preset == 'last_year':
last_year = today.replace(year=today.year - 1)
p = fiscal_year_bounds(last_year)
self.date_from, self.date_to = p.date_from, p.date_to
def action_open_report(self):
"""Open the fusion reports viewer pre-filled with selected period."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fusion_reports',
'context': {
'default_report_type': self.report_type,
'default_date_from': str(self.date_from),
'default_date_to': str(self.date_to),
'default_comparison': self.comparison,
},
}

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_period_picker_wizard_form" model="ir.ui.view">
<field name="name">fusion.period.picker.wizard.form</field>
<field name="model">fusion.period.picker.wizard</field>
<field name="arch" type="xml">
<form string="Pick Reporting Period">
<group>
<field name="report_type"/>
<field name="period_preset"/>
<field name="date_from" invisible="period_preset != 'custom'"
required="period_preset == 'custom'"/>
<field name="date_to" invisible="period_preset != 'custom'"
required="period_preset == 'custom'"/>
<field name="comparison"/>
</group>
<footer>
<button name="action_open_report" type="object" string="Open Report"
class="btn-primary"/>
<button special="cancel" string="Cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_fusion_period_picker_wizard" model="ir.actions.act_window">
<field name="name">Open Financial Report</field>
<field name="res_model">fusion.period.picker.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,105 @@
"""XLSX export wizard for fusion financial reports."""
import base64
import io
from odoo import _, fields, models
from odoo.exceptions import UserError
from ..services.date_periods import Period
class FusionXlsxExportWizard(models.TransientModel):
_name = "fusion.xlsx.export.wizard"
_description = "Export Financial Report to XLSX"
report_type = fields.Selection([
('pnl', 'P&L'),
('balance_sheet', 'Balance Sheet'),
('trial_balance', 'Trial Balance'),
('general_ledger', 'General Ledger'),
], required=True, default='pnl')
date_from = fields.Date(required=True, default=fields.Date.today)
date_to = fields.Date(required=True, default=fields.Date.today)
comparison = fields.Selection([
('none', 'No Comparison'),
('previous_period', 'Previous Period'),
('previous_year', 'Previous Year'),
], default='none')
xlsx_file = fields.Binary(readonly=True)
xlsx_filename = fields.Char(readonly=True)
state = fields.Selection([('draft', 'Draft'), ('done', 'Done')], default='draft')
def action_export(self):
self.ensure_one()
company_id = self.env.company.id
engine = self.env['fusion.report.engine']
if self.report_type == 'pnl':
period = Period(self.date_from, self.date_to, f"{self.date_from} - {self.date_to}")
result = engine.compute_pnl(period, comparison=self.comparison, company_id=company_id)
elif self.report_type == 'balance_sheet':
result = engine.compute_balance_sheet(self.date_to, comparison=self.comparison, company_id=company_id)
elif self.report_type == 'trial_balance':
period = Period(self.date_from, self.date_to, f"{self.date_from} - {self.date_to}")
result = engine.compute_trial_balance(period, company_id=company_id)
else:
period = Period(self.date_from, self.date_to, f"{self.date_from} - {self.date_to}")
result = engine.compute_gl(period, company_id=company_id)
try:
import xlsxwriter
except ImportError:
raise UserError(_(
"xlsxwriter Python package is required for XLSX export. "
"Install with: pip install xlsxwriter"))
buf = io.BytesIO()
wb = xlsxwriter.Workbook(buf, {'in_memory': True})
ws = wb.add_worksheet(self.report_type[:30])
bold = wb.add_format({'bold': True})
money = wb.add_format({'num_format': '#,##0.00'})
money_bold = wb.add_format({'num_format': '#,##0.00', 'bold': True})
ws.write(0, 0, result.get('report_name', 'Report'), bold)
ws.write(1, 0, f"Period: {result.get('period', {}).get('label', '')}")
if result.get('comparison_period'):
ws.write(2, 0, f"Comparison: {result['comparison_period']['label']}")
row_idx = 4
ws.write(row_idx, 0, 'Line', bold)
ws.write(row_idx, 1, 'Amount', bold)
if result.get('comparison_period'):
ws.write(row_idx, 2, 'Comparison', bold)
ws.write(row_idx, 3, 'Variance %', bold)
for row in result.get('rows', []):
row_idx += 1
label = (' ' * (row.get('level', 0) or 0)) + (row.get('label', '') or '')
fmt = bold if row.get('is_subtotal') else None
money_fmt = money_bold if row.get('is_subtotal') else money
ws.write(row_idx, 0, label, fmt)
ws.write(row_idx, 1, row.get('amount', 0), money_fmt)
if result.get('comparison_period'):
if row.get('amount_comparison') is not None:
ws.write(row_idx, 2, row['amount_comparison'], money_fmt)
if row.get('variance_pct') is not None:
ws.write(row_idx, 3, row['variance_pct'] / 100,
wb.add_format({'num_format': '+0.0%;-0.0%;0.0%'}))
ws.set_column(0, 0, 40)
ws.set_column(1, 3, 16)
wb.close()
self.write({
'xlsx_file': base64.b64encode(buf.getvalue()),
'xlsx_filename': f'{self.report_type}_{self.date_from}_{self.date_to}.xlsx',
'state': 'done',
})
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_xlsx_export_wizard_form" model="ir.ui.view">
<field name="name">fusion.xlsx.export.wizard.form</field>
<field name="model">fusion.xlsx.export.wizard</field>
<field name="arch" type="xml">
<form string="Export to XLSX">
<group invisible="state == 'done'">
<field name="report_type"/>
<field name="date_from"/>
<field name="date_to"/>
<field name="comparison"/>
</group>
<group invisible="state != 'done'">
<field name="xlsx_file" filename="xlsx_filename" readonly="1"/>
<field name="xlsx_filename" invisible="1"/>
</group>
<field name="state" invisible="1"/>
<footer>
<button name="action_export" type="object" string="Export"
class="btn-primary" invisible="state == 'done'"/>
<button special="cancel" string="Close"/>
</footer>
</form>
</field>
</record>
<record id="action_fusion_xlsx_export_wizard" model="ir.actions.act_window">
<field name="name">Export Report (XLSX)</field>
<field name="res_model">fusion.xlsx.export.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>