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