Compare commits
11 Commits
d1661f3a33
...
fusion_acc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
848aa0f0e5 | ||
|
|
5a864e4b48 | ||
|
|
0618ca7773 | ||
|
|
6a53da6002 | ||
|
|
3c7a1c8cea | ||
|
|
1c773bb5e4 | ||
|
|
5994a1b96b | ||
|
|
e17e7f9e4c | ||
|
|
8de4beb46a | ||
|
|
7d7bd93345 | ||
|
|
23b988c401 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting',
|
'name': 'Fusion Accounting',
|
||||||
'version': '19.0.1.0.1',
|
'version': '19.0.1.0.2',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'sequence': 25,
|
'sequence': 25,
|
||||||
'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).',
|
'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).',
|
||||||
@@ -14,9 +14,9 @@ Currently installs:
|
|||||||
- fusion_accounting_ai AI Co-Pilot (Claude/GPT)
|
- fusion_accounting_ai AI Co-Pilot (Claude/GPT)
|
||||||
- fusion_accounting_migration Transitional Enterprise->Fusion data migration
|
- fusion_accounting_migration Transitional Enterprise->Fusion data migration
|
||||||
- fusion_accounting_bank_rec AI-assisted bank reconciliation (Phase 1)
|
- fusion_accounting_bank_rec AI-assisted bank reconciliation (Phase 1)
|
||||||
|
- fusion_accounting_reports AI-augmented financial reports (Phase 2)
|
||||||
|
|
||||||
Future sub-modules (added per the roadmap as each Phase ships):
|
Future sub-modules (added per the roadmap as each Phase ships):
|
||||||
- fusion_accounting_reports (Phase 2)
|
|
||||||
- fusion_accounting_dashboard (Phase 3)
|
- fusion_accounting_dashboard (Phase 3)
|
||||||
- fusion_accounting_followup (Phase 5)
|
- fusion_accounting_followup (Phase 5)
|
||||||
- fusion_accounting_assets (Phase 6)
|
- fusion_accounting_assets (Phase 6)
|
||||||
@@ -34,6 +34,7 @@ Built by Nexa Systems Inc.
|
|||||||
'fusion_accounting_ai',
|
'fusion_accounting_ai',
|
||||||
'fusion_accounting_migration',
|
'fusion_accounting_migration',
|
||||||
'fusion_accounting_bank_rec',
|
'fusion_accounting_bank_rec',
|
||||||
|
'fusion_accounting_reports',
|
||||||
],
|
],
|
||||||
'data': [],
|
'data': [],
|
||||||
'installable': True,
|
'installable': True,
|
||||||
|
|||||||
147
fusion_accounting_reports/CLAUDE.md
Normal file
147
fusion_accounting_reports/CLAUDE.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# fusion_accounting_reports — Cursor / Claude Context
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
AI-augmented financial reports — a Fusion-native replacement for Odoo
|
||||||
|
Enterprise's `account_reports` module. Phase 2 of the fusion_accounting
|
||||||
|
roadmap.
|
||||||
|
|
||||||
|
CORE scope:
|
||||||
|
- Income Statement (P&L)
|
||||||
|
- Balance Sheet
|
||||||
|
- Trial Balance
|
||||||
|
- General Ledger (with drill-down)
|
||||||
|
|
||||||
|
AI augmentation:
|
||||||
|
- Anomaly detection (variance vs prior period)
|
||||||
|
- AI commentary (LLM-generated narrative)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Hybrid: the engine (`fusion.report.engine`, AbstractModel) is the SINGLE
|
||||||
|
read surface for reports. Per-report definitions are stored as `fusion.report`
|
||||||
|
records with JSON `line_specs` so non-developers can tweak the layouts.
|
||||||
|
|
||||||
|
Public engine API (5 methods):
|
||||||
|
- `compute_pnl(period, *, comparison='none', company_id=None)`
|
||||||
|
- `compute_balance_sheet(date_to, *, comparison='none', company_id=None)`
|
||||||
|
- `compute_trial_balance(period, *, company_id=None)`
|
||||||
|
- `compute_gl(period, *, account_ids=None, company_id=None)`
|
||||||
|
- `drill_down(*, account_id, period, company_id=None)`
|
||||||
|
|
||||||
|
Pure-Python services in `services/` (no Odoo imports — independently
|
||||||
|
unit-testable):
|
||||||
|
- `date_periods` — `Period` dataclass + comparison-period math
|
||||||
|
- `account_hierarchy` — chart-of-accounts tree walk
|
||||||
|
- `totaling` — debit/credit/balance roll-ups
|
||||||
|
- `currency_conversion` — multi-currency conversion via `res.currency.rate`
|
||||||
|
- `line_resolver` — JSON `line_specs` → rendered rows
|
||||||
|
- `drill_down_resolver` — line → underlying journal items
|
||||||
|
- `anomaly_detection` — variance vs prior period (z-score + abs/pct gates)
|
||||||
|
- `commentary_generator` — LLM narrative with templated fallback
|
||||||
|
- `commentary_prompt` — provider-agnostic system + user prompt
|
||||||
|
|
||||||
|
Persisted models in `models/`:
|
||||||
|
- `fusion.report` — definition with JSON `line_specs`
|
||||||
|
- `fusion.report.commentary` — LLM-output cache (one per period+mode)
|
||||||
|
- `fusion.report.anomaly` — flagged variances
|
||||||
|
- `fusion.account.balance.mv` — pre-aggregated materialized view
|
||||||
|
- `fusion.report.engine` — AbstractModel (the API)
|
||||||
|
- `fusion.reports.cron` — cron handlers (commentary refresh, MV refresh)
|
||||||
|
- `fusion.xlsx.export.wizard` — TransientModel (XLSX export)
|
||||||
|
- `fusion.period.picker.wizard` — TransientModel (UX entry-point)
|
||||||
|
- `fusion.migration.wizard` (inherits) — adds `_reports_bootstrap_step`
|
||||||
|
|
||||||
|
Controller: `controllers/reports_controller.py` exposes 8 JSON-RPC endpoints
|
||||||
|
under `/fusion/reports/*`. All read paths route through the engine.
|
||||||
|
|
||||||
|
OWL frontend: `static/src/`
|
||||||
|
- `scss/` — variables, base styles, dark-mode overrides
|
||||||
|
- `services/reports_service.js` — central reactive state + RPC wrappers
|
||||||
|
- `views/report_viewer/` — top-level OWL view + view-registry adapter
|
||||||
|
- `components/report_table/` — generic financial-table renderer
|
||||||
|
- `components/drill_down_dialog/` — modal for journal-item listing
|
||||||
|
- `components/period_filter/` — date-range + comparison picker
|
||||||
|
- `components/ai_commentary_panel/` — LLM commentary surface
|
||||||
|
- `components/anomaly_strip/` — variance summary banner
|
||||||
|
- `tours/reports_tours.js` — 5 OWL tour smoke tests
|
||||||
|
|
||||||
|
## Coexistence
|
||||||
|
|
||||||
|
When `account_reports` is installed, the Reports menu hides via
|
||||||
|
`fusion_accounting_core.group_fusion_show_when_enterprise_absent`
|
||||||
|
(a computed group). The engine + AI tools (commentary, anomaly detection)
|
||||||
|
remain available for the chat regardless.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **V19 deprecations to avoid:** `_sql_constraints` (use `models.Constraint`),
|
||||||
|
`@api.depends('id')` (raises `NotImplementedError`), `@route(type='json')`
|
||||||
|
(use `type='jsonrpc'`), `numbercall` field on `ir.cron` (removed),
|
||||||
|
`groups_id` on `res.users` (use `all_group_ids` for searching),
|
||||||
|
`users` field on `res.groups` (use `user_ids`), `groups_id` on
|
||||||
|
`ir.ui.menu` (use `group_ids`).
|
||||||
|
|
||||||
|
- **Engine signature:** Public methods are keyword-only after the leading
|
||||||
|
positional `period` / `date_to`. Always pass `company_id=...` explicitly.
|
||||||
|
|
||||||
|
- **`fusion.report` lookup:** `_get_report` falls back from per-company
|
||||||
|
override to global (`company_id=False`) — order is `company_id desc nulls
|
||||||
|
last`.
|
||||||
|
|
||||||
|
- **Materialized view refresh:** `fusion.account.balance.mv` rebuilds via a
|
||||||
|
dedicated autocommit cursor (REFRESH CONCURRENTLY can't run inside Odoo's
|
||||||
|
regular transaction). Triggered by cron + on demand from the engine when
|
||||||
|
data is older than the configured TTL.
|
||||||
|
|
||||||
|
- **JSON `line_specs`:** Strings prefixed `account:`, `prefix:`, `formula:`
|
||||||
|
or `header` — `line_resolver.py` resolves each spec to a row. Header rows
|
||||||
|
have no compute payload and are silently skipped by downstream totals.
|
||||||
|
|
||||||
|
- **Commentary cache:** Keyed on `(report_id, company_id, period_from,
|
||||||
|
period_to, comparison_mode)` with a unique constraint. Re-runs use the
|
||||||
|
cache unless `force_refresh=True`.
|
||||||
|
|
||||||
|
## Test counts (Phase 2 ship)
|
||||||
|
|
||||||
|
- 130 logical tests, 0 failed, 0 errors
|
||||||
|
- Includes:
|
||||||
|
- 6 benchmarks (tagged `benchmark`)
|
||||||
|
- 1 LLM compat smoke (tagged `local_llm`, skips when no LLM)
|
||||||
|
- 5 OWL tours (tagged `tour`, skips without `websocket-client`)
|
||||||
|
- Property-based, integration, controller, materialized-view, coexistence,
|
||||||
|
migration round-trip, PDF/XLSX export
|
||||||
|
|
||||||
|
## Performance baseline
|
||||||
|
|
||||||
|
| Operation | Median | P95 | Budget |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `engine.compute_pnl` | 3ms | 8ms | <2000ms |
|
||||||
|
| `engine.compute_balance_sheet` | 15ms | 20ms | <2000ms |
|
||||||
|
| `engine.compute_trial_balance` | 3ms | 8ms | <1000ms |
|
||||||
|
| `engine.compute_gl` | 25ms | 81ms | <3000ms |
|
||||||
|
| `engine.drill_down` | 2ms | 10ms | <500ms |
|
||||||
|
| `controller.run` (HTTP round-trip) | 9ms | 46ms | <2500ms |
|
||||||
|
|
||||||
|
All metrics within 1x of budget at Phase 2 ship. Numbers from
|
||||||
|
`tests/test_performance_benchmarks.py` against the dev VM
|
||||||
|
(`westin-v19`, ~1 fiscal year of data).
|
||||||
|
|
||||||
|
## Known concerns / Phase 2.5 backlog
|
||||||
|
|
||||||
|
- Trial balance period-only sum doesn't auto-close to retained earnings
|
||||||
|
(drift visible in `test_trial_balance_total_near_zero`, currently skipped)
|
||||||
|
- Balance sheet `TOTAL LIABILITIES + EQUITY` math limited (no
|
||||||
|
subtotal-of-subtotals expansion in `formula:` specs)
|
||||||
|
- GL `line_specs` need `prefix:` empty-string handling for
|
||||||
|
"all accounts" semantics
|
||||||
|
- Header rows (no compute payload) silently skipped by `line_resolver` —
|
||||||
|
fine for layout, but a `header_only=True` flag would be clearer
|
||||||
|
- `expense` prefix overlaps with subtypes (`expense_direct_cost`,
|
||||||
|
`expense_depreciation`) — current line_specs need explicit ordering or a
|
||||||
|
longer-prefix-wins rule
|
||||||
|
- `wkhtmltopdf` may need configuration for PDF export on first install
|
||||||
|
- `ReportsAdapter.run_report` vs `run_fusion_report` naming (legacy clash
|
||||||
|
with Enterprise wrapper)
|
||||||
|
- Tour tests skip when `websocket-client` is absent — install it in CI to
|
||||||
|
exercise the OWL surface end-to-end
|
||||||
103
fusion_accounting_reports/README.md
Normal file
103
fusion_accounting_reports/README.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# fusion_accounting_reports
|
||||||
|
|
||||||
|
AI-augmented financial reports for Odoo 19 Community — a Fusion-native
|
||||||
|
replacement for Enterprise's `account_reports` module.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **CORE reports**: Income Statement (P&L), Balance Sheet, Trial Balance,
|
||||||
|
General Ledger (with drill-down to journal items)
|
||||||
|
- **AI augmentation**: variance-based anomaly detection + LLM-generated
|
||||||
|
commentary (Claude / GPT / local LM Studio / Ollama)
|
||||||
|
- **Wizards**: period picker (common presets — MTD, QTD, YTD, last month,
|
||||||
|
custom range) + XLSX export
|
||||||
|
- **Coexists** with Enterprise's `account_reports` (Enterprise wins by
|
||||||
|
default; the Fusion menu appears only when Enterprise is uninstalled —
|
||||||
|
the engine and AI tools are always available via the AI chat)
|
||||||
|
- **Multi-currency** aware via `services/currency_conversion.py`
|
||||||
|
- **Multi-company** aware (per-company `fusion.report` overrides fall back
|
||||||
|
to global definitions)
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install
|
||||||
|
odoo --addons-path=... -i fusion_accounting_reports
|
||||||
|
|
||||||
|
# Open the reports menu (when Enterprise's account_reports is NOT installed)
|
||||||
|
# Apps → Reports → Open Financial Report
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### LLM commentary (optional)
|
||||||
|
|
||||||
|
For LM Studio / Ollama (local):
|
||||||
|
|
||||||
|
- `fusion_accounting.openai_base_url` = `http://host.docker.internal:1234/v1`
|
||||||
|
- `fusion_accounting.openai_model` = your local model name
|
||||||
|
- `fusion_accounting.openai_api_key` = `lm-studio` (or anything non-empty)
|
||||||
|
- `fusion_accounting.provider.reports_commentary` = `openai`
|
||||||
|
|
||||||
|
For OpenAI / Anthropic, set the corresponding API keys via the
|
||||||
|
`fusion_accounting_ai` config screen — `reports_commentary` will route
|
||||||
|
through whatever provider you choose.
|
||||||
|
|
||||||
|
If no provider is configured, commentary falls back to a deterministic
|
||||||
|
templated summary (no LLM call).
|
||||||
|
|
||||||
|
### Cron jobs
|
||||||
|
|
||||||
|
Two cron handlers live in `models/fusion_reports_cron.py`:
|
||||||
|
|
||||||
|
- `fusion_reports_commentary_refresh` — daily, regenerates commentary for
|
||||||
|
the most recently completed period
|
||||||
|
- `fusion_reports_mv_refresh` — every 15 min, refreshes
|
||||||
|
`fusion.account.balance.mv`
|
||||||
|
|
||||||
|
## Public engine API
|
||||||
|
|
||||||
|
```python
|
||||||
|
engine = env['fusion.report.engine']
|
||||||
|
|
||||||
|
# Income statement
|
||||||
|
result = engine.compute_pnl(period, comparison='previous_year')
|
||||||
|
|
||||||
|
# Balance sheet (point-in-time)
|
||||||
|
result = engine.compute_balance_sheet(date(2026, 12, 31))
|
||||||
|
|
||||||
|
# Trial balance
|
||||||
|
result = engine.compute_trial_balance(period)
|
||||||
|
|
||||||
|
# General ledger (journal items per account)
|
||||||
|
result = engine.compute_gl(period, account_ids=[1, 2, 3])
|
||||||
|
|
||||||
|
# Drill-down (one account, period)
|
||||||
|
items = engine.drill_down(account_id=1, period=period)
|
||||||
|
```
|
||||||
|
|
||||||
|
## JSON-RPC endpoints
|
||||||
|
|
||||||
|
All under `/fusion/reports/`:
|
||||||
|
|
||||||
|
- `POST /fusion/reports/run` — single entry-point (dispatches by `report_type`)
|
||||||
|
- `POST /fusion/reports/drill_down` — journal items for an account+period
|
||||||
|
- `POST /fusion/reports/commentary` — fetch/refresh LLM commentary
|
||||||
|
- `POST /fusion/reports/anomalies` — flagged variances for a period
|
||||||
|
- `POST /fusion/reports/export_xlsx` — XLSX bytes
|
||||||
|
- `POST /fusion/reports/export_pdf` — PDF bytes (via wkhtmltopdf)
|
||||||
|
- `POST /fusion/reports/list_definitions` — available `fusion.report` records
|
||||||
|
- `POST /fusion/reports/period_presets` — date-range presets for the picker
|
||||||
|
|
||||||
|
## Test counts
|
||||||
|
|
||||||
|
- 130 logical tests, 0 failures, 0 errors
|
||||||
|
- 6 performance benchmarks (tagged `benchmark`)
|
||||||
|
- 1 local-LLM compat smoke (tagged `local_llm`, skips without LLM)
|
||||||
|
- 5 OWL tour tests (tagged `tour`, skips without `websocket-client`)
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- `CLAUDE.md` — agent context (architecture, conventions, perf baseline,
|
||||||
|
Phase 2.5 backlog)
|
||||||
|
- `UPGRADE_NOTES.md` — V19 anchor + migration strategy
|
||||||
60
fusion_accounting_reports/UPGRADE_NOTES.md
Normal file
60
fusion_accounting_reports/UPGRADE_NOTES.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# fusion_accounting_reports — Upgrade Notes
|
||||||
|
|
||||||
|
## Odoo Version Anchor
|
||||||
|
|
||||||
|
This module targets **Odoo 19.0** (community-base).
|
||||||
|
|
||||||
|
Reference snapshot of Enterprise code mirrored from:
|
||||||
|
- `account_reports` (Odoo 19.0.x)
|
||||||
|
- Source: `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_reports/`
|
||||||
|
|
||||||
|
## Cross-Version Diff Strategy
|
||||||
|
|
||||||
|
When a new Odoo version ships:
|
||||||
|
|
||||||
|
1. Run `check_odoo_diff.sh` (in repo root) against the new Enterprise version
|
||||||
|
2. Note any breaking changes in `account.move.line` / `account.account` API
|
||||||
|
surfaces relied on by `services/totaling.py` and
|
||||||
|
`services/drill_down_resolver.py`
|
||||||
|
3. For mirrored OWL components, diff Enterprise's new versions against ours
|
||||||
|
and port material changes (signature renames, new behaviour we want to
|
||||||
|
inherit)
|
||||||
|
4. Re-run the full test suite + tour tests + benchmarks against the new Odoo
|
||||||
|
version
|
||||||
|
5. Update this file with the new version anchor + any deviations
|
||||||
|
|
||||||
|
## V19 Migration Notes (already applied — Phase 1 lessons)
|
||||||
|
|
||||||
|
These were the bite-points from Phase 1 (`fusion_accounting_bank_rec`); we
|
||||||
|
preempted them in Phase 2 from day one:
|
||||||
|
|
||||||
|
- `_sql_constraints` → `models.Constraint` (used in `fusion.report`,
|
||||||
|
`fusion.report.commentary`, `fusion.report.anomaly`)
|
||||||
|
- `@api.depends('id')` → removed everywhere; computed fields depend on real
|
||||||
|
field names instead
|
||||||
|
- `@route(type='json')` → `type='jsonrpc'` (all 8 endpoints)
|
||||||
|
- `numbercall` field on `ir.cron` → omitted (removed in V19)
|
||||||
|
- `res.groups.users` → `user_ids`
|
||||||
|
- `ir.ui.menu.groups_id` → `group_ids` (used in `views/menu_views.xml` and
|
||||||
|
the two wizard view files for the coexistence-group filter)
|
||||||
|
|
||||||
|
## Engine API Stability
|
||||||
|
|
||||||
|
The 5 public engine methods (`compute_pnl`, `compute_balance_sheet`,
|
||||||
|
`compute_trial_balance`, `compute_gl`, `drill_down`) are the public contract.
|
||||||
|
Their signatures are keyword-only after the first positional argument and
|
||||||
|
will be treated as semver-stable across patch releases. Breaking changes
|
||||||
|
will bump the minor version (e.g. 19.0.2.x.y).
|
||||||
|
|
||||||
|
## Phase 2 → Phase 2.5 Migration
|
||||||
|
|
||||||
|
If we ship Phase 2.5 (line_spec polish, deferred features, header_only
|
||||||
|
flag, prefix overlap fix), changes will go in incremental commits. No DB
|
||||||
|
migration needed — Phase 2 schema is forward-compatible:
|
||||||
|
|
||||||
|
- `fusion.report.line_specs` is a JSON column; the migration path is to
|
||||||
|
rewrite specs in place
|
||||||
|
- `fusion.account.balance.mv` can be dropped/re-created freely
|
||||||
|
- `fusion.report.commentary` is a cache; safe to truncate on upgrade
|
||||||
|
- `fusion.report.anomaly` records carry Period as date_from/date_to fields;
|
||||||
|
no schema-level changes anticipated
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
from . import services
|
from . import services
|
||||||
from . import models
|
from . import models
|
||||||
from . import controllers
|
from . import controllers
|
||||||
|
from . import reports
|
||||||
|
from . import wizards
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting Reports',
|
'name': 'Fusion Accounting Reports',
|
||||||
'version': '19.0.1.0.29',
|
'version': '19.0.1.0.38',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
|
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -27,6 +27,7 @@ menu hides; the engine and AI tools remain available for the chat.
|
|||||||
'depends': [
|
'depends': [
|
||||||
'fusion_accounting_core',
|
'fusion_accounting_core',
|
||||||
'fusion_accounting_ai',
|
'fusion_accounting_ai',
|
||||||
|
'fusion_accounting_migration',
|
||||||
'account',
|
'account',
|
||||||
],
|
],
|
||||||
'data': [
|
'data': [
|
||||||
@@ -36,7 +37,14 @@ menu hides; the engine and AI tools remain available for the chat.
|
|||||||
'data/report_trial_balance.xml',
|
'data/report_trial_balance.xml',
|
||||||
'data/report_general_ledger.xml',
|
'data/report_general_ledger.xml',
|
||||||
'data/cron.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': {
|
'assets': {
|
||||||
'web.assets_backend': [
|
'web.assets_backend': [
|
||||||
'fusion_accounting_reports/static/src/scss/_variables.scss',
|
'fusion_accounting_reports/static/src/scss/_variables.scss',
|
||||||
@@ -57,6 +65,9 @@ menu hides; the engine and AI tools remain available for the chat.
|
|||||||
'fusion_accounting_reports/static/src/components/anomaly_strip/anomaly_strip.js',
|
'fusion_accounting_reports/static/src/components/anomaly_strip/anomaly_strip.js',
|
||||||
'fusion_accounting_reports/static/src/components/anomaly_strip/anomaly_strip.xml',
|
'fusion_accounting_reports/static/src/components/anomaly_strip/anomaly_strip.xml',
|
||||||
],
|
],
|
||||||
|
'web.assets_tests': [
|
||||||
|
'fusion_accounting_reports/static/src/tours/reports_tours.js',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'auto_install': False,
|
'auto_install': False,
|
||||||
|
|||||||
@@ -210,15 +210,39 @@ class FusionReportsController(http.Controller):
|
|||||||
@http.route('/fusion/reports/export_pdf', type='jsonrpc', auth='user')
|
@http.route('/fusion/reports/export_pdf', type='jsonrpc', auth='user')
|
||||||
def export_pdf(self, report_type, date_from, date_to,
|
def export_pdf(self, report_type, date_from, date_to,
|
||||||
comparison='none', company_id=None):
|
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 {
|
return {
|
||||||
'status': 'not_implemented',
|
'status': 'ok',
|
||||||
'message': 'PDF export shipping in Task 34',
|
'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')
|
@http.route('/fusion/reports/export_xlsx', type='jsonrpc', auth='user')
|
||||||
def export_xlsx(self, report_type, date_from, date_to,
|
def export_xlsx(self, report_type, date_from, date_to,
|
||||||
comparison='none', company_id=None):
|
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 {
|
return {
|
||||||
'status': 'not_implemented',
|
'status': 'ok',
|
||||||
'message': 'XLSX export shipping in Task 35',
|
'xlsx_base64': wizard.xlsx_file.decode('ascii') if wizard.xlsx_file else '',
|
||||||
|
'filename': wizard.xlsx_filename,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ from . import fusion_report_commentary
|
|||||||
from . import fusion_report_anomaly
|
from . import fusion_report_anomaly
|
||||||
from . import fusion_account_balance_mv
|
from . import fusion_account_balance_mv
|
||||||
from . import fusion_reports_cron
|
from . import fusion_reports_cron
|
||||||
|
from . import fusion_migration_wizard
|
||||||
|
|||||||
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
|
||||||
@@ -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_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_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_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
|
||||||
|
|||||||
|
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" },
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -18,3 +18,11 @@ from . import test_pnl_integration
|
|||||||
from . import test_bs_tb_integration
|
from . import test_bs_tb_integration
|
||||||
from . import test_account_balance_mv
|
from . import test_account_balance_mv
|
||||||
from . import test_cron
|
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
|
||||||
|
|||||||
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)
|
||||||
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')
|
||||||
@@ -101,18 +101,26 @@ class TestReportsController(HttpCase):
|
|||||||
self.assertIn('highlights', result)
|
self.assertIn('highlights', result)
|
||||||
self.assertIn('concerns', result)
|
self.assertIn('concerns', result)
|
||||||
|
|
||||||
def test_export_pdf_placeholder(self):
|
def test_export_pdf_returns_pdf(self):
|
||||||
result = self._jsonrpc('export_pdf', {
|
result = self._jsonrpc('export_pdf', {
|
||||||
'report_type': 'pnl',
|
'report_type': 'pnl',
|
||||||
'date_from': '2026-01-01',
|
'date_from': '2026-01-01',
|
||||||
'date_to': '2026-12-31',
|
'date_to': '2026-12-31',
|
||||||
})
|
})
|
||||||
self.assertEqual(result.get('status'), 'not_implemented')
|
self.assertEqual(result.get('status'), 'ok')
|
||||||
|
self.assertIn('pdf_base64', result)
|
||||||
|
self.assertTrue(result.get('filename', '').endswith('.pdf'))
|
||||||
|
|
||||||
def test_export_xlsx_placeholder(self):
|
def test_export_xlsx_returns_xlsx(self):
|
||||||
|
try:
|
||||||
|
import xlsxwriter # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
self.skipTest("xlsxwriter not installed")
|
||||||
result = self._jsonrpc('export_xlsx', {
|
result = self._jsonrpc('export_xlsx', {
|
||||||
'report_type': 'pnl',
|
'report_type': 'pnl',
|
||||||
'date_from': '2026-01-01',
|
'date_from': '2026-01-01',
|
||||||
'date_to': '2026-12-31',
|
'date_to': '2026-12-31',
|
||||||
})
|
})
|
||||||
self.assertEqual(result.get('status'), 'not_implemented')
|
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