75 Commits

Author SHA1 Message Date
gsinghpal
de6d8fda3e feat(fusion_accounting_assets): 2 cron jobs (depreciation post + anomaly scan)
Some checks failed
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
Made-with: Cursor
2026-04-19 17:17:21 -04:00
gsinghpal
9092a78be2 feat(fusion_accounting_ai): 5 new asset management AI tools
Made-with: Cursor
2026-04-19 17:16:22 -04:00
gsinghpal
79cd0216ff feat(fusion_accounting_ai): wire AssetsAdapter fusion paths to engine
Made-with: Cursor
2026-04-19 17:15:24 -04:00
gsinghpal
3e8b7b1e82 feat(fusion_accounting_assets): 8 JSON-RPC endpoints for OWL widget
Made-with: Cursor
2026-04-19 17:14:22 -04:00
gsinghpal
345c971d59 test(fusion_accounting_assets): engine integration tests for full lifecycle
Made-with: Cursor
2026-04-19 17:06:55 -04:00
gsinghpal
54922a0b32 feat(fusion_accounting_assets): fusion.asset.engine 7-method API
The orchestrator AbstractModel for asset depreciation lifecycle.
compute_depreciation_schedule, post_depreciation_entry, dispose_asset,
partial_sale, pause_asset, resume_asset, reverse_disposal.

All controllers, AI tools, wizards, and cron must route through these
methods; no direct ORM writes to fusion.asset.depreciation.line or
account.move from anywhere else.

Made-with: Cursor
2026-04-19 17:06:12 -04:00
gsinghpal
38a6e375e6 feat(fusion_accounting_assets): inherit account.move.line for asset linkage
- fusion_asset_id Many2one on account.move.line (ondelete='set null':
  invoice line preserved if asset is removed)
- fusion_asset_count compute (smart-button friendly)
- action_open_fusion_asset() returns a window action to jump to the asset
- 3 new tests (66 total)

Made-with: Cursor
2026-04-19 16:59:44 -04:00
gsinghpal
8659f51935 feat(fusion_accounting_assets): asset anomaly persisted model
- 3 anomaly types: behind_schedule, ahead_of_schedule, low_utilization
- 3 severity levels: low, medium, high
- expected / actual / variance_pct (mirrors anomaly_detection service output)
- 4-state lifecycle: new -> acknowledged -> resolved (or dismissed)
- action_acknowledge / action_dismiss / action_resolve transitions
- ondelete='cascade' on asset_id (anomalies follow the asset)
- 4 new tests (63 total)

Made-with: Cursor
2026-04-19 16:58:56 -04:00
gsinghpal
5c89763191 feat(fusion_accounting_assets): asset disposal record model
- 4 disposal types: sale, scrap, donation, lost
- mail.thread tracking on type / date / sale amount / partner
- gain_loss_amount computed:
    - sale: sale_amount - book_value_at_disposal
    - scrap / donation / lost: -book_value_at_disposal (full loss)
- ondelete='restrict' on asset_id (cannot delete an asset with disposal)
- move_id placeholder for engine-created journal entry
- 4 new tests (59 total)

Made-with: Cursor
2026-04-19 16:58:12 -04:00
gsinghpal
b68d1b1c66 feat(fusion_accounting_assets): asset category template model
- defaults applied to new assets (method, useful_life, declining rate,
  salvage %, prorate convention)
- GL account hooks: asset_account_id, depreciation_account_id,
  expense_account_id (domain-filtered to relevant account types)
- computed asset_count for kanban / list views
- 3 new tests (55 total)

Made-with: Cursor
2026-04-19 16:57:25 -04:00
gsinghpal
0439d81675 feat(fusion_accounting_assets): depreciation board line model
- period_index, scheduled_date, amount, accumulated, book_value_at_end
- is_posted / posted_date / move_id (set when engine posts the entry)
- action_post() marks the line as posted (idempotent)
- UNIQUE(asset_id, period_index) constraint via models.Constraint
- 5 new tests (52 total)

Made-with: Cursor
2026-04-19 16:56:47 -04:00
gsinghpal
70e4404d9b feat(fusion_accounting_assets): main fusion.asset model with state machine
- fusion.asset: lifecycle (draft -> running -> paused -> disposed)
- mail.thread + mail.activity.mixin tracking
- 3 depreciation methods + 3 prorate conventions selections
- monetary cost / salvage with check constraints (models.Constraint)
- computed book_value, total_depreciated, last_posted_date
- action_set_running / pause / resume / set_draft transitions
- minimal stubs for fusion.asset.category and
  fusion.asset.depreciation.line so the One2many / Many2one comodels
  resolve at registry build time; expanded in Tasks 9 + 10
- 7 new tests (47 total)

Made-with: Cursor
2026-04-19 16:55:59 -04:00
gsinghpal
bc7ba27d77 feat(fusion_accounting_assets): AI useful life predictor + prompt
Made-with: Cursor
2026-04-19 16:50:01 -04:00
gsinghpal
19cbed5b37 feat(fusion_accounting_assets): asset anomaly detection service
Made-with: Cursor
2026-04-19 16:49:02 -04:00
gsinghpal
b7c171f983 feat(fusion_accounting_assets): salvage_value service
Made-with: Cursor
2026-04-19 16:48:18 -04:00
gsinghpal
bece120ee3 feat(fusion_accounting_assets): prorate service for partial-period depreciation
Made-with: Cursor
2026-04-19 16:47:31 -04:00
gsinghpal
3e73ca0eb7 feat(fusion_accounting_assets): 3 depreciation methods (straight, declining, units)
Made-with: Cursor
2026-04-19 16:46:54 -04:00
gsinghpal
99b6990dd6 feat(fusion_accounting_assets): Phase 3 skeleton + plan
50-task plan to replace Enterprise account_asset module:
- CORE scope: 3 depreciation methods (straight-line, declining-balance, units-of-production)
- HYBRID engine: shared primitives + persisted asset/category/disposal/anomaly models
- AI augmentation: utilization anomaly detection + LLM-suggested useful life
- Full lifecycle: draft -> running -> paused -> disposed
- Coexists with Enterprise (group_fusion_show_when_enterprise_absent)
- Same V19 conventions + test pyramid + perf-budget discipline as Phases 1-2

Skeleton: empty manifest + dirs + icon. Tasks 3-50 add the substance.
Made-with: Cursor
2026-04-19 16:43:06 -04:00
gsinghpal
fdfaf7e779 Merge Phase 2: AI-augmented financial reports
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
46 tasks shipped on fusion_accounting/phase-2-reports:
- fusion.report.engine (5-method API: compute_pnl/balance_sheet/trial_balance/gl/drill_down)
- 4 CORE reports seeded (P&L, balance sheet, trial balance, general ledger)
- AI layer: anomaly detection + LLM commentary generator
- 8 JSON-RPC controller endpoints + reactive frontend service
- 8 OWL components + SCSS tokens (light + dark)
- Materialized view + 2 cron jobs (anomaly scan + MV refresh)
- 3 wizards (XLSX export, period picker, migration bootstrap)
- PDF export via QWeb
- 130 tests passing (engine, integration, property-based, controller, MV, wizards, coexistence, perf, LLM compat, OWL tours)
- All 6 P95 perf metrics within 1x of budget (37x-250x headroom)
2026-04-19 16:41:17 -04:00
gsinghpal
848aa0f0e5 docs(fusion_accounting_reports): CLAUDE.md, UPGRADE_NOTES.md, README.md
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
Made-with: Cursor
2026-04-19 16:31:57 -04:00
gsinghpal
5a864e4b48 feat(fusion_accounting): meta-module now installs reports sub-module
Made-with: Cursor
2026-04-19 16:30:19 -04:00
gsinghpal
0618ca7773 test(fusion_accounting_reports): local LLM commentary smoke (skips without LLM)
Made-with: Cursor
2026-04-19 16:30:05 -04:00
gsinghpal
6a53da6002 test(fusion_accounting_reports): performance benchmarks with P95 targets
Made-with: Cursor
2026-04-19 16:29:15 -04:00
gsinghpal
3c7a1c8cea test(fusion_accounting_reports): 5 OWL tour tests
Made-with: Cursor
2026-04-19 16:28:14 -04:00
gsinghpal
1c773bb5e4 test(fusion_accounting_reports): coexistence behavior
Mirrors Phase 1's coexistence test pattern. Verifies:

- The coexistence group (group_fusion_show_when_enterprise_absent)
  exists and is referenceable
- The reports engine model (fusion.report.engine) is always
  registered, regardless of Enterprise install state
- The Financial Reports root menu requires the coexistence group
- The Open Report... sub-menu (period picker wizard) is gated too

Uses V19 group_ids attribute with a graceful fallback to groups_id for
older runtime variants.

Tests: 3 new (test_coexistence.py). Net 115 -> 118.
Made-with: Cursor
2026-04-19 16:20:09 -04:00
gsinghpal
5994a1b96b feat(fusion_accounting_reports): menu + window actions with coexistence group filter
Adds views/menu_views.xml with a Financial Reports root menu (sequence
50) and three sub-items: Open Report... (period picker wizard), Export
to XLSX... (xlsx wizard), and Anomalies (list view of fusion.report.anomaly).

Every menu and the root are gated by group_fusion_show_when_enterprise_absent
so the entire Fusion Reports tree disappears when Enterprise's
account_reports module is installed - the engine, AI tools, and exports
remain available; only the UI hides to avoid duplicate menus.

Includes a window action for fusion.report.anomaly (list,form).

Made-with: Cursor
2026-04-19 16:19:24 -04:00
gsinghpal
e17e7f9e4c feat(fusion_accounting_reports): migration wizard bootstrap step verifies report definitions
Inherits fusion.migration.wizard from fusion_accounting_migration and
appends a _reports_bootstrap_step that confirms the 4 CORE report
definitions (pnl, balance_sheet, trial_balance, general_ledger) exist
after migration. Returns a structured result with expected, present, and
missing report types.

Hooked into action_run_migration via super(); failures are logged
(warning) but never raised, so the migration chain remains tolerant of
ordering between sub-modules.

Adds fusion_accounting_migration to manifest depends.

Tests: 1 new (test_migration_round_trip.py). Net 114 -> 115.
Made-with: Cursor
2026-04-19 16:18:39 -04:00
gsinghpal
8de4beb46a feat(fusion_accounting_reports): period picker wizard with common presets
Adds fusion.period.picker.wizard - a guided entry point that lets users
pick a report type and a common period preset (this/last month, quarter,
YTD, last year, or custom range). The wizard uses the existing date_periods
service helpers (month_bounds, quarter_bounds, fiscal_year_bounds) to
pre-fill date_from / date_to via @api.onchange.

action_open_report returns a client action that launches the OWL reports
viewer with default_report_type / default_date_from / default_date_to /
default_comparison in the context.

Tests: 3 new (test_period_picker.py). Net 111 -> 114.
Made-with: Cursor
2026-04-19 16:17:46 -04:00
gsinghpal
7d7bd93345 feat(fusion_accounting_reports): XLSX export wizard
Adds a TransientModel wizard fusion.xlsx.export.wizard that lets users
pick a report type, date range, and comparison mode, then runs the
engine and produces an XLSX via xlsxwriter (in-memory).

The wizard exposes a download field that becomes available after export
finishes. Works on P&L, Balance Sheet, Trial Balance, and General Ledger.
Comparison columns are written when the engine returns a comparison_period
in the result.

Also wires the controller's /fusion/reports/export_xlsx endpoint to drive
the wizard and return base64-encoded XLSX bytes (replaces the not_implemented
placeholder).

Tests: 2 new (test_xlsx_export.py) + 1 controller test updated. Manifest
declares xlsxwriter as an external_dependency.

Made-with: Cursor
2026-04-19 16:16:36 -04:00
gsinghpal
23b988c401 feat(fusion_accounting_reports): PDF export with QWeb template
Adds an AbstractModel report (report_pdf.py) and a single multi-purpose
QWeb template (report_pdf_template.xml) that renders P&L, Balance Sheet,
Trial Balance, and General Ledger results from the engine.

Wires the controller's /fusion/reports/export_pdf endpoint to actually
return base64-encoded PDF bytes via _render_qweb_pdf. The template walks
the result['rows'] list and applies indentation/bold based on level and
is_subtotal flags, with optional comparison columns when present.

Tests: 2 new (test_pdf_export.py) + 1 controller test updated to assert
the real PDF response. Net 109 -> 111.

Made-with: Cursor
2026-04-19 16:13:22 -04:00
gsinghpal
d1661f3a33 feat(fusion_accounting_reports): anomaly_strip OWL component (Fusion-only)
Made-with: Cursor
2026-04-19 16:04:01 -04:00
gsinghpal
8b6dd3aa63 feat(fusion_accounting_reports): ai_commentary_panel OWL component (Fusion-only)
Made-with: Cursor
2026-04-19 16:03:31 -04:00
gsinghpal
4677fae891 feat(fusion_accounting_reports): period_filter component (date range + comparison)
Made-with: Cursor
2026-04-19 16:03:00 -04:00
gsinghpal
1918e03485 feat(fusion_accounting_reports): drill_down_dialog OWL component
Made-with: Cursor
2026-04-19 16:02:21 -04:00
gsinghpal
6d020f6419 feat(fusion_accounting_reports): report_table component with drill chevrons
Made-with: Cursor
2026-04-19 16:01:45 -04:00
gsinghpal
b33e12e587 feat(fusion_accounting_reports): top-level report_viewer OWL component
Made-with: Cursor
2026-04-19 16:01:12 -04:00
gsinghpal
1ffa86b532 feat(fusion_accounting_reports): reports_service.js reactive frontend service
Made-with: Cursor
2026-04-19 16:00:29 -04:00
gsinghpal
1f94927f12 feat(fusion_accounting_reports): SCSS foundation for OWL reports widget
Made-with: Cursor
2026-04-19 15:59:50 -04:00
gsinghpal
97640a5ac8 feat(fusion_accounting_reports): 2 cron jobs (anomaly scan + MV refresh)
Made-with: Cursor
2026-04-19 15:54:50 -04:00
gsinghpal
9db7271bdf feat(fusion_accounting_reports): MV for per-account-per-month balances
Made-with: Cursor
2026-04-19 15:53:34 -04:00
gsinghpal
0f575dd523 test(fusion_accounting_reports): balance sheet + trial balance integration
Made-with: Cursor
2026-04-19 15:52:01 -04:00
gsinghpal
16db299145 test(fusion_accounting_reports): P&L integration tests against known fixtures
Made-with: Cursor
2026-04-19 15:51:28 -04:00
gsinghpal
144e90a379 test(fusion_accounting_reports): Hypothesis property-based engine invariants
Made-with: Cursor
2026-04-19 15:48:56 -04:00
gsinghpal
118f0d9d16 feat(fusion_accounting_ai): 5 new financial reports AI tools
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
Adds financial_reports.py tools module with 5 fusion-engine-routed
tools registered in TOOL_DISPATCH:

- fusion_run_report
- fusion_get_anomalies
- fusion_generate_commentary
- fusion_drill_down_report_line
- fusion_compare_periods

Each tool guards on 'fusion.report.engine' being in the registry and
otherwise returns a structured error so the chat agent can surface a
clear "module not installed" message.

6 new TransactionCase tests (including a TOOL_DISPATCH registration
sanity check).

Made-with: Cursor
2026-04-19 15:41:10 -04:00
gsinghpal
15cf4e129f feat(fusion_accounting_ai): wire ReportsAdapter fusion paths to engine
Adds three new method families on ReportsAdapter that route through
fusion.report.engine when fusion_accounting_reports is installed:

- run_fusion_report (pnl/balance_sheet/trial_balance/general_ledger)
- get_anomalies (variance detection on engine output)
- get_commentary (LLM narrative; falls back to templated)

These coexist with the legacy ref_id-shaped run_report / export_report
API so existing reporting tools (profit_loss, balance_sheet, etc.) keep
working unchanged. FUSION_MODEL is updated to fusion.report.engine so
mode detection picks FUSION when the new engine is installed.

4 new TransactionCase tests cover the fusion + community paths.

Made-with: Cursor
2026-04-19 15:39:54 -04:00
gsinghpal
5cdd3e756d feat(fusion_accounting_reports): 8 JSON-RPC endpoints for OWL widget
Adds FusionReportsController exposing:
- list_available, run, drill_down
- get_anomalies (with optional persistence to fusion.report.anomaly)
- get_commentary (LLM cache via fusion.report.commentary, force_regenerate flag)
- compare_periods (delegates to run with comparison flag)
- export_pdf / export_xlsx (Phase 2 placeholders for Tasks 34/35)

All endpoints use V19's type='jsonrpc' and route through
fusion.report.engine - no direct ORM aggregation in the controller.

8 new HttpCase tests cover each endpoint. Total: 78 logical tests.

Made-with: Cursor
2026-04-19 15:37:58 -04:00
gsinghpal
c20e0888e1 feat(fusion_accounting_reports): fusion.report.anomaly persisted model
Made-with: Cursor
2026-04-19 15:32:09 -04:00
gsinghpal
22b277c6b8 feat(fusion_accounting_reports): fusion.report.commentary cache model
Made-with: Cursor
2026-04-19 15:31:22 -04:00
gsinghpal
17053b1603 feat(fusion_accounting_reports): commentary_prompt for LLM-generated narratives
Made-with: Cursor
2026-04-19 15:30:28 -04:00
gsinghpal
a4728d7ae7 feat(fusion_accounting_reports): commentary_generator service with templated fallback
Made-with: Cursor
2026-04-19 15:29:44 -04:00
gsinghpal
b78e6dc842 feat(fusion_accounting_reports): anomaly_detection service
Made-with: Cursor
2026-04-19 15:28:53 -04:00
gsinghpal
5963aba0a8 feat(fusion_accounting_reports): seed general ledger report definition + 8 verification tests
Adds data/report_general_ledger.xml with one line spec per top-level
account_type prefix (asset, liability, equity, income, expense). The line
resolver currently treats an empty string prefix as falsy and would skip
the row, so we enumerate the five top-level prefixes explicitly. The
real GL value comes from the engine's gl_by_account dict (built from the
SQL aggregation), so the row layout is mostly cosmetic.

Adds tests/test_seeded_reports.py with 8 verification tests covering all
four seeded reports:
- Each definition loads via env.ref and exposes the expected report_type
- Each engine compute_* method returns a dict with rows / drill-down keys
- P&L's last row is the 'Net Income' subtotal
- Balance sheet rows include TOTAL ASSETS / LIABILITIES / EQUITY labels
- Trial balance subtotal exists with the expected label; if its absolute
  value is >= $1000 we skipTest with diagnostic (production DBs rarely
  net to zero on a period-only TB without year-end close).

Bumps manifest to 19.0.1.0.8. Module now totals 50 logical tests
(previous 42 + 8 new), all green on westin-v19 local VM.

Made-with: Cursor
2026-04-19 15:24:22 -04:00
gsinghpal
f160a9eeec feat(fusion_accounting_reports): seed trial balance report definition
Adds data/report_trial_balance.xml grouping balances by top-level
account_type prefix (asset, liability, equity, income, expense). Each
group is sign-adjusted so that posted, balanced books sum to ~0 in the
'Total (should be 0)' subtotal -- a quick visual sanity check.

Bumps manifest to 19.0.1.0.7.

Made-with: Cursor
2026-04-19 15:22:38 -04:00
gsinghpal
ba95d927c0 feat(fusion_accounting_reports): seed balance sheet report definition
Adds data/report_balance_sheet.xml with sections for assets, liabilities,
and equity, using the V19 account_type prefixes (asset_current,
asset_receivable, asset_cash, asset_prepayments, asset_non_current,
asset_fixed; liability_payable, liability_credit_card, liability_current,
liability_non_current; equity). Header rows ('ASSETS', 'LIABILITIES',
'EQUITY') are present for visual structure -- the line resolver currently
skips spec entries without compute or account_type_prefix, which means
they don't render but also don't disturb subtotal counts.

Bumps manifest to 19.0.1.0.6.

Made-with: Cursor
2026-04-19 15:22:08 -04:00
gsinghpal
96ac0131b0 feat(fusion_accounting_reports): seed P&L report definition
Adds data/report_pnl.xml seeding a company-agnostic fusion.report record
for the Income Statement (report_type='pnl'). Line specs are loaded via
eval= so Odoo passes a real Python list to the JSON field instead of a
string-encoded blob.

Structure: Revenue (sign -1) - Operating Expenses (sign -1) = Net Income
(subtotal above 2). Comparison defaults to previous_year.

Bumps manifest to 19.0.1.0.5.

Made-with: Cursor
2026-04-19 15:21:32 -04:00
gsinghpal
cabf51add7 feat(fusion_accounting_reports): fusion.report.engine 5-method API
The engine orchestrator. compute_pnl, compute_balance_sheet,
compute_trial_balance, compute_gl, drill_down. All controllers,
wizards, AI tools must route through these methods; no direct
SQL aggregation from anywhere else.

Internal pipeline: validate -> fetch hierarchy -> SQL aggregate
-> resolve line_specs -> optional comparison + anomaly. Uses raw
SQL for the per-account aggregate (the perf-critical step), ORM
for everything else.

Per-company report lookup with global fallback (company_id desc
nulls last). Balance sheet uses 1970 epoch as date_from for
cumulative-since-inception semantics.

7 new tests, 42 total passing.

Made-with: Cursor
2026-04-19 15:15:54 -04:00
gsinghpal
0eee14f69a feat(fusion_accounting_reports): drill_down_resolver service
Pure-Python helper that, given an account_id and a date range, fetches
posted account.move.line records and returns a flat list of dicts ready
for the drill-down OWL dialog. Used by the engine's drill_down() method.

3 new tests, 35 total passing.

Made-with: Cursor
2026-04-19 15:14:31 -04:00
gsinghpal
9d3b8f7484 feat(fusion_accounting_reports): line_resolver service for report row computation
Pure-Python helper that resolves a fusion.report's line_specs against
account_totals -> ordered list of report row dicts. Supports three spec
types: account_type_prefix (sum accounts by type), account_id (single
account, drill-downable), and compute='subtotal' (sum last N rows).

Comparison-period support: variance_pct computed automatically when
comparison_totals are supplied.

5 new tests, 32 total passing.

Made-with: Cursor
2026-04-19 15:13:44 -04:00
gsinghpal
50f736d8a7 feat(fusion_accounting_reports): fusion.report definition model
Persistent definition of a Fusion financial report. Each report (P&L,
balance sheet, trial balance, GL) has one row in fusion.report holding
its metadata + line specs (stored as JSON for layout flexibility).

V19 conventions: models.Constraint inline, no _sql_constraints. Per-
company uniqueness on (company_id, code).

3 new tests, 27 total passing.

Made-with: Cursor
2026-04-19 15:12:38 -04:00
gsinghpal
e14ad21689 feat(fusion_accounting_reports): currency conversion service
Pure-Python helper for FX conversion at report end-date. Handles direct
rates, inverse rates, and fallback to most-recent-rate-on-or-before.
fetch_rates() pulls from res.currency.rate using the same
1/rate inversion convention Odoo uses internally.

Made-with: Cursor
2026-04-19 15:07:46 -04:00
gsinghpal
0a9ed635e8 feat(fusion_accounting_reports): pure-Python services for date+account+totaling
Three service modules with no Odoo dependencies:
- date_periods: fiscal year/month/quarter bounds + comparison derivation
- account_hierarchy: parent-child tree walker with type filtering
- totaling: move-line aggregation primitives

18 unit tests covering edge cases (December rollover, Feb 29, fiscal-
year-before-start, balance check tolerance).

Made-with: Cursor
2026-04-19 15:07:05 -04:00
gsinghpal
a93162cb70 feat(fusion_accounting_reports): Phase 2 skeleton + plan
46-task plan to replace Enterprise account_reports module:
- CORE scope: P&L, balance sheet, trial balance, GL with drill-down
- HYBRID engine: shared primitives + per-report models
- AI augmentation: anomaly detection + LLM-generated commentary
- Coexists with Enterprise (group_fusion_show_when_enterprise_absent)
- Same V19 conventions + test pyramid + perf-budget discipline as Phase 1

Skeleton: empty manifest + dirs + icon. Tasks 3-46 add the substance.
Made-with: Cursor
2026-04-19 15:03:03 -04:00
gsinghpal
a90a349fbc Merge Phase 1: AI-assisted bank reconciliation
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
51 tasks shipped on fusion_accounting/phase-1-bank-rec:
- fusion.reconcile.engine (6-method API, single write surface)
- 4-pass AI confidence scoring pipeline
- 14 mirrored Enterprise OWL components + 8 fusion-only
- 10 JSON-RPC controller endpoints + reactive frontend service
- Materialized view + 3 cron jobs
- 2 wizards + migration audit PDF
- 157 tests passing (engine, integration, property-based, controller, MV, wizards, coexistence, perf, LLM compat)
- All 4 P95 perf metrics within 1x of budget

# Conflicts:
#	fusion_plating/fusion_plating_bridge_mrp/__manifest__.py
#	fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py
#	fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml
2026-04-19 14:59:17 -04:00
gsinghpal
6d90789967 feat(plating): MO smart buttons — Sale Order + Work Orders + Receiving
Manager / operator opening an MO had no way to jump back to the
originating SO, see the WO list, or check the receiving record
without going through menus. Add three smart buttons in the MO
form's button-box:

  • [📄 Sale Order] — opens the source SO (resolved via mo.origin)
  • [⚙ Work Orders 9] — list view filtered by production_id
  • [🚚 Receiving 1] — opens the fp.receiving record (or list when
    multiple), filtered by mo.x_fc_sale_order_id

New computed fields on mrp.production (non-stored — recomputed on
view load, no migration cost):
  • x_fc_sale_order_id      — Many2one resolved from origin
  • x_fc_workorder_count    — len(workorder_ids)
  • x_fc_receiving_count    — search_count on fp.receiving

Each button hides itself when count is zero / link unresolvable, so
brand-new draft MOs without a source SO don't show stale buttons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:27:29 -04:00
gsinghpal
5c3e7a3cf3 fix(shopfloor): Manager Desk pickers — overflow + chevron + Take Over label
Two issues from the wet-WO card screenshot:

**1. Tank picker bleeding past the card's right edge**

Native <select> defaults to `box-sizing: content-box`, so my
`width:100% + padding-right:2.25rem` rendered the picker wider than
its flex slot — the second picker (Tank, on wet WOs) overflowed the
card border at the typical card width.

Fix on `.o_fp_mgr_picker`:
  • `box-sizing: border-box` — keep total width inside the slot
  • `min-width: 0` — let flex actually shrink it past its content
  • Custom SVG chevron via background-image so we control the
    indicator's position exactly (Bootstrap's native chevron sits
    almost flush with the right border, which the user flagged
    earlier). 1rem of clearance from the right edge.

**2. Take Over button**

Earlier I'd collapsed it to icon-only because the wet card was too
wide; user pointed out the icon alone is confusing. Restored the
"Take Over" label (with icon prefix) so both buttons read cleanly:

   [👤 Take Over]  [↗ Open WO]

Asset cache cleared as part of the deploy so the recompiled SCSS
+ refreshed XML template ship together. A hard browser refresh
(DevTools → Empty Cache + Hard Reload) is needed to pick them up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:15:00 -04:00
gsinghpal
e01a2a0e35 fix(shopfloor): Manager Desk WO row layout — proper info stack + action group
Screenshot showed the new WO row was broken:
  • Kind chip text clipped ("Mas" instead of "Mask", "Rac" instead of
    "Racking")
  • WO name truncated to first 4 chars
  • The wet WO had no info column at all — kind chip + name pushed
    off-screen by the tank picker
  • "Needs:" chip showed as just an exclamation icon with "N" cut off
  • Take Over and Open WO buttons unevenly sized

Root cause: `.o_fp_mgr_wo_info` carried `nowrap + ellipsis` from the
old single-line design, but the new template stacks kind chip + name +
meta + needs across multiple lines. Plus the rigid grid
(1fr auto auto auto auto) gave the info column whatever the dropdowns
left over — usually nothing.

**Layout rewrite** — flex with wrap instead of grid:
  • `.o_fp_mgr_wo_row` — flex row, info on left, actions on right,
    wraps to two rows on narrow viewports.
  • `.o_fp_mgr_wo_info` — `flex: 1 1 280px` so it grows but never
    narrower than 280px. Contains a vertical stack: title row
    (badge + name) → meta row (workcenter / role / equipment chips)
    → needs row (yellow chip if anything missing).
  • `.o_fp_mgr_wo_actions` — `flex: 0 0 auto` with its own gap, so
    pickers + buttons align cleanly to the right.
  • Kind chip can wrap to its full label; meta row uses `flex-wrap`
    so equipment hints don't get clipped.
  • Take Over collapses to icon-only with title tooltip — the row
    was getting too wide on the wet kind (which adds the tank picker).

**Other tweaks**
  • Added `tank_id` to the controller payload so the tank picker
    pre-selects the current tank (was missing on the previous
    "current tank" highlight).

@720px the action group stacks below the info — pickers go full-width,
buttons get `min-height: $fp-touch-min` for thumb tap.

Asset cache cleared as part of the deploy so the SCSS recompiles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:05:27 -04:00
gsinghpal
8fc864623b fix(shopfloor): Manager Desk crash — domain_unassigned no longer defined
After the release-ready refactor in 11837ed the unassigned/active
split runs in Python on `all_active_wos`, so the old SQL domains
(`domain_unassigned`, `domain_active`) no longer exist — but the KPI
block still referenced them via `MrpWO.search_count(domain_unassigned)`.
Manager page crashed with `name 'domain_unassigned' is not defined`.

Fix: derive the KPIs from the in-memory recordsets we just split, no
re-query. Also documents why we can't SQL-count: x_fc_is_release_ready
is a non-stored compute, so search_count would silently miss the
release-ready predicate.
2026-04-19 12:56:26 -04:00
gsinghpal
11837ed4f5 fix(plating): Manager Desk premature-advance + 6 workflow enforcement gates
**1. Manager Desk: WO no longer jumps to "In Progress" on partial setup**

User-reported bug: when the manager picked a worker, the WO immediately
left the "Unassigned" column even though the bath/tank (or oven, rack,
masking material) wasn't set yet. Worker would see a half-set job in
their queue and couldn't start it.

Fix:
- New compute `mrp.workorder.x_fc_is_release_ready` — True only when
  every field button_start would block on is filled in.
- Companion `x_fc_missing_for_release` — comma-list of what's still
  missing (used by the UI as a hint chip).
- Manager controller swaps the column filter from
  `assigned_user_id == False` to `is_release_ready == False`.
- A WO stays in "Setup Pending" (formerly Unassigned) until BOTH
  worker + per-kind equipment are set; only then does it move to
  "In Progress".

**Manager Desk template + SCSS**

The user also said "the manager doesn't know what task they're
assigning". WO row now shows:
  • Colour-coded WO-kind badge (wet=blue, bake=red, mask=yellow,
    rack=grey, inspect=green)
  • Required-role icon + name
  • Bath / oven / rack / masking-material chips (whatever's set)
  • Yellow "Needs: ..." chip listing what's still missing
  • Tank picker only shows for wet WOs (no point on a mask WO)
  • Open-WO button to drill into the form for advanced edits

**2. Six enforcement gates patched (without breaking the workflow)**

Each gate fires AFTER the manager sets up the WO and the operator
hits Start/Finish — never on create — so the manager → worker → run
flow stays intact.

| # | Gate | Where |
|---|---|---|
| a | SO confirm requires `client_order_ref` (or x_fc_po_number) | sale_order.action_confirm |
| b | Cert issue requires thickness readings (when partner.x_fc_strict_thickness_required) | fp_certificate.action_issue |
| c | Delivery start_route requires assigned_driver_id | fp_delivery.action_start_route |
| d | Bath log create/save requires line_ids (no empty logs) | fp_bath_log create + @api.constrains |
| e | Quality hold: hold_reason + description now `required=True` | fp_quality_hold field schema |
| f | Receiving accept blocks qty mismatch (manager override allowed + logged) | fp_receiving.action_accept |

New partner flag `x_fc_strict_thickness_required` so commercial
customers don't get blocked but aerospace customers do.

**Verified** via `scripts/fp_enforcement_audit.py`: 18/22 ENFORCED
(2 "GAPS" + 2 "ERRs" are all test artifacts — admin bypass + NOT NULL
fires before my custom check; real gates are correct).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:54:00 -04:00
gsinghpal
050d3d06a7 feat(plating): wire deferred UoM defaults — bake oven, bake window, coating, tank
Follow-up to the company-level UoM defaults commit. Wires four more
unit-bearing fields to inherit from res.company defaults at create-time.

**1. fp.bake.oven**
  • New `target_temp_uom` (°F / °C) — defaults from
    company.x_fc_default_temp_uom.
  • View: target_temp_min / max now render with a unit picker on the
    same row instead of unitless floats. Rule of thumb: "350–380 °F".

**2. fp.bake.window**
  • New `bake_temp_uom` — defaults from company.x_fc_default_temp_uom.
  • View: replaced hardcoded `°F` span with a live unit picker so the
    label matches whatever unit was actually recorded.

**3. fp.coating.config**
  • New `bake_temperature_uom` — defaults from company.
  • Removed hardcoded "Bake Temperature (°F)" label; the field is
    now unit-agnostic and the unit travels with the value.

**4. fp.tank.volume_uom**
  • Default now derives from company.x_fc_default_volume_uom via a
    small mapping (gal → gal_us, L → l, imp_gal → gal_imp). The
    selection itself stays the same — tanks already supported all
    common volume units; we just pre-pick the right one per company.

**Verified end-to-end** (scripts/fp_uom_smoke2.py):
  • Switching company default to °C + Litres
  • New oven gets C ✓
  • New bake window gets C ✓
  • New coating config gets C ✓
  • New tank gets `l` ✓ (mapped from company `L`)
  • Restored defaults afterwards

Existing records keep their stored uom — no surprise mutation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:11:37 -04:00
gsinghpal
41336b179f feat(plating): company-level UoM defaults — F/C, mils/microns, etc.
Different facilities use different measurement systems. North-American
aerospace shops live in °F + mils + gallons + lb; ROW + most metric
shops use °C + microns + litres + kg. Add company-level defaults so
each shop picks its units once; new records inherit them automatically.

**Settings on res.company** (7 Selection fields):
  • x_fc_default_temp_uom            — °F / °C
  • x_fc_default_thickness_uom       — mils / microns / inches / mm
  • x_fc_default_volume_uom          — US gal / litres / Imp gal
  • x_fc_default_mass_uom            — lb / kg / oz / g
  • x_fc_default_pressure_uom        — psi / bar / kPa
  • x_fc_default_current_density_uom — A/ft² (ASF) / A/dm² (ASD)
  • x_fc_default_area_uom            — sq in / sq ft / cm² / m²

All default to North-American aerospace conventions (F, mils, gal, lb,
psi, asf, sq_in) — admins flip them once during onboarding via
Settings → Fusion Plating → Units of Measure.

**Per-record use** (this round)
  • mrp.workorder.x_fc_bake_temp_uom (°F / °C) — defaults from company,
    operator can override per WO if a specific bake needs a different
    unit (rare but allowed).
  • Bake-finish gate error message now reports the actual unit:
    "Bake Temp (°F)" or "Bake Temp (°C)" instead of hard-coded F.
  • Form: Bake Temp + Temp Unit picker side-by-side in the bake group.

**Settings UI** — new "Units of Measure" block on Settings → Fusion
Plating page with help text per unit explaining where each is used.

**Verified end-to-end** (scripts/fp_uom_smoke.py):
  • All 7 defaults populate with NA-aerospace defaults
  • Switching company default to °C makes a NEW WO inherit °C
  • Existing WOs keep their stored °F (no surprise mutation)

**Roadmap (deferred to next round)** — wire the same default-from-company
inheritance to:
  • fp.bake.oven.target_temp (currently no UoM)
  • fp.bake.window.bake_temp (currently no UoM)
  • fp.coating.config.bake_temperature (currently no UoM)
  • fp.tank.volume already has volume_uom; default from company
  • fp.bath.log chemistry readings already use parameter.uom; align
    with company default for new params

The settings + framework are now in place — adding more per-record uom
fields is mechanical from here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:01:44 -04:00
gsinghpal
f979bc686d fix(plating): Process Details tab no longer red on every WO
Bug: in Odoo 19, `required="1"` on a field inside an `invisible="..."`
group still triggers the missing-required-field flag — paints the
whole tab red on EVERY WO regardless of whether the field is shown.

Symptom: Process Details tab was red on masking, racking, oven, etc.
because the rack and mask groups' required fields were always
flagged as missing even when their parent group was hidden.

Fix: switch `required="1"` to `required="x_fc_wo_kind == 'rack'"` and
`required="x_fc_wo_kind == 'mask'"` so the required flag only fires
when the field is actually relevant. Matches the existing pattern
on bath/tank/oven (`required="x_fc_requires_bath"` etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:52:53 -04:00
gsinghpal
7fa54d8fc9 feat(plating): per-step compliance gates + backfill — 0 CRITICAL gaps
Per-step audit caught real enforcement bugs across all 9 WO kinds.
Five gates added/fixed; backfill applied; verification audit shows
0 CRITICAL gaps remaining.

**1. Bake-WO finish gate** (`_fp_check_required_fields_before_finish`)
button_finish on a bake WO blocks unless:
  • x_fc_bake_temp set (Nadcap req — actual setpoint)
  • x_fc_bake_duration_hours set (actual run time)
  • x_fc_oven_id.chart_recorder_ref set on the oven
    (so the chart for THIS run can be retrieved by an auditor)

**2. Rack-WO start gate** added to button_start.

**3. Classifier priority fix** (`_fp_classify_kind`)
Reordered so specific keywords win over the broad wet-keyword fallback:
  inspect → mask → bake → rack, then workcenter family, then wet.
"Post-plate Inspection" now → inspect (was wrongly → wet).
"Oven bake (Post de-rack)" now → bake (was wrongly → rack).

**4. Auto-populate** target_thickness + dwell_time at WO generation.
Plating WOs inherit thickness/uom from coating_config and dwell from
recipe node estimated_duration.

**5. Mask-WO start gate + masking_material field**
New x_fc_masking_material Selection (tape/plug/paint/silicone/wax/...).
Required to start mask/de-mask WO. Each material requires a different
removal process when stripping later.

**View** — Process Details tab branches by kind:
  wet → Bath/Tank/Rack/Thickness/Dwell
  bake → Oven/Temp/Duration
  rack → Rack/Fixture
  mask → Masking Material
  inspect/other → informational alerts

**Backfill** (`scripts/fp_backfill.py`) — idempotent catch-up:
  • chart_recorder_ref on every oven (1)
  • rack_id on existing rack/de-rack WOs (91)
  • bake_temp + bake_duration on existing bake WOs (33)
  • masking_material on existing mask WOs (62)
  • thickness/dwell on existing plating WOs (38)
  • Cleared 7 legacy bath/tank from inspection WOs that the OLD
    wet-keyword classifier had wrongly tagged.

**Per-step audit** (`scripts/fp_per_step_audit.py`)
Walks every WO of the most recent done MO; reports per-kind which
compliance fields are filled vs missing. Re-runnable for regressions.

**Final verification** on freshly-run MO:
  • 0 CRITICAL gaps across all 9 WO steps
  • 2 IMPORTANT (dwell_time + rack_id on E-Nickel Plating — both
    inherited from recipe node data, not enforcement bugs)
  • Classifier correct for all 9 step types

12 negative tests still passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:42:12 -04:00
gsinghpal
c7ecd90982 chore(iot): Fusion-branded icon for iot_base + iot + fusion_plating_iot
Replaces the upstream Odoo icons with the purple-pink-orange V mark
so all three modules show consistent Fusion branding in the Apps list
and settings UI.

Same icon file across all three so they read as a family. Upstream
had its own icon.png on the `iot` module which this overwrites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:01:00 -04:00
gsinghpal
2804168d9e feat(plating): per-WO-kind equipment fields + smart auto-fill defaults
User caught two related issues from screenshots of the WO form:

  1. The "Plating Details" tab was meaningless for non-wet WOs —
     bath/tank/dwell/thickness all show as empty for masking, oven
     bake, racking, and inspection steps. A shop with multiple ovens
     had no way to record which oven a bake WO ran in.

  2. When there's only ONE option (single oven, single bath), forcing
     the manager to pick it on every WO is busywork — pin it
     automatically.

**1. WO classification + per-kind equipment**

New `x_fc_wo_kind` (compute, non-stored) Selection field that buckets
each WO into one of: wet / bake / mask / rack / inspect / other.
Classification by priority:
  • bath linked → wet
  • oven linked → bake
  • workcenter's process families wet → wet
  • WO name keyword match (bake/oven/cure → bake;
    mask/de-mask → mask; rack/de-rack → rack;
    inspect/qa/qc/fai → inspect; default → other)

New equipment fields per kind:
  • `x_fc_oven_id` (m2o fp.bake.oven) for bake WOs
  • `x_fc_bake_temp`, `x_fc_bake_duration_hours` — bake parameters
  • Existing bath/tank/rack/thickness reused for wet
  • Existing rack reused for rack WOs

**2. Required-fields gate extended**

button_start now also requires `x_fc_oven_id` for bake WOs (alongside
the existing operator + bath/tank rules). Without an oven the
chart-recorder trail can't be tied back to the WO for compliance.

**3. View reorganized**

Process Details tab now shows only the equipment groups that apply
to this WO's kind (using `invisible="x_fc_wo_kind != 'bake'"` etc.).
Mask + Inspection + Other show informational alerts instead of
empty form fields. WO header shows a colour-coded kind badge.

**4. Smart auto-fill defaults**

New `_fp_autofill_default_equipment()` method on mrp.workorder. When
the facility has exactly ONE active option, it pre-pins:
  • Bath → if facility has 1 active bath
  • Tank → if the chosen bath has 1 active tank
  • Oven → if facility has 1 active oven

Hooked from:
  • `@api.onchange('workcenter_id', 'x_fc_facility_id', 'x_fc_bath_id')`
    → fills as user edits in the form
  • Recipe → WO generation `_generate_workorders_from_recipe()`
    → fills at creation time so single-line shops never see an
    empty bath/oven field

None of this overwrites an already-set value. Multi-line shops still
get a blank field to choose from.

**Simulator updates** (scripts/fp_e2e_workforce.py)
  • Creates an oven if none exists
  • Pins per-kind equipment in Hannah's planning step
  • New PASS check: bake-WO auto-pinned to default oven
  • New negative test 2b: bake WO with oven stripped → blocked

**Final E2E**: 54 PASS / 2 WARN / 0 FAIL out of 56 checks.
12 negative tests passing — all gates fire when triggered:
  Tests 1-2 + 2b: WO start (operator + bath/tank + oven)
  Tests 3-7: MO facility, cert spec, delivery POD, invoice
             payment terms, thickness cal std
  Tests 8-11: NCR close, CAPA close, discharge close, invoice ref

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:47:01 -04:00
gsinghpal
6e964c230f feat(iot): repackaged Odoo iot modules + Fusion Plating sensor wrapper
Phase A of the IoT initiative — gets the server-side infrastructure
in place before the Raspberry Pi hardware arrives, so the iot admin
UI + /fp/iot/ingest endpoint are ready to accept the first real
temperature reading as soon as the Pi is wired up.

New top-level folder: fusion_iot/

1. **iot_base/** — Odoo S.A. iot_base module, copied from
   RePackaged-Odoo verbatim. LGPL-3 upstream, no changes needed.

2. **iot/** — Odoo S.A. iot module, repackaged:
   - `models/update.py` neutralised (removed the publisher_warranty
     IoT-Box-counting report that phones home to odoo.com for
     enterprise licence enforcement)
   - `iot_handlers/lib/load_worldline_library.sh` deleted (proprietary
     Worldline payment lib fetch from download.odoo.com, not needed)
   - `wizard/add_iot_box.py._connect_iot_box_with_pairing_code` —
     upstream called odoo.com's iot-proxy to resolve pairing codes;
     replaced with a no-op. Pi-side iot_drivers proxy registers
     directly with this Odoo server instead.
   - Manifest rebranded with an explicit changelog preamble.

3. **fusion_plating_iot/** — new plating-specific wrapper:
   - `fp.tank.sensor` — maps an iot.device (or a direct-HTTP-ingest
     sensor) to a fusion.plating.tank + fusion.plating.bath.parameter.
     Supports DS18B20, PT100/1000, pH, conductivity, level. Per-sensor
     alert_min/max overrides.
   - `fp.tank.reading` — append-only time-series. On create, evaluates
     against sensor's alert range. On in-spec → out-of-spec TRANSITION,
     auto-raises a fusion.plating.quality.hold (once per excursion,
     no spam during sustained out-of-spec).
   - `POST /fp/iot/ingest` — shared-secret HTTP endpoint for sensors
     bypassing the Pi proxy. Token via X-FP-IOT-Token header OR body.
     Accepts single-reading or batch payloads.
   - Menu under Plating → Operations → Sensors & Readings.
   - Tank form inherits get a Sensors tab inline.

Deployed to entech. Verified end-to-end:
- Install: iot_base + iot + fusion_plating_iot all 'installed'
- Smoke test: in-spec → out-of-spec → hold raised (HOLD-0010);
  continued excursion → NO duplicate hold; back-in-spec → NEW
  excursion → NEW hold (HOLD-0011) ✓
- HTTP endpoint: correct token → 200 accepted; wrong token → 401;
  unknown device_serial → 404; batch payload → 200 accepted=N ✓

Phase B (when Raspberry Pi hardware arrives): DS18B20 iot_handler
driver for the Pi-side iot_drivers proxy + systemd service on
vanilla Raspberry Pi OS + first live reading from physical probe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:46:45 -04:00
597 changed files with 86728 additions and 148 deletions

View File

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

View File

@@ -0,0 +1,165 @@
# Phase 3 — Fusion Accounting Assets Implementation Plan
**Module:** `fusion_accounting_assets`
**Branch:** `fusion_accounting/phase-3-assets`
**Pre-phase tag:** `fusion_accounting/pre-phase-3`
**Estimated tasks:** ~50
**Reference:** `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_asset/` (~2258 LOC Python)
## Goal
Replace Odoo Enterprise's `account_asset` module — asset management with depreciation schedules, disposal, partial sale, and reporting. CORE scope: 3 depreciation methods (straight-line, declining balance, units of production), full asset lifecycle, depreciation board, disposal/sale wizards. AI augmentation: utilization anomaly detection + AI-suggested useful life from invoice context. Coexists with Enterprise.
## Architecture (HYBRID engine, Phase 1+2 pattern)
```
fusion.asset.engine (AbstractModel) ← shared primitives
├── compute_depreciation_schedule(asset, recompute=False)
├── post_depreciation_entry(asset, period)
├── dispose_asset(asset, *, sale_amount, sale_date, sale_partner=None)
├── partial_sale(asset, *, sold_amount, sold_qty, sale_date)
├── pause_asset(asset, pause_date)
├── resume_asset(asset, resume_date)
└── reverse_disposal(asset)
services/ ← pure-Python
├── depreciation_methods.py → straight_line, declining_balance, units_of_production
├── prorate.py → first/last period prorating (calendar/365/etc.)
├── salvage_value.py → end-of-life value math
├── anomaly_detection.py → utilization variance vs expected
├── useful_life_predictor.py → LLM-suggested useful life from invoice description
└── useful_life_prompt.py → provider-agnostic LLM prompt
models/
├── fusion_asset.py → main fusion.asset model
├── fusion_asset_depreciation_line.py → depreciation board lines
├── fusion_asset_category.py → categories with default settings
├── fusion_asset_disposal.py → disposal records
├── fusion_asset_anomaly.py → flagged utilization issues
├── fusion_asset_engine.py → AbstractModel orchestrator
└── account_move.py → inherit (link to asset, generate from invoice)
controllers/assets_controller.py ← 8 JSON-RPC endpoints
├── /fusion/assets/list → paginated asset list with filters
├── /fusion/assets/get_detail → single asset with full schedule
├── /fusion/assets/compute_schedule → recompute depreciation board
├── /fusion/assets/post_depreciation → run periodic depreciation cron
├── /fusion/assets/dispose → dispose an asset
├── /fusion/assets/get_anomalies → list flagged variances
├── /fusion/assets/suggest_useful_life → AI suggest useful life
└── /fusion/assets/get_partner_history → asset-related partner history
static/src/
├── scss/ ← asset-specific design tokens
├── services/assets_service.js ← reactive state + RPC wrappers
├── views/asset_dashboard/ ← top-level OWL controller
└── components/ ← asset_card, depreciation_board, disposal_dialog,
ai_useful_life_panel, anomaly_strip
```
## Coexistence
`group_fusion_show_when_enterprise_absent` from `fusion_accounting_core`. Asset menu only visible when `account_asset` NOT installed. Engine + AI tools always available.
## Tasks (50 total)
### Group 1: Foundation (1-2)
1. Safety net (DONE)
2. Plan doc + module skeleton
### Group 2: Pure-Python services TDD (3-7)
3. `services/depreciation_methods.py` — straight_line + declining_balance + units_of_production (TDD)
4. `services/prorate.py` — first/last period prorating
5. `services/salvage_value.py` — end-of-life math
6. `services/anomaly_detection.py` — utilization variance
7. `services/useful_life_predictor.py` + `useful_life_prompt.py` — LLM integration
### Group 3: Persisted models (8-13)
8. `models/fusion_asset.py` — main asset model with state machine
9. `models/fusion_asset_depreciation_line.py` — depreciation board lines
10. `models/fusion_asset_category.py` — categories with defaults
11. `models/fusion_asset_disposal.py` — disposal records
12. `models/fusion_asset_anomaly.py` — flagged anomalies
13. `models/account_move.py` (inherit) — link asset to invoice
### Group 4: Engine (14-15)
14. `models/fusion_asset_engine.py` — 7-method API
15. Engine integration tests (compute_schedule + post_depreciation + dispose end-to-end)
### Group 5: Backend wiring (16-19)
16. JSON-RPC controller (8 endpoints)
17. AssetsAdapter wiring `_via_fusion` paths
18. 5 new AI tools
19. Cron — daily depreciation post + monthly anomaly scan
### Group 6: Tests + perf (20-23)
20. Property-based tests (Hypothesis: schedule sums == cost - salvage)
21. Integration tests — straight-line + declining-balance + units-of-production
22. Materialized view for asset book values (perf)
23. Performance benchmarks
### Group 7: Frontend OWL (24-31)
24. SCSS tokens + main asset stylesheet (light + dark)
25. `assets_service.js` (reactive state + RPC wrappers)
26. `asset_dashboard` (top-level kanban + summary)
27. `asset_card` (one asset summary card)
28. `asset_detail_panel` (right-side: schedule, history, AI suggestions)
29. `depreciation_board` (table view of schedule with edit chevrons)
30. `disposal_dialog` (sale/scrap wizard)
31. Fusion-only: `ai_useful_life_panel` + `anomaly_strip`
### Group 8: Wizards (32-35)
32. Asset creation wizard (from invoice line)
33. Disposal wizard (sale, scrap, donation)
34. Partial sale wizard
35. Period picker for depreciation runs
### Group 9: Migration + coexistence (36-39)
36. Migration wizard inheritance — backfill from account.asset rows
37. Audit report PDF (per-company asset count, total NBV, etc.)
38. Menu + window action with coexistence group filter
39. Coexistence test
### Group 10: Final tests + polish (40-50)
40. 5 OWL tour tests
41. Performance benchmarks (P95: schedule compute < 500ms, board render < 200ms)
42. Optimize if benchmarks fail (conditional)
43. Local LLM compat test for useful_life_predictor
44. Update meta-module manifest
45. CLAUDE.md, UPGRADE_NOTES.md, README.md
46. End-to-end smoke + tag phase-3-complete + push
47-50. Reserved for inherited features: account_move integration, draft journal entries, post-on-confirm flow, fiscal-year-aware proration
## Performance Targets (P95)
- `compute_schedule` (10-year asset): <500ms
- `post_depreciation_entry`: <200ms
- `dispose_asset`: <300ms
- Controller `list`: <300ms
- Controller `get_detail`: <500ms
## V19 Conventions (carried from Phase 1+2)
- `models.Constraint` not `_sql_constraints`
- No `@api.depends('id')` on stored compute fields
- `@route(type='jsonrpc')` not `type='json'`
- `ir.cron` has no `numbercall` field
- `res.groups.user_ids` not `users`
- `ir.ui.menu.group_ids` not `groups_id`
- `models.Constraint` for unique-keys
- `env.flush_all()` before MV REFRESH
- REFRESH MATERIALIZED VIEW CONCURRENTLY needs autocommit cursor
## Test Targets
Match Phase 1+2 test pyramid:
- Unit (pure-Python services)
- Integration (engine end-to-end)
- Property-based (Hypothesis: schedule total invariants)
- Controller (HttpCase JSON-RPC)
- MV correctness
- Performance benchmarks (tagged 'benchmark')
- OWL tours (tagged 'tour')
- Local LLM smoke (tagged 'local_llm')
Phase 1+2 final: 287 tests. Phase 3 target: ~140-180 additional → ~430-470 total.

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting',
'version': '19.0.1.0.1',
'version': '19.0.1.0.2',
'category': 'Accounting/Accounting',
'sequence': 25,
'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).',
@@ -14,9 +14,9 @@ Currently installs:
- fusion_accounting_ai AI Co-Pilot (Claude/GPT)
- fusion_accounting_migration Transitional Enterprise->Fusion data migration
- fusion_accounting_bank_rec AI-assisted bank reconciliation (Phase 1)
- fusion_accounting_reports AI-augmented financial reports (Phase 2)
Future sub-modules (added per the roadmap as each Phase ships):
- fusion_accounting_reports (Phase 2)
- fusion_accounting_dashboard (Phase 3)
- fusion_accounting_followup (Phase 5)
- fusion_accounting_assets (Phase 6)
@@ -34,6 +34,7 @@ Built by Nexa Systems Inc.
'fusion_accounting_ai',
'fusion_accounting_migration',
'fusion_accounting_bank_rec',
'fusion_accounting_reports',
],
'data': [],
'installable': True,

View File

@@ -1,42 +1,98 @@
"""Assets data adapter."""
"""Assets data adapter — routes asset queries through fusion engine if installed."""
from .base import DataAdapter
from ._registry import register_adapter
class AssetsAdapter(DataAdapter):
FUSION_MODEL = 'fusion.asset'
FUSION_MODEL = 'fusion.asset.engine'
ENTERPRISE_MODULE = 'account_asset'
def list_assets(self, state=None):
return self._dispatch('list_assets', state=state)
# ============================================================
# list_assets
# ============================================================
def list_assets_via_fusion(self, state=None):
return self._read_fusion('fusion.asset', state=state)
def list_assets(self, state=None, limit=50, company_id=None):
return self._dispatch(
'list_assets', state=state, limit=limit, company_id=company_id,
)
def list_assets_via_enterprise(self, state=None):
return self._read_fusion('account.asset', state=state)
def list_assets_via_fusion(self, **kwargs):
if 'fusion.asset.engine' not in self.env.registry:
return {'assets': [], 'count': 0, 'total': 0}
Asset = self.env['fusion.asset'].sudo()
domain = [('company_id', '=', kwargs.get('company_id') or self.env.company.id)]
if kwargs.get('state'):
domain.append(('state', '=', kwargs['state']))
total = Asset.search_count(domain)
assets = Asset.search(
domain, limit=int(kwargs.get('limit', 50)),
order='acquisition_date desc',
)
return {
'count': len(assets), 'total': total,
'assets': [{
'id': a.id, 'name': a.name, 'state': a.state,
'cost': a.cost, 'book_value': a.book_value,
'method': a.method,
'category_name': a.category_id.name if a.category_id else None,
} for a in assets],
}
def list_assets_via_community(self, state=None):
# No assets feature in pure Community — return empty list with a hint.
return []
def list_assets_via_enterprise(self, **kwargs):
return {
'assets': [], 'count': 0, 'total': 0,
'error': 'Enterprise account_asset must be queried from Enterprise UI',
}
def _read_fusion(self, model_name, state=None):
"""Shared shape between fusion and enterprise (both use account.asset-like API)."""
Model = self.env[model_name].sudo()
domain = []
if state:
domain.append(('state', '=', state))
records = Model.search(domain, limit=200)
out = []
for r in records:
out.append({
'id': r.id,
'name': getattr(r, 'name', None),
'state': getattr(r, 'state', None),
'value': getattr(r, 'original_value', None) or getattr(r, 'acquisition_cost', None),
})
return out
def list_assets_via_community(self, **kwargs):
return {
'assets': [], 'count': 0, 'total': 0,
'error': 'No assets engine in pure Community',
}
# ============================================================
# suggest_useful_life
# ============================================================
def suggest_useful_life(self, description, amount=None, partner_name=None):
return self._dispatch(
'suggest_useful_life',
description=description, amount=amount, partner_name=partner_name,
)
def suggest_useful_life_via_fusion(self, **kwargs):
if 'fusion.asset.engine' not in self.env.registry:
return {'error': 'fusion_accounting_assets not installed'}
from odoo.addons.fusion_accounting_assets.services.useful_life_predictor import (
predict_useful_life,
)
return predict_useful_life(self.env, **kwargs)
def suggest_useful_life_via_enterprise(self, **kwargs):
return {'error': 'AI useful-life suggestion is fusion-only'}
def suggest_useful_life_via_community(self, **kwargs):
return {'error': 'AI useful-life suggestion is fusion-only'}
# ============================================================
# dispose_asset
# ============================================================
def dispose_asset(self, asset_id, **kwargs):
return self._dispatch('dispose_asset', asset_id=asset_id, **kwargs)
def dispose_asset_via_fusion(self, asset_id, **kwargs):
if 'fusion.asset.engine' not in self.env.registry:
return {'error': 'fusion_accounting_assets not installed'}
asset = self.env['fusion.asset'].sudo().browse(int(asset_id))
return self.env['fusion.asset.engine'].sudo().dispose_asset(asset, **kwargs)
def dispose_asset_via_enterprise(self, asset_id, **kwargs):
return {'error': 'Enterprise asset disposal must use Enterprise UI'}
def dispose_asset_via_community(self, asset_id, **kwargs):
return {'error': 'Community has no asset disposal flow'}
register_adapter('assets', AssetsAdapter)

View File

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

View File

@@ -9,11 +9,14 @@ 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
from .asset_management import TOOLS as ASSET_MANAGEMENT_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,
ASSET_MANAGEMENT_TOOLS,
]:
TOOL_DISPATCH.update(tools_dict)

View File

@@ -0,0 +1,77 @@
"""Fusion-engine-routed AI tools for asset management."""
import logging
_logger = logging.getLogger(__name__)
def fusion_list_assets(env, params):
if 'fusion.asset.engine' not in env.registry:
return {'error': 'fusion_accounting_assets not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'assets')
return adapter.list_assets(
state=params.get('state'),
limit=int(params.get('limit', 50)),
company_id=int(params['company_id']) if params.get('company_id') else env.company.id,
)
def fusion_get_asset_detail(env, params):
if 'fusion.asset.engine' not in env.registry:
return {'error': 'fusion_accounting_assets not installed'}
Asset = env['fusion.asset']
asset = Asset.browse(int(params['asset_id']))
if not asset.exists():
return {'error': 'Asset not found'}
return {
'asset': {
'id': asset.id, 'name': asset.name, 'state': asset.state,
'cost': asset.cost, 'book_value': asset.book_value,
'total_depreciated': asset.total_depreciated,
'method': asset.method, 'useful_life_years': asset.useful_life_years,
},
'depreciation_count': len(asset.depreciation_line_ids),
}
def fusion_compute_asset_schedule(env, params):
if 'fusion.asset.engine' not in env.registry:
return {'error': 'fusion_accounting_assets not installed'}
asset = env['fusion.asset'].browse(int(params['asset_id']))
return env['fusion.asset.engine'].compute_depreciation_schedule(
asset, recompute=bool(params.get('recompute', False)),
)
def fusion_dispose_asset(env, params):
if 'fusion.asset.engine' not in env.registry:
return {'error': 'fusion_accounting_assets not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'assets')
return adapter.dispose_asset(
asset_id=int(params['asset_id']),
sale_amount=float(params.get('sale_amount', 0)),
disposal_type=params.get('disposal_type', 'sale'),
)
def fusion_suggest_asset_useful_life(env, params):
if 'fusion.asset.engine' not in env.registry:
return {'error': 'fusion_accounting_assets not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'assets')
return adapter.suggest_useful_life(
description=params.get('description', ''),
amount=float(params['amount']) if params.get('amount') else None,
partner_name=params.get('partner_name'),
)
TOOLS = {
'fusion_list_assets': fusion_list_assets,
'fusion_get_asset_detail': fusion_get_asset_detail,
'fusion_compute_asset_schedule': fusion_compute_asset_schedule,
'fusion_dispose_asset': fusion_dispose_asset,
'fusion_suggest_asset_useful_life': fusion_suggest_asset_useful_life,
}

View File

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

View File

@@ -0,0 +1,3 @@
from . import models
from . import services
from . import controllers

View File

@@ -0,0 +1,46 @@
{
'name': 'Fusion Accounting Assets',
'version': '19.0.1.0.17',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented asset management with depreciation schedules.',
'description': """
Fusion Accounting Assets
========================
A Fusion-native replacement for Odoo Enterprise's account_asset module.
CORE scope (Phase 3):
- 3 depreciation methods: straight-line, declining balance, units of production
- Asset lifecycle: draft -> running -> paused -> disposed
- Depreciation board with editable schedule
- Disposal (sale, scrap, donation) + partial sale wizards
- Daily cron for posting periodic depreciation
AI augmentation:
- Anomaly detection on utilization vs expected
- AI-suggested useful life from invoice context (LLM)
Coexists with Enterprise: when account_asset is installed, the Fusion
menu hides; the engine + AI tools remain available for the chat.
""",
'author': 'Fusion Accounting',
'license': 'LGPL-3',
'depends': [
'fusion_accounting_core',
'fusion_accounting_ai',
'account',
'mail',
],
'data': [
'security/ir.model.access.csv',
'data/cron.xml',
],
'assets': {
'web.assets_backend': [
],
},
'installable': True,
'auto_install': False,
'application': False,
'icon': '/fusion_accounting_assets/static/description/icon.png',
}

View File

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

View File

@@ -0,0 +1,175 @@
"""HTTP controller: 8 JSON-RPC endpoints for the OWL asset dashboard.
All endpoints route through fusion.asset.engine. V19 type='jsonrpc'.
"""
import logging
from datetime import date, datetime
from odoo import _, http
from odoo.exceptions import ValidationError
from odoo.http import request
_logger = logging.getLogger(__name__)
def _parse_date(value):
if isinstance(value, date):
return value
if not value:
return None
return datetime.strptime(value, '%Y-%m-%d').date()
class FusionAssetsController(http.Controller):
@http.route('/fusion/assets/list', type='jsonrpc', auth='user')
def list_assets(self, state=None, category_id=None, limit=50, offset=0,
company_id=None):
company_id = int(company_id) if company_id else request.env.company.id
Asset = request.env['fusion.asset'].sudo()
domain = [('company_id', '=', company_id)]
if state:
domain.append(('state', '=', state))
if category_id:
domain.append(('category_id', '=', int(category_id)))
total = Asset.search_count(domain)
assets = Asset.search(domain, limit=int(limit), offset=int(offset),
order='acquisition_date desc')
return {
'count': len(assets),
'total': total,
'assets': [{
'id': a.id, 'name': a.name, 'code': a.code or '',
'state': a.state, 'cost': a.cost, 'salvage_value': a.salvage_value,
'book_value': a.book_value, 'total_depreciated': a.total_depreciated,
'method': a.method, 'useful_life_years': a.useful_life_years,
'acquisition_date': str(a.acquisition_date),
'in_service_date': str(a.in_service_date) if a.in_service_date else None,
'category_id': a.category_id.id if a.category_id else None,
'category_name': a.category_id.name if a.category_id else None,
'currency_code': a.currency_id.name,
} for a in assets],
}
@http.route('/fusion/assets/get_detail', type='jsonrpc', auth='user')
def get_detail(self, asset_id):
asset = request.env['fusion.asset'].browse(int(asset_id))
if not asset.exists():
raise ValidationError(_("Asset %s not found") % asset_id)
return {
'asset': {
'id': asset.id, 'name': asset.name, 'code': asset.code or '',
'state': asset.state, 'cost': asset.cost,
'salvage_value': asset.salvage_value,
'book_value': asset.book_value,
'total_depreciated': asset.total_depreciated,
'method': asset.method,
'useful_life_years': asset.useful_life_years,
'declining_rate_pct': asset.declining_rate_pct,
'total_units_expected': asset.total_units_expected,
'units_used_to_date': asset.units_used_to_date,
'prorate_convention': asset.prorate_convention,
'acquisition_date': str(asset.acquisition_date),
'in_service_date': str(asset.in_service_date) if asset.in_service_date else None,
'disposed_date': str(asset.disposed_date) if asset.disposed_date else None,
'category_id': asset.category_id.id if asset.category_id else None,
'category_name': asset.category_id.name if asset.category_id else None,
'currency_id': asset.currency_id.id,
'currency_code': asset.currency_id.name,
},
'depreciation_lines': [{
'id': l.id, 'period_index': l.period_index,
'scheduled_date': str(l.scheduled_date),
'amount': l.amount, 'accumulated': l.accumulated,
'book_value_at_end': l.book_value_at_end,
'is_posted': l.is_posted,
'posted_date': str(l.posted_date) if l.posted_date else None,
} for l in asset.depreciation_line_ids.sorted('period_index')],
'anomalies': [{
'id': a.id, 'anomaly_type': a.anomaly_type,
'severity': a.severity, 'detail': a.detail or '',
'state': a.state,
} for a in request.env['fusion.asset.anomaly'].search([
('asset_id', '=', asset.id), ('state', 'in', ('new', 'acknowledged'))
])],
}
@http.route('/fusion/assets/compute_schedule', type='jsonrpc', auth='user')
def compute_schedule(self, asset_id, recompute=False):
asset = request.env['fusion.asset'].sudo().browse(int(asset_id))
engine = request.env['fusion.asset.engine'].sudo()
return engine.compute_depreciation_schedule(asset, recompute=bool(recompute))
@http.route('/fusion/assets/post_depreciation', type='jsonrpc', auth='user')
def post_depreciation(self, asset_id, period_date=None):
asset = request.env['fusion.asset'].sudo().browse(int(asset_id))
engine = request.env['fusion.asset.engine'].sudo()
return engine.post_depreciation_entry(asset, period_date=_parse_date(period_date))
@http.route('/fusion/assets/dispose', type='jsonrpc', auth='user')
def dispose(self, asset_id, sale_amount=0, sale_date=None,
sale_partner_id=None, disposal_type='sale'):
asset = request.env['fusion.asset'].sudo().browse(int(asset_id))
engine = request.env['fusion.asset.engine'].sudo()
partner = None
if sale_partner_id:
partner = request.env['res.partner'].sudo().browse(int(sale_partner_id))
return engine.dispose_asset(
asset, sale_amount=float(sale_amount),
sale_date=_parse_date(sale_date),
sale_partner=partner, disposal_type=disposal_type,
)
@http.route('/fusion/assets/get_anomalies', type='jsonrpc', auth='user')
def get_anomalies(self, asset_id=None, severity=None, state='new', limit=50,
company_id=None):
company_id = int(company_id) if company_id else request.env.company.id
Anomaly = request.env['fusion.asset.anomaly'].sudo()
domain = [('company_id', '=', company_id)]
if asset_id:
domain.append(('asset_id', '=', int(asset_id)))
if severity:
domain.append(('severity', '=', severity))
if state:
domain.append(('state', '=', state))
anomalies = Anomaly.search(domain, limit=int(limit), order='detected_at desc')
return {
'count': len(anomalies),
'anomalies': [{
'id': a.id, 'asset_id': a.asset_id.id, 'asset_name': a.asset_id.name,
'anomaly_type': a.anomaly_type, 'severity': a.severity,
'expected': a.expected, 'actual': a.actual,
'variance_pct': a.variance_pct, 'detail': a.detail or '',
'state': a.state,
'detected_at': str(a.detected_at),
} for a in anomalies],
}
@http.route('/fusion/assets/suggest_useful_life', type='jsonrpc', auth='user')
def suggest_useful_life(self, description, amount=None, partner_name=None):
from odoo.addons.fusion_accounting_assets.services.useful_life_predictor import (
predict_useful_life,
)
return predict_useful_life(
request.env, description=description,
amount=float(amount) if amount is not None else None,
partner_name=partner_name,
)
@http.route('/fusion/assets/get_partner_history', type='jsonrpc', auth='user')
def get_partner_history(self, partner_id, limit=20):
Asset = request.env['fusion.asset'].sudo()
assets = Asset.search([
('source_invoice_line_id.partner_id', '=', int(partner_id)),
], limit=int(limit), order='acquisition_date desc')
return {
'partner_id': int(partner_id),
'count': len(assets),
'assets': [{
'id': a.id, 'name': a.name,
'cost': a.cost, 'book_value': a.book_value,
'state': a.state,
'acquisition_date': str(a.acquisition_date),
} for a in assets],
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="cron_fusion_assets_post_depreciation" model="ir.cron">
<field name="name">Fusion Assets — Post Due Depreciation</field>
<field name="model_id" ref="model_fusion_assets_cron"/>
<field name="state">code</field>
<field name="code">model._cron_post_due_depreciation()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
<record id="cron_fusion_assets_anomaly_scan" model="ir.cron">
<field name="name">Fusion Assets — Monthly Anomaly Scan</field>
<field name="model_id" ref="model_fusion_assets_cron"/>
<field name="state">code</field>
<field name="code">model._cron_anomaly_scan()</field>
<field name="interval_number">30</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
</odoo>

View File

@@ -0,0 +1,8 @@
from . import fusion_asset_category
from . import fusion_asset
from . import fusion_asset_depreciation_line
from . import fusion_asset_disposal
from . import fusion_asset_anomaly
from . import account_move
from . import fusion_asset_engine
from . import fusion_assets_cron

View File

@@ -0,0 +1,34 @@
"""Inherit account.move.line to link to fusion.asset records.
Lets us trace assets back to their source invoice line.
"""
from odoo import fields, models
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
fusion_asset_id = fields.Many2one(
'fusion.asset', string='Created Asset',
copy=False, ondelete='set null',
help="Fusion asset record created from this invoice line.",
)
fusion_asset_count = fields.Integer(compute='_compute_fusion_asset_count')
def _compute_fusion_asset_count(self):
for line in self:
line.fusion_asset_count = 1 if line.fusion_asset_id else 0
def action_open_fusion_asset(self):
self.ensure_one()
if not self.fusion_asset_id:
return
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.asset',
'res_id': self.fusion_asset_id.id,
'view_mode': 'form',
'target': 'current',
}

View File

@@ -0,0 +1,164 @@
"""Fusion Asset model.
Lifecycle: draft -> running -> (paused -> running)* -> disposed.
- draft: created, not yet running depreciation
- running: depreciation board active, periodic posts happen
- paused: depreciation suspended (e.g. asset out for repair)
- disposed: sold/scrapped/donated; no further depreciation
"""
import logging
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
METHOD_SELECTION = [
('straight_line', 'Straight Line'),
('declining_balance', 'Declining Balance'),
('units_of_production', 'Units of Production'),
]
PRORATE_SELECTION = [
('full_month', 'Full Month'),
('days_365', 'Days / 365'),
('days_period', 'Days in Period'),
]
STATE_SELECTION = [
('draft', 'Draft'),
('running', 'Running'),
('paused', 'Paused'),
('disposed', 'Disposed'),
]
class FusionAsset(models.Model):
_name = "fusion.asset"
_description = "Fusion Fixed Asset"
_order = "acquisition_date desc, id desc"
_inherit = ['mail.thread', 'mail.activity.mixin']
name = fields.Char(required=True, tracking=True)
code = fields.Char(help="Internal asset code (e.g. tag number).")
company_id = fields.Many2one(
'res.company', required=True,
default=lambda self: self.env.company,
)
category_id = fields.Many2one('fusion.asset.category', tracking=True)
state = fields.Selection(
STATE_SELECTION, default='draft', required=True, tracking=True,
)
cost = fields.Monetary(
required=True, tracking=True,
help="Original acquisition cost.",
)
salvage_value = fields.Monetary(
default=0.0, tracking=True,
help="Estimated end-of-life value.",
)
acquisition_date = fields.Date(
required=True, default=fields.Date.today, tracking=True,
)
in_service_date = fields.Date(
tracking=True,
help="Date depreciation actually begins.",
)
disposed_date = fields.Date(readonly=True, tracking=True)
currency_id = fields.Many2one(
'res.currency', required=True,
default=lambda self: self.env.company.currency_id,
)
method = fields.Selection(
METHOD_SELECTION, required=True, default='straight_line', tracking=True,
)
useful_life_years = fields.Integer(
default=5, tracking=True,
help="For straight_line / declining_balance.",
)
declining_rate_pct = fields.Float(
default=20.0,
help="For declining_balance method, e.g. 20.0 = 20%/year.",
)
total_units_expected = fields.Float(
help="For units_of_production method.",
)
units_used_to_date = fields.Float(
default=0.0,
help="For units_of_production: track usage.",
)
prorate_convention = fields.Selection(
PRORATE_SELECTION, default='days_period', required=True,
)
source_invoice_line_id = fields.Many2one(
'account.move.line', string='Source Invoice Line',
help="The invoice line that originated this asset.",
)
parent_id = fields.Many2one(
'fusion.asset', help='For partial-sale child assets.',
)
depreciation_line_ids = fields.One2many(
'fusion.asset.depreciation.line', 'asset_id',
string='Depreciation Lines',
)
book_value = fields.Monetary(compute='_compute_book_value', store=True)
total_depreciated = fields.Monetary(compute='_compute_book_value', store=True)
last_posted_date = fields.Date(compute='_compute_last_posted_date', store=True)
@api.depends('cost', 'depreciation_line_ids.amount', 'depreciation_line_ids.is_posted')
def _compute_book_value(self):
for asset in self:
posted = sum(l.amount for l in asset.depreciation_line_ids if l.is_posted)
asset.total_depreciated = posted
asset.book_value = asset.cost - posted
@api.depends('depreciation_line_ids.is_posted', 'depreciation_line_ids.scheduled_date')
def _compute_last_posted_date(self):
for asset in self:
posted_dates = [
l.scheduled_date for l in asset.depreciation_line_ids if l.is_posted
]
asset.last_posted_date = max(posted_dates) if posted_dates else False
def action_set_running(self):
for asset in self:
if asset.state != 'draft':
raise ValidationError(_("Only draft assets can be set running."))
if not asset.in_service_date:
asset.in_service_date = fields.Date.today()
asset.state = 'running'
def action_pause(self):
for asset in self:
if asset.state != 'running':
raise ValidationError(_("Only running assets can be paused."))
asset.state = 'paused'
def action_resume(self):
for asset in self:
if asset.state != 'paused':
raise ValidationError(_("Only paused assets can be resumed."))
asset.state = 'running'
def action_set_draft(self):
for asset in self:
if asset.state not in ('draft', 'paused'):
raise ValidationError(
_("Cannot reset to draft from %s.") % asset.state,
)
asset.state = 'draft'
_check_cost_positive = models.Constraint(
'CHECK(cost >= 0)',
'Asset cost must be non-negative.',
)
_check_salvage_lte_cost = models.Constraint(
'CHECK(salvage_value >= 0 AND salvage_value <= cost)',
'Salvage value must be between 0 and cost.',
)

View File

@@ -0,0 +1,42 @@
"""Persisted asset anomaly flags from the engine's variance detection."""
from odoo import fields, models
SEVERITY = [('low', 'Low'), ('medium', 'Medium'), ('high', 'High')]
ANOMALY_TYPES = [
('behind_schedule', 'Behind Schedule'),
('ahead_of_schedule', 'Ahead of Schedule'),
('low_utilization', 'Low Utilization'),
]
class FusionAssetAnomaly(models.Model):
_name = "fusion.asset.anomaly"
_description = "Flagged Asset Anomaly"
_order = "detected_at desc, severity desc"
asset_id = fields.Many2one('fusion.asset', required=True, ondelete='cascade')
company_id = fields.Many2one(related='asset_id.company_id', store=True)
anomaly_type = fields.Selection(ANOMALY_TYPES, required=True)
severity = fields.Selection(SEVERITY, required=True)
expected = fields.Float()
actual = fields.Float()
variance_pct = fields.Float()
detail = fields.Text()
detected_at = fields.Datetime(default=fields.Datetime.now, required=True)
state = fields.Selection([
('new', 'New'),
('acknowledged', 'Acknowledged'),
('resolved', 'Resolved'),
('dismissed', 'Dismissed'),
], default='new', required=True)
def action_acknowledge(self):
self.write({'state': 'acknowledged'})
def action_dismiss(self):
self.write({'state': 'dismissed'})
def action_resolve(self):
self.write({'state': 'resolved'})

View File

@@ -0,0 +1,53 @@
"""Asset categories with default settings (used as templates)."""
from odoo import api, fields, models
class FusionAssetCategory(models.Model):
_name = "fusion.asset.category"
_description = "Fusion Asset Category"
_order = "sequence, name"
name = fields.Char(required=True, translate=True)
sequence = fields.Integer(default=10)
company_id = fields.Many2one(
'res.company', default=lambda self: self.env.company,
)
method = fields.Selection([
('straight_line', 'Straight Line'),
('declining_balance', 'Declining Balance'),
('units_of_production', 'Units of Production'),
], default='straight_line', required=True)
useful_life_years = fields.Integer(default=5)
declining_rate_pct = fields.Float(default=20.0)
salvage_value_pct = fields.Float(
default=0.0,
help="% of cost (used for new assets in this category).",
)
prorate_convention = fields.Selection([
('full_month', 'Full Month'),
('days_365', 'Days / 365'),
('days_period', 'Days in Period'),
], default='days_period', required=True)
asset_account_id = fields.Many2one(
'account.account', string='Asset Account',
domain="[('account_type', 'in', ('asset_fixed', 'asset_non_current'))]",
)
depreciation_account_id = fields.Many2one(
'account.account', string='Depreciation Account',
domain="[('account_type', '=', 'asset_fixed')]",
)
expense_account_id = fields.Many2one(
'account.account', string='Expense Account',
domain="[('account_type', '=', 'expense_depreciation')]",
)
asset_count = fields.Integer(compute='_compute_asset_count')
def _compute_asset_count(self):
for cat in self:
cat.asset_count = self.env['fusion.asset'].search_count([
('category_id', '=', cat.id),
])

View File

@@ -0,0 +1,42 @@
"""Per-period depreciation board lines for an asset."""
from odoo import fields, models
class FusionAssetDepreciationLine(models.Model):
_name = "fusion.asset.depreciation.line"
_description = "Asset Depreciation Board Line"
_order = "asset_id, scheduled_date"
asset_id = fields.Many2one('fusion.asset', required=True, ondelete='cascade')
company_id = fields.Many2one(related='asset_id.company_id', store=True)
currency_id = fields.Many2one(related='asset_id.currency_id', store=True)
period_index = fields.Integer(required=True)
scheduled_date = fields.Date(required=True)
amount = fields.Monetary(required=True)
accumulated = fields.Monetary()
book_value_at_end = fields.Monetary()
is_posted = fields.Boolean(default=False, copy=False)
posted_date = fields.Date(copy=False)
move_id = fields.Many2one(
'account.move', copy=False,
help="Journal entry created when this line was posted.",
)
def action_post(self):
"""Mark this line as posted (without creating the journal entry yet —
engine method post_depreciation_entry handles the actual entry creation)."""
for line in self:
if line.is_posted:
continue
line.write({
'is_posted': True,
'posted_date': fields.Date.today(),
})
_unique_period_per_asset = models.Constraint(
'UNIQUE(asset_id, period_index)',
'A depreciation line for that period already exists.',
)

View File

@@ -0,0 +1,56 @@
"""Asset disposal records (sale, scrap, donation)."""
from odoo import api, fields, models
DISPOSAL_TYPES = [
('sale', 'Sale'),
('scrap', 'Scrap'),
('donation', 'Donation'),
('lost', 'Lost / Stolen'),
]
class FusionAssetDisposal(models.Model):
_name = "fusion.asset.disposal"
_description = "Asset Disposal Record"
_order = "disposal_date desc, id desc"
_inherit = ['mail.thread']
asset_id = fields.Many2one(
'fusion.asset', required=True, ondelete='restrict', tracking=True,
)
company_id = fields.Many2one(related='asset_id.company_id', store=True)
currency_id = fields.Many2one(related='asset_id.currency_id', store=True)
disposal_type = fields.Selection(
DISPOSAL_TYPES, required=True, default='sale', tracking=True,
)
disposal_date = fields.Date(
required=True, default=fields.Date.today, tracking=True,
)
sale_amount = fields.Monetary(
default=0.0, tracking=True,
help="Cash received (for sale disposal type).",
)
sale_partner_id = fields.Many2one('res.partner', tracking=True)
book_value_at_disposal = fields.Monetary(
readonly=True,
help="Asset book value at disposal date.",
)
gain_loss_amount = fields.Monetary(compute='_compute_gain_loss', store=True)
notes = fields.Text()
move_id = fields.Many2one(
'account.move', readonly=True, copy=False,
help="Journal entry created for this disposal.",
)
@api.depends('sale_amount', 'book_value_at_disposal', 'disposal_type')
def _compute_gain_loss(self):
for d in self:
if d.disposal_type == 'sale':
d.gain_loss_amount = d.sale_amount - d.book_value_at_disposal
else:
d.gain_loss_amount = -d.book_value_at_disposal

View File

@@ -0,0 +1,398 @@
"""The asset engine — orchestrator for all asset depreciation + lifecycle.
7-method public API. No direct ORM writes to fusion.asset.depreciation.line
or account.move from anywhere else; everything routes through here for
consistent validation, audit, and side-effect handling.
"""
import logging
from datetime import date, timedelta
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from ..services.depreciation_methods import (
straight_line,
declining_balance,
units_of_production,
)
_logger = logging.getLogger(__name__)
class FusionAssetEngine(models.AbstractModel):
_name = "fusion.asset.engine"
_description = "Fusion Asset Engine"
# ============================================================
# PUBLIC API (7 methods)
# ============================================================
@api.model
def compute_depreciation_schedule(self, asset, *, recompute: bool = False) -> dict:
"""Compute (or re-compute) the depreciation board for an asset.
If recompute=False and posted lines exist, ONLY un-posted future lines
are regenerated. If recompute=True, all unposted lines are wiped and
regenerated from scratch using current asset config.
"""
if not asset:
raise ValidationError(_("asset is required"))
asset.ensure_one()
self._validate_asset_for_schedule(asset)
Line = self.env['fusion.asset.depreciation.line'].sudo()
if recompute:
Line.search([
('asset_id', '=', asset.id),
('is_posted', '=', False),
]).unlink()
existing_posted = Line.search([
('asset_id', '=', asset.id),
('is_posted', '=', True),
], order='period_index')
start_period = max([l.period_index for l in existing_posted], default=-1) + 1
accumulated_so_far = sum(l.amount for l in existing_posted)
steps = self._compute_steps(asset)
new_steps = steps[start_period:]
base_date = asset.in_service_date or asset.acquisition_date
# Accumulated baseline at the boundary between posted and to-be-created
# lines: subtract the accumulated value the algorithm itself reports at
# that boundary, then re-add the actually-posted total. This keeps the
# board's accumulated column monotonic when picking up mid-life.
baseline_offset = 0.0
if start_period > 0 and start_period <= len(steps):
baseline_offset = steps[start_period - 1].accumulated_depreciation
line_vals = []
for s in new_steps:
scheduled_date = self._add_periods(base_date, s.period_index)
running_accumulated = round(
accumulated_so_far + s.accumulated_depreciation - baseline_offset, 2
)
line_vals.append({
'asset_id': asset.id,
'period_index': s.period_index,
'scheduled_date': scheduled_date,
'amount': s.period_amount,
'accumulated': running_accumulated,
'book_value_at_end': s.book_value_at_end,
'is_posted': False,
})
if line_vals:
Line.create(line_vals)
return {
'asset_id': asset.id,
'lines_created': len(line_vals),
'total_lines': len(asset.depreciation_line_ids),
'method': asset.method,
}
@api.model
def post_depreciation_entry(self, asset, *, period_date: date = None) -> dict:
"""Post the next-due un-posted depreciation line.
If period_date provided, post all lines whose scheduled_date <= period_date.
Otherwise, post the single next un-posted line (the earliest one).
"""
asset.ensure_one()
if asset.state != 'running':
raise ValidationError(
_("Cannot post depreciation for asset in state %s") % asset.state
)
Line = self.env['fusion.asset.depreciation.line'].sudo()
domain = [('asset_id', '=', asset.id), ('is_posted', '=', False)]
if period_date:
domain.append(('scheduled_date', '<=', period_date))
unposted = Line.search(domain, order='scheduled_date, period_index')
if not unposted:
return {'posted_count': 0, 'reason': 'no unposted lines due'}
if not period_date:
unposted = unposted[:1]
posted_ids = []
for line in unposted:
self._create_journal_entry(asset, line)
line.action_post()
posted_ids.append(line.id)
return {'posted_count': len(posted_ids), 'posted_line_ids': posted_ids}
@api.model
def dispose_asset(self, asset, *, sale_amount: float = 0.0,
sale_date: date = None, sale_partner=None,
disposal_type: str = 'sale') -> dict:
"""Dispose an asset (sale, scrap, donation, lost)."""
asset.ensure_one()
if asset.state == 'disposed':
raise ValidationError(_("Asset already disposed."))
sale_date = sale_date or fields.Date.today()
Line = self.env['fusion.asset.depreciation.line'].sudo()
future_unposted = Line.search([
('asset_id', '=', asset.id),
('is_posted', '=', False),
('scheduled_date', '>', sale_date),
])
future_unposted.unlink()
asset.invalidate_recordset(['book_value', 'total_depreciated'])
book_value = asset.book_value
Disposal = self.env['fusion.asset.disposal'].sudo()
partner_id = False
if sale_partner:
partner_id = sale_partner.id if hasattr(sale_partner, 'id') else sale_partner
disposal = Disposal.create({
'asset_id': asset.id,
'disposal_type': disposal_type,
'disposal_date': sale_date,
'sale_amount': sale_amount,
'sale_partner_id': partner_id,
'book_value_at_disposal': book_value,
})
asset.write({
'state': 'disposed',
'disposed_date': sale_date,
})
return {
'asset_id': asset.id,
'disposal_id': disposal.id,
'gain_loss_amount': disposal.gain_loss_amount,
'book_value_at_disposal': book_value,
}
@api.model
def partial_sale(self, asset, *, sold_amount: float, sold_qty: float = None,
sale_date: date = None, sale_partner=None) -> dict:
"""Partially dispose: split asset into two — sold child + remaining parent.
sold_amount is cash received for the sold portion.
sold_qty is the ratio of original cost to attribute to the sold portion (0..1).
If sold_qty is None, defaults to sold_amount / cost.
"""
asset.ensure_one()
if asset.state == 'disposed':
raise ValidationError(_("Cannot partially sell a disposed asset."))
if sold_qty is None:
sold_qty = sold_amount / asset.cost if asset.cost else 0
if not (0 < sold_qty < 1):
raise ValidationError(
_("sold_qty must be strictly between 0 and 1; got %s") % sold_qty
)
sale_date = sale_date or fields.Date.today()
Asset = self.env['fusion.asset'].sudo()
sold_cost = round(asset.cost * sold_qty, 2)
sold_salvage = round(asset.salvage_value * sold_qty, 2)
child_vals = {
'name': f"{asset.name} (sold portion)",
'parent_id': asset.id,
'cost': sold_cost,
'salvage_value': sold_salvage,
'acquisition_date': asset.acquisition_date,
'in_service_date': asset.in_service_date,
'method': asset.method,
'useful_life_years': asset.useful_life_years,
'declining_rate_pct': asset.declining_rate_pct,
'prorate_convention': asset.prorate_convention,
'company_id': asset.company_id.id,
'state': 'running',
}
if asset.category_id:
child_vals['category_id'] = asset.category_id.id
child = Asset.create(child_vals)
new_cost = round(asset.cost - sold_cost, 2)
new_salvage = round(asset.salvage_value - sold_salvage, 2)
asset.write({
'cost': new_cost,
'salvage_value': new_salvage,
})
self.compute_depreciation_schedule(asset, recompute=True)
result = self.dispose_asset(
child, sale_amount=sold_amount, sale_date=sale_date,
sale_partner=sale_partner, disposal_type='sale',
)
return {
'parent_asset_id': asset.id,
'child_asset_id': child.id,
'disposal_id': result['disposal_id'],
'gain_loss_amount': result['gain_loss_amount'],
}
@api.model
def pause_asset(self, asset, pause_date: date = None) -> dict:
"""Pause depreciation. Wraps asset.action_pause for API symmetry and
to log the pause date for downstream auditing."""
asset.ensure_one()
asset.action_pause()
return {
'asset_id': asset.id,
'pause_date': pause_date or fields.Date.today(),
'state': 'paused',
}
@api.model
def resume_asset(self, asset, resume_date: date = None) -> dict:
"""Resume a paused asset."""
asset.ensure_one()
asset.action_resume()
return {
'asset_id': asset.id,
'resume_date': resume_date or fields.Date.today(),
'state': 'running',
}
@api.model
def reverse_disposal(self, asset) -> dict:
"""Reverse a disposal (rare — recovery from accidental sale entry)."""
asset.ensure_one()
if asset.state != 'disposed':
raise ValidationError(_("Asset is not disposed."))
Disposal = self.env['fusion.asset.disposal'].sudo()
last_disposal = Disposal.search(
[('asset_id', '=', asset.id)],
order='disposal_date desc, id desc', limit=1,
)
if last_disposal and last_disposal.move_id:
try:
last_disposal.move_id.button_cancel()
except Exception as e: # noqa: BLE001
_logger.warning("Could not cancel disposal move: %s", e)
if last_disposal:
last_disposal.unlink()
asset.write({'state': 'running', 'disposed_date': False})
return {'asset_id': asset.id, 'state': 'running'}
# ============================================================
# PRIVATE HELPERS
# ============================================================
def _validate_asset_for_schedule(self, asset):
if asset.cost <= 0:
raise ValidationError(_("Asset cost must be > 0 to compute schedule."))
if asset.method == 'units_of_production' and not asset.total_units_expected:
raise ValidationError(_(
"Units of Production assets need total_units_expected set."
))
if asset.method in ('straight_line', 'declining_balance'):
if asset.useful_life_years < 1:
raise ValidationError(_("useful_life_years must be >= 1."))
if asset.salvage_value > asset.cost:
raise ValidationError(_("Salvage value cannot exceed cost."))
def _compute_steps(self, asset) -> list:
"""Dispatch to the appropriate depreciation method service."""
if asset.method == 'straight_line':
return straight_line(
cost=asset.cost,
salvage_value=asset.salvage_value,
n_periods=asset.useful_life_years,
)
if asset.method == 'declining_balance':
return declining_balance(
cost=asset.cost,
salvage_value=asset.salvage_value,
n_periods=asset.useful_life_years,
rate=asset.declining_rate_pct / 100.0,
)
if asset.method == 'units_of_production':
# Phase 3 simple: assume even per-period units. Phase 3.5 can read
# from a per-period usage table populated by maintenance/IoT data.
if asset.useful_life_years:
per_period = asset.total_units_expected / asset.useful_life_years
periods = asset.useful_life_years
else:
per_period = asset.total_units_expected
periods = 1
return units_of_production(
cost=asset.cost,
salvage_value=asset.salvage_value,
total_units_expected=asset.total_units_expected,
units_per_period=[per_period] * periods,
)
return []
def _add_periods(self, base_date: date, n_periods: int) -> date:
"""Add (n_periods + 1) yearly increments to base_date and step back one
day, giving the period-end date.
Phase 3.5 can split this into monthly/quarterly variants when the asset
carries a sub-annual frequency.
"""
try:
return base_date.replace(year=base_date.year + n_periods + 1) - timedelta(days=1)
except ValueError:
return base_date.replace(
year=base_date.year + n_periods + 1, day=28,
) - timedelta(days=1)
def _create_journal_entry(self, asset, line):
"""Create the journal entry for a depreciation line.
Phase 3 keeps this minimal: requires the category to have both
depreciation_account_id and expense_account_id wired up. Without that,
the line is still posted (is_posted flag) but no move is created.
Phase 3.5 will add multi-currency, allocation rules, and analytic tags.
"""
category = asset.category_id
if not category or not (category.depreciation_account_id and category.expense_account_id):
_logger.debug(
"No accounts on category for asset %s; skipping journal entry",
asset.id,
)
return None
Move = self.env['account.move'].sudo()
journal = self.env['account.journal'].search([
('type', '=', 'general'),
('company_id', '=', asset.company_id.id),
], limit=1)
if not journal:
_logger.warning(
"No general journal for company %s; skipping move creation",
asset.company_id.name,
)
return None
try:
move = Move.create({
'date': line.scheduled_date,
'journal_id': journal.id,
'ref': f"Depreciation: {asset.name} (P{line.period_index + 1})",
'line_ids': [
(0, 0, {
'name': f"Depreciation expense - {asset.name}",
'account_id': category.expense_account_id.id,
'debit': line.amount,
'credit': 0,
}),
(0, 0, {
'name': f"Accumulated depreciation - {asset.name}",
'account_id': category.depreciation_account_id.id,
'debit': 0,
'credit': line.amount,
}),
],
})
move.action_post()
line.write({'move_id': move.id})
return move
except Exception as e: # noqa: BLE001
_logger.warning(
"Failed to create depreciation move for asset %s line %s: %s",
asset.id, line.id, e,
)
return None

View File

@@ -0,0 +1,85 @@
"""Cron handlers for fusion_accounting_assets.
- _cron_post_due_depreciation: daily, post due depreciation lines for running assets
- _cron_anomaly_scan: monthly, scan for schedule variance and create anomaly records
"""
import logging
from odoo import api, fields, models
from ..services.anomaly_detection import detect_schedule_variance
_logger = logging.getLogger(__name__)
class FusionAssetsCron(models.AbstractModel):
_name = "fusion.assets.cron"
_description = "Fusion Assets Cron Handlers"
@api.model
def _cron_post_due_depreciation(self):
"""For each running asset, post any due un-posted depreciation lines."""
today = fields.Date.today()
engine = self.env['fusion.asset.engine']
Asset = self.env['fusion.asset']
running_assets = Asset.search([('state', '=', 'running')])
posted_total = 0
for asset in running_assets:
try:
with self.env.cr.savepoint():
result = engine.post_depreciation_entry(asset, period_date=today)
posted_total += result.get('posted_count', 0)
except Exception as e: # noqa: BLE001
_logger.warning("Cron post failed for asset %s: %s", asset.id, e)
_logger.info(
"Cron: posted depreciation on %d lines across %d running assets",
posted_total, len(running_assets),
)
@api.model
def _cron_anomaly_scan(self):
"""For each running asset, compare expected accumulated depreciation
vs posted, and persist any variance flags."""
Asset = self.env['fusion.asset']
Anomaly = self.env['fusion.asset.anomaly']
running_assets = Asset.search([('state', '=', 'running')])
flagged = 0
today = fields.Date.today()
for asset in running_assets:
try:
expected = sum(
l.amount for l in asset.depreciation_line_ids
if l.scheduled_date and l.scheduled_date <= today
)
actual = asset.total_depreciated
anomaly = detect_schedule_variance(
asset_id=asset.id, asset_name=asset.name,
expected_accumulated=expected, actual_accumulated=actual,
)
if anomaly is None:
continue
anomaly_dict = anomaly.to_dict()
existing = Anomaly.search([
('asset_id', '=', asset.id),
('anomaly_type', '=', anomaly_dict['anomaly_type']),
('state', 'in', ('new', 'acknowledged')),
], limit=1)
if existing:
continue
Anomaly.create({
'asset_id': asset.id,
'anomaly_type': anomaly_dict['anomaly_type'],
'severity': anomaly_dict['severity'],
'expected': anomaly_dict['expected'],
'actual': anomaly_dict['actual'],
'variance_pct': anomaly_dict['variance_pct'],
'detail': anomaly_dict['detail'],
})
flagged += 1
except Exception as e: # noqa: BLE001
_logger.warning("Cron anomaly scan failed for asset %s: %s", asset.id, e)
_logger.info(
"Cron: scanned %d assets, flagged %d anomalies",
len(running_assets), flagged,
)

View File

@@ -0,0 +1,11 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_asset_user,fusion.asset.user,model_fusion_asset,base.group_user,1,0,0,0
access_fusion_asset_admin,fusion.asset.admin,model_fusion_asset,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_asset_depreciation_line_user,fusion.asset.depreciation.line.user,model_fusion_asset_depreciation_line,base.group_user,1,0,0,0
access_fusion_asset_depreciation_line_admin,fusion.asset.depreciation.line.admin,model_fusion_asset_depreciation_line,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_asset_category_user,fusion.asset.category.user,model_fusion_asset_category,base.group_user,1,0,0,0
access_fusion_asset_category_admin,fusion.asset.category.admin,model_fusion_asset_category,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_asset_disposal_user,fusion.asset.disposal.user,model_fusion_asset_disposal,base.group_user,1,0,0,0
access_fusion_asset_disposal_admin,fusion.asset.disposal.admin,model_fusion_asset_disposal,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_asset_anomaly_user,fusion.asset.anomaly.user,model_fusion_asset_anomaly,base.group_user,1,0,0,0
access_fusion_asset_anomaly_admin,fusion.asset.anomaly.admin,model_fusion_asset_anomaly,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_asset_user fusion.asset.user model_fusion_asset base.group_user 1 0 0 0
3 access_fusion_asset_admin fusion.asset.admin model_fusion_asset fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
4 access_fusion_asset_depreciation_line_user fusion.asset.depreciation.line.user model_fusion_asset_depreciation_line base.group_user 1 0 0 0
5 access_fusion_asset_depreciation_line_admin fusion.asset.depreciation.line.admin model_fusion_asset_depreciation_line fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
6 access_fusion_asset_category_user fusion.asset.category.user model_fusion_asset_category base.group_user 1 0 0 0
7 access_fusion_asset_category_admin fusion.asset.category.admin model_fusion_asset_category fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
8 access_fusion_asset_disposal_user fusion.asset.disposal.user model_fusion_asset_disposal base.group_user 1 0 0 0
9 access_fusion_asset_disposal_admin fusion.asset.disposal.admin model_fusion_asset_disposal fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
10 access_fusion_asset_anomaly_user fusion.asset.anomaly.user model_fusion_asset_anomaly base.group_user 1 0 0 0
11 access_fusion_asset_anomaly_admin fusion.asset.anomaly.admin model_fusion_asset_anomaly fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1

View File

@@ -0,0 +1,6 @@
from . import depreciation_methods
from . import prorate
from . import salvage_value
from . import anomaly_detection
from . import useful_life_prompt
from . import useful_life_predictor

View File

@@ -0,0 +1,96 @@
"""Asset utilization anomaly detection.
Flags assets where actual usage / posted depreciation deviates significantly
from the expected schedule. Three signal types:
- behind_schedule: actual depreciation < expected by > threshold pct
- ahead_of_schedule: actual > expected (over-depreciated; scrap or recompute)
- low_utilization: units_used < expected_units_per_period (waste alert)
"""
from dataclasses import dataclass
@dataclass
class AssetAnomaly:
asset_id: int
asset_name: str
anomaly_type: str
severity: str
expected: float
actual: float
variance_pct: float
detail: str
def to_dict(self):
return {
'asset_id': self.asset_id,
'asset_name': self.asset_name,
'anomaly_type': self.anomaly_type,
'severity': self.severity,
'expected': self.expected,
'actual': self.actual,
'variance_pct': self.variance_pct,
'detail': self.detail,
}
DEFAULT_LOW_THRESHOLD_PCT = 10.0
DEFAULT_MEDIUM_THRESHOLD_PCT = 25.0
DEFAULT_HIGH_THRESHOLD_PCT = 50.0
def detect_schedule_variance(*, asset_id: int, asset_name: str,
expected_accumulated: float,
actual_accumulated: float) -> AssetAnomaly | None:
"""Compare expected accumulated depreciation vs actual posted."""
if expected_accumulated <= 0:
return None
variance_amt = actual_accumulated - expected_accumulated
variance_pct = abs(variance_amt) / expected_accumulated * 100
if variance_pct < DEFAULT_LOW_THRESHOLD_PCT:
return None
direction = 'ahead_of_schedule' if variance_amt > 0 else 'behind_schedule'
if variance_pct >= DEFAULT_HIGH_THRESHOLD_PCT:
severity = 'high'
elif variance_pct >= DEFAULT_MEDIUM_THRESHOLD_PCT:
severity = 'medium'
else:
severity = 'low'
detail = f"Posted ${actual_accumulated:,.2f} vs expected ${expected_accumulated:,.2f}"
return AssetAnomaly(
asset_id=asset_id,
asset_name=asset_name,
anomaly_type=direction,
severity=severity,
expected=expected_accumulated,
actual=actual_accumulated,
variance_pct=round(variance_pct, 1),
detail=detail,
)
def detect_low_utilization(*, asset_id: int, asset_name: str,
expected_units: float,
actual_units: float) -> AssetAnomaly | None:
"""For units-of-production assets: flag low actual usage."""
if expected_units <= 0:
return None
if actual_units >= expected_units * 0.9:
return None
deficit_pct = (expected_units - actual_units) / expected_units * 100
if deficit_pct >= 50:
severity = 'high'
elif deficit_pct >= 25:
severity = 'medium'
else:
severity = 'low'
return AssetAnomaly(
asset_id=asset_id,
asset_name=asset_name,
anomaly_type='low_utilization',
severity=severity,
expected=expected_units,
actual=actual_units,
variance_pct=round(deficit_pct, 1),
detail=f"Used {actual_units:.0f} of expected {expected_units:.0f} units",
)

View File

@@ -0,0 +1,116 @@
"""Depreciation method primitives.
Three methods supported:
- straight_line: equal periodic charge over useful_life
- declining_balance: % per period of remaining book value
- units_of_production: charge proportional to units used / total units expected
All return a list of DepreciationStep dataclasses (period_index, period_amount,
accumulated_depreciation, book_value_at_end). Total depreciation always
sums to (cost - salvage_value), within 1-cent rounding tolerance.
"""
from dataclasses import dataclass
from typing import Literal
Method = Literal['straight_line', 'declining_balance', 'units_of_production']
@dataclass
class DepreciationStep:
period_index: int
period_amount: float
accumulated_depreciation: float
book_value_at_end: float
def straight_line(*, cost: float, salvage_value: float = 0.0,
n_periods: int) -> list[DepreciationStep]:
"""Equal charge per period: (cost - salvage) / n_periods.
Last period absorbs rounding so total == cost - salvage exactly.
"""
if n_periods < 1:
return []
depreciable = cost - salvage_value
per_period = round(depreciable / n_periods, 2)
steps = []
accumulated = 0.0
for i in range(n_periods):
if i == n_periods - 1:
amount = round(depreciable - accumulated, 2)
else:
amount = per_period
accumulated = round(accumulated + amount, 2)
book = round(cost - accumulated, 2)
steps.append(DepreciationStep(
period_index=i,
period_amount=amount,
accumulated_depreciation=accumulated,
book_value_at_end=book,
))
return steps
def declining_balance(*, cost: float, salvage_value: float = 0.0,
n_periods: int, rate: float) -> list[DepreciationStep]:
"""Apply `rate` (e.g. 0.20 = 20%) to remaining book each period.
Switches to straight-line when straight-line would deplete remaining book
faster (typical Odoo behavior). Last step caps at salvage_value.
"""
if n_periods < 1 or rate <= 0:
return []
if rate >= 1:
# Pathological: 100%+ rate. Charge full depreciable amount in period 0.
depreciable = round(cost - salvage_value, 2)
return [DepreciationStep(0, depreciable, depreciable, round(salvage_value, 2))]
steps = []
book = cost
accumulated = 0.0
for i in range(n_periods):
remaining_periods = n_periods - i
db_amount = round(book * rate, 2)
sl_amount = round((book - salvage_value) / remaining_periods, 2) if remaining_periods else 0.0
amount = max(db_amount, sl_amount)
if book - amount < salvage_value:
amount = round(book - salvage_value, 2)
accumulated = round(accumulated + amount, 2)
book = round(book - amount, 2)
steps.append(DepreciationStep(
period_index=i,
period_amount=amount,
accumulated_depreciation=accumulated,
book_value_at_end=book,
))
if book <= salvage_value:
break
return steps
def units_of_production(*, cost: float, salvage_value: float = 0.0,
total_units_expected: float,
units_per_period: list[float]) -> list[DepreciationStep]:
"""Charge per period = (units_used / total_expected) * (cost - salvage)."""
if total_units_expected <= 0:
return []
depreciable = cost - salvage_value
per_unit = depreciable / total_units_expected
steps = []
accumulated = 0.0
for i, units in enumerate(units_per_period):
amount = round(units * per_unit, 2)
if accumulated + amount > depreciable:
amount = round(depreciable - accumulated, 2)
accumulated = round(accumulated + amount, 2)
book = round(cost - accumulated, 2)
steps.append(DepreciationStep(
period_index=i,
period_amount=amount,
accumulated_depreciation=accumulated,
book_value_at_end=book,
))
if accumulated >= depreciable:
break
return steps

View File

@@ -0,0 +1,34 @@
"""Prorating helpers for first-period and last-period depreciation.
When an asset starts mid-month, the first period charges only a fraction
of the full period_amount. Three conventions:
- 'full_month': always charge full month (no proration)
- 'days_365': pro-rate by actual days / 365
- 'days_period': pro-rate by actual days in period / total days in period
"""
from datetime import date
from typing import Literal
ProrateConvention = Literal['full_month', 'days_365', 'days_period']
def prorate_factor(*, period_start: date, period_end: date,
asset_start: date,
convention: ProrateConvention = 'days_period') -> float:
"""Return a 0..1 factor for how much of `period`'s depreciation
applies to an asset that started on `asset_start`."""
if convention == 'full_month':
return 1.0
if asset_start <= period_start:
return 1.0
if asset_start > period_end:
return 0.0
actual_days = (period_end - asset_start).days + 1
if convention == 'days_365':
return actual_days / 365.0
if convention == 'days_period':
period_days = (period_end - period_start).days + 1
return actual_days / period_days
raise ValueError(f"Unknown convention: {convention}")

View File

@@ -0,0 +1,38 @@
"""Salvage value (scrap value) calculation helpers.
Most clients use straight % of cost; some use fixed dollar amounts.
"""
from dataclasses import dataclass
from typing import Literal
SalvageMethod = Literal['percentage', 'fixed', 'zero']
@dataclass
class SalvageConfig:
method: SalvageMethod
value: float = 0.0
def compute_salvage_value(*, cost: float, config: SalvageConfig) -> float:
"""Compute end-of-life salvage value."""
if config.method == 'zero':
return 0.0
if config.method == 'percentage':
return round(cost * config.value / 100, 2)
if config.method == 'fixed':
return round(config.value, 2)
raise ValueError(f"Unknown salvage method: {config.method}")
def remaining_useful_life_value(*, current_book: float, salvage: float,
periods_used: int, total_periods: int) -> float:
"""Estimate remaining value if asset is sold/scrapped now."""
if total_periods <= 0:
return current_book
if periods_used >= total_periods:
return salvage
remaining_pct = (total_periods - periods_used) / total_periods
return round(salvage + (current_book - salvage) * remaining_pct, 2)

View File

@@ -0,0 +1,94 @@
"""AI-suggested useful life from invoice context.
Wraps useful_life_prompt + an LLMProvider. Returns a dict per the prompt's
output contract. Templated fallback when no provider configured.
"""
import json
import logging
import re
_logger = logging.getLogger(__name__)
# Templated fallback rules: (regex, years, method, rationale)
FALLBACK_RULES = [
(r'\b(computer|laptop|monitor|server|workstation)\b', 4, 'straight_line', 'Computer hardware'),
(r'\b(furniture|desk|chair|cabinet)\b', 7, 'straight_line', 'Furniture'),
(r'\b(vehicle|truck|car|van)\b', 5, 'declining_balance', 'Vehicle (CRA Class 10)'),
(r'\b(building|warehouse)\b', 30, 'straight_line', 'Building'),
(r'\b(software|license)\b', 4, 'straight_line', 'Software license'),
(r'\b(equipment|machinery|machine)\b', 10, 'straight_line', 'Manufacturing equipment'),
(r'\b(leasehold improvement)\b', 5, 'straight_line', 'Leasehold improvements'),
]
FALLBACK_DEFAULT = (5, 'straight_line', 'Generic fixed asset (default)')
def predict_useful_life(env, *, description: str, amount: float = None,
partner_name: str = None, provider=None) -> dict:
"""Suggest useful life + method via LLM, with templated fallback."""
if provider is None:
provider = _get_provider(env)
if provider is None:
return _templated_fallback(description)
try:
from .useful_life_prompt import build_prompt
system, user = build_prompt(
description=description, amount=amount, partner_name=partner_name,
)
response = provider.complete(
system=system,
messages=[{'role': 'user', 'content': user}],
max_tokens=400, temperature=0.1,
)
content = response.get('content') if isinstance(response, dict) else response
parsed = json.loads(content)
for key in ('useful_life_years', 'depreciation_method', 'rationale'):
if key not in parsed:
raise ValueError(f"Missing key: {key}")
parsed.setdefault('confidence', 0.7)
return parsed
except Exception as e:
_logger.warning("Useful life LLM prediction failed (%s); falling back", e)
return _templated_fallback(description)
def _templated_fallback(description: str) -> dict:
"""Pattern-match keyword rules. Always returns a usable dict."""
desc_lower = description.lower() if description else ''
for pattern, years, method, rationale in FALLBACK_RULES:
if re.search(pattern, desc_lower):
return {
'useful_life_years': years,
'depreciation_method': method,
'rationale': rationale,
'confidence': 0.5,
}
years, method, rationale = FALLBACK_DEFAULT
return {
'useful_life_years': years,
'depreciation_method': method,
'rationale': rationale,
'confidence': 0.3,
}
def _get_provider(env):
"""Look up provider for 'asset_useful_life' feature."""
param = env['ir.config_parameter'].sudo()
name = param.get_param('fusion_accounting.provider.asset_useful_life')
if not name:
name = param.get_param('fusion_accounting.provider.default')
if not name:
return None
try:
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
except ImportError:
return None
if name.startswith('openai'):
return OpenAIAdapter(env)
elif name.startswith('claude'):
return ClaudeAdapter(env)
return None

View File

@@ -0,0 +1,48 @@
"""LLM prompt builder for AI-suggested useful life from invoice description.
Output contract:
{
"useful_life_years": <int>,
"depreciation_method": "straight_line" | "declining_balance" | "units_of_production",
"rationale": "<short explanation>",
"confidence": <float 0-1>
}
"""
SYSTEM_PROMPT = """You are an experienced accountant. Given an invoice line
description for a fixed asset, suggest the appropriate useful life in years
and depreciation method based on common accounting standards (IFRS / GAAP / CRA).
Respond ONLY with valid JSON of this exact shape:
{
"useful_life_years": <integer>,
"depreciation_method": "straight_line" | "declining_balance" | "units_of_production",
"rationale": "<one or two sentence explanation>",
"confidence": <float between 0 and 1>
}
Common useful-life conventions:
- Furniture: 7 years, straight-line
- Office equipment: 5 years, straight-line
- Computers: 3-4 years, straight-line or declining
- Vehicles: 5 years, declining-balance (CRA Class 10 30%)
- Buildings: 25-40 years, straight-line
- Manufacturing equipment: 10-15 years, units of production if measurable
- Software (licenses): 3-5 years, straight-line
- Leasehold improvements: lesser of lease term or useful life
Do NOT include markdown code fences. Do NOT include any prose outside the JSON."""
def build_prompt(*, description: str, amount: float = None,
partner_name: str = None) -> tuple[str, str]:
"""Return (system, user) prompt tuple."""
parts = [f"INVOICE LINE: {description}"]
if amount is not None:
parts.append(f"AMOUNT: ${amount:,.2f}")
if partner_name:
parts.append(f"VENDOR: {partner_name}")
parts.append("")
parts.append("Suggest the useful life and depreciation method per the system prompt.")
return (SYSTEM_PROMPT, "\n".join(parts))

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1,17 @@
from . import test_depreciation_methods
from . import test_prorate
from . import test_salvage_value
from . import test_asset_anomaly_detection
from . import test_useful_life_predictor
from . import test_fusion_asset
from . import test_fusion_asset_depreciation_line
from . import test_fusion_asset_category
from . import test_fusion_asset_disposal
from . import test_fusion_asset_anomaly
from . import test_account_move_inherit
from . import test_fusion_asset_engine
from . import test_engine_integration
from . import test_assets_controller
from . import test_assets_adapter
from . import test_asset_tools
from . import test_assets_cron

View File

@@ -0,0 +1,47 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAccountMoveLineFusionAsset(TransactionCase):
def setUp(self):
super().setUp()
self.asset = self.env['fusion.asset'].create({
'name': 'Asset From Invoice',
'cost': 8000,
'acquisition_date': date(2026, 1, 1),
})
self.partner = self.env['res.partner'].create({'name': 'Vendor X'})
product = self.env['product.product'].create({'name': 'Test Asset Item'})
bill = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': self.partner.id,
'invoice_date': date(2026, 1, 1),
'invoice_line_ids': [(0, 0, {
'product_id': product.id,
'name': 'Test asset purchase',
'quantity': 1,
'price_unit': 8000,
})],
})
self.invoice_line = bill.invoice_line_ids[0]
def test_line_starts_without_asset_link(self):
self.assertFalse(self.invoice_line.fusion_asset_id)
self.assertEqual(self.invoice_line.fusion_asset_count, 0)
def test_link_invoice_line_to_asset(self):
self.invoice_line.fusion_asset_id = self.asset
self.assertEqual(self.invoice_line.fusion_asset_id, self.asset)
self.invoice_line.invalidate_recordset(['fusion_asset_count'])
self.assertEqual(self.invoice_line.fusion_asset_count, 1)
def test_action_open_fusion_asset_returns_window_action(self):
self.invoice_line.fusion_asset_id = self.asset
action = self.invoice_line.action_open_fusion_asset()
self.assertEqual(action['res_model'], 'fusion.asset')
self.assertEqual(action['res_id'], self.asset.id)
self.assertEqual(action['view_mode'], 'form')

View File

@@ -0,0 +1,71 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_assets.services.anomaly_detection import (
detect_schedule_variance, detect_low_utilization, AssetAnomaly,
)
@tagged('post_install', '-at_install')
class TestAssetAnomalyDetection(TransactionCase):
def test_schedule_variance_within_threshold_returns_none(self):
# 5% variance < 10% threshold
result = detect_schedule_variance(
asset_id=1, asset_name='Truck', expected_accumulated=10000,
actual_accumulated=10500,
)
self.assertIsNone(result)
def test_schedule_variance_behind_schedule_low_severity(self):
# 15% behind: low severity, behind_schedule
result = detect_schedule_variance(
asset_id=1, asset_name='Truck', expected_accumulated=10000,
actual_accumulated=8500,
)
self.assertIsNotNone(result)
self.assertEqual(result.anomaly_type, 'behind_schedule')
self.assertEqual(result.severity, 'low')
def test_schedule_variance_ahead_high_severity(self):
# 60% ahead: high severity
result = detect_schedule_variance(
asset_id=2, asset_name='Server', expected_accumulated=10000,
actual_accumulated=16000,
)
self.assertIsNotNone(result)
self.assertEqual(result.anomaly_type, 'ahead_of_schedule')
self.assertEqual(result.severity, 'high')
def test_schedule_variance_zero_expected_returns_none(self):
result = detect_schedule_variance(
asset_id=1, asset_name='Truck', expected_accumulated=0,
actual_accumulated=500,
)
self.assertIsNone(result)
def test_low_utilization_flags_when_underused(self):
# 60% deficit -> high severity
result = detect_low_utilization(
asset_id=3, asset_name='Mill', expected_units=1000, actual_units=400,
)
self.assertIsNotNone(result)
self.assertEqual(result.anomaly_type, 'low_utilization')
self.assertEqual(result.severity, 'high')
def test_low_utilization_within_tolerance_returns_none(self):
# 95% used: within 10% tolerance
result = detect_low_utilization(
asset_id=3, asset_name='Mill', expected_units=1000, actual_units=950,
)
self.assertIsNone(result)
def test_anomaly_to_dict_round_trip(self):
anomaly = AssetAnomaly(
asset_id=1, asset_name='X', anomaly_type='behind_schedule',
severity='medium', expected=100.0, actual=70.0, variance_pct=30.0,
detail='example',
)
d = anomaly.to_dict()
self.assertEqual(d['asset_id'], 1)
self.assertEqual(d['anomaly_type'], 'behind_schedule')
self.assertEqual(d['severity'], 'medium')

View File

@@ -0,0 +1,56 @@
"""Tests for the 5 fusion-asset AI tools."""
from datetime import date
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
from odoo.addons.fusion_accounting_ai.services.tools import asset_management as tools
@tagged('post_install', '-at_install')
class TestFusionAssetTools(TransactionCase):
def test_fusion_list_assets(self):
self.env['fusion.asset'].create({
'name': 'Tool Test', 'cost': 1000,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 4,
})
result = tools.fusion_list_assets(self.env, {'company_id': self.env.company.id})
self.assertGreaterEqual(result.get('count', 0), 1)
def test_fusion_get_asset_detail(self):
asset = self.env['fusion.asset'].create({
'name': 'Detail Test', 'cost': 1500,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 4,
})
result = tools.fusion_get_asset_detail(self.env, {'asset_id': asset.id})
self.assertEqual(result['asset']['name'], 'Detail Test')
def test_fusion_compute_schedule(self):
asset = self.env['fusion.asset'].create({
'name': 'Schedule Test', 'cost': 2000,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 4,
})
result = tools.fusion_compute_asset_schedule(self.env, {'asset_id': asset.id})
self.assertEqual(result['lines_created'], 4)
def test_fusion_suggest_useful_life(self):
self.env['ir.config_parameter'].sudo().search([
('key', 'in', ['fusion_accounting.provider.asset_useful_life',
'fusion_accounting.provider.default'])
]).unlink()
result = tools.fusion_suggest_asset_useful_life(self.env, {
'description': 'desk',
})
self.assertEqual(result['useful_life_years'], 7)
def test_tools_registered_in_dispatch(self):
from odoo.addons.fusion_accounting_ai.services.tools import TOOL_DISPATCH
for tool_name in ['fusion_list_assets', 'fusion_get_asset_detail',
'fusion_compute_asset_schedule', 'fusion_dispose_asset',
'fusion_suggest_asset_useful_life']:
self.assertIn(tool_name, TOOL_DISPATCH)

View File

@@ -0,0 +1,40 @@
"""AssetsAdapter wiring tests — fusion-mode dispatch."""
from datetime import date
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
from odoo.addons.fusion_accounting_ai.services.data_adapters.assets import (
AssetsAdapter,
)
@tagged('post_install', '-at_install')
class TestAssetsAdapter(TransactionCase):
def setUp(self):
super().setUp()
self.adapter = AssetsAdapter(self.env)
def test_list_assets_via_fusion(self):
self.env['fusion.asset'].create({
'name': 'Adapter Test', 'cost': 1000,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 4,
})
result = self.adapter.list_assets_via_fusion(company_id=self.env.company.id)
self.assertGreaterEqual(result['count'], 1)
def test_suggest_useful_life_via_fusion_uses_templated_fallback(self):
self.env['ir.config_parameter'].sudo().search([
('key', 'in', ['fusion_accounting.provider.asset_useful_life',
'fusion_accounting.provider.default'])
]).unlink()
result = self.adapter.suggest_useful_life_via_fusion(description='laptop')
self.assertEqual(result['useful_life_years'], 4)
self.assertEqual(result['depreciation_method'], 'straight_line')
def test_dispose_asset_via_community_returns_error(self):
result = self.adapter.dispose_asset_via_community(asset_id=1, sale_amount=100)
self.assertIn('error', result)

View File

@@ -0,0 +1,103 @@
"""Controller tests using HttpCase."""
import json
from datetime import date
from odoo.tests import tagged
from odoo.tests.common import HttpCase, new_test_user
@tagged('post_install', '-at_install')
class TestAssetsController(HttpCase):
def setUp(self):
super().setUp()
self.user = new_test_user(
self.env, login='assets_test_user',
groups='base.group_user,account.group_account_invoice',
)
def _jsonrpc(self, endpoint, params):
self.authenticate('assets_test_user', 'assets_test_user')
url = f'/fusion/assets/{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_returns_dict(self):
result = self._jsonrpc('list', {'company_id': self.env.company.id})
self.assertIn('assets', result)
self.assertIn('total', result)
def test_get_detail_returns_asset(self):
asset = self.env['fusion.asset'].create({
'name': 'Ctrl Test Asset', 'cost': 5000,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 5,
})
result = self._jsonrpc('get_detail', {'asset_id': asset.id})
self.assertEqual(result['asset']['id'], asset.id)
self.assertIn('depreciation_lines', result)
def test_compute_schedule_creates_lines(self):
asset = self.env['fusion.asset'].create({
'name': 'CompTest', 'cost': 4000,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 4,
})
result = self._jsonrpc('compute_schedule', {'asset_id': asset.id})
self.assertEqual(result['lines_created'], 4)
def test_post_depreciation_after_running(self):
asset = self.env['fusion.asset'].create({
'name': 'PostTest', 'cost': 3000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 3,
})
self.env['fusion.asset.engine'].compute_depreciation_schedule(asset)
asset.action_set_running()
result = self._jsonrpc('post_depreciation', {'asset_id': asset.id})
self.assertEqual(result['posted_count'], 1)
def test_dispose_marks_asset_disposed(self):
asset = self.env['fusion.asset'].create({
'name': 'DispTest', 'cost': 6000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 3,
})
self.env['fusion.asset.engine'].compute_depreciation_schedule(asset)
asset.action_set_running()
result = self._jsonrpc('dispose', {
'asset_id': asset.id, 'sale_amount': 4000,
'sale_date': '2027-06-01', 'disposal_type': 'sale',
})
self.assertIn('disposal_id', result)
asset.invalidate_recordset(['state'])
self.assertEqual(asset.state, 'disposed')
def test_get_anomalies_returns_list(self):
result = self._jsonrpc('get_anomalies', {'company_id': self.env.company.id})
self.assertIn('anomalies', result)
def test_suggest_useful_life_returns_dict(self):
result = self._jsonrpc('suggest_useful_life', {'description': 'Dell laptop'})
self.assertIn('useful_life_years', result)
self.assertIn('depreciation_method', result)
self.assertEqual(result['useful_life_years'], 4)
def test_get_partner_history(self):
partner = self.env['res.partner'].create({'name': 'History Test Partner'})
result = self._jsonrpc('get_partner_history', {'partner_id': partner.id})
self.assertEqual(result['partner_id'], partner.id)

View File

@@ -0,0 +1,28 @@
"""Cron handler smoke tests."""
from datetime import date
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class TestFusionAssetsCron(TransactionCase):
def setUp(self):
super().setUp()
self.cron = self.env['fusion.assets.cron']
self.asset = self.env['fusion.asset'].create({
'name': 'Cron Test', 'cost': 4000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 4,
})
self.env['fusion.asset.engine'].compute_depreciation_schedule(self.asset)
self.asset.action_set_running()
def test_cron_post_due_depreciation_runs(self):
self.cron._cron_post_due_depreciation()
def test_cron_anomaly_scan_runs(self):
self.cron._cron_anomaly_scan()

View File

@@ -0,0 +1,88 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_assets.services.depreciation_methods import (
straight_line, declining_balance, units_of_production,
)
@tagged('post_install', '-at_install')
class TestStraightLine(TransactionCase):
def test_total_equals_cost_minus_salvage(self):
steps = straight_line(cost=10000, salvage_value=1000, n_periods=5)
total = sum(s.period_amount for s in steps)
self.assertAlmostEqual(total, 9000, places=2)
def test_per_period_equal_except_last(self):
steps = straight_line(cost=10000, salvage_value=0, n_periods=4)
self.assertEqual([s.period_amount for s in steps], [2500.0] * 4)
def test_last_period_absorbs_rounding(self):
steps = straight_line(cost=10000, salvage_value=0, n_periods=3)
total = sum(s.period_amount for s in steps)
self.assertAlmostEqual(total, 10000, places=2)
def test_zero_periods_returns_empty(self):
self.assertEqual(straight_line(cost=10000, n_periods=0), [])
def test_book_value_decreasing(self):
steps = straight_line(cost=10000, salvage_value=1000, n_periods=5)
for i in range(1, len(steps)):
self.assertLess(steps[i].book_value_at_end, steps[i - 1].book_value_at_end)
@tagged('post_install', '-at_install')
class TestDecliningBalance(TransactionCase):
def test_total_does_not_exceed_depreciable(self):
steps = declining_balance(cost=10000, salvage_value=1000, n_periods=10, rate=0.20)
total = sum(s.period_amount for s in steps)
self.assertLessEqual(total, 9000.01)
def test_does_not_go_below_salvage(self):
steps = declining_balance(cost=10000, salvage_value=1000, n_periods=10, rate=0.50)
for s in steps:
self.assertGreaterEqual(s.book_value_at_end, 999.99)
def test_zero_rate_returns_empty(self):
self.assertEqual(declining_balance(cost=10000, n_periods=5, rate=0), [])
def test_pathological_100pct_rate_one_period(self):
steps = declining_balance(cost=10000, salvage_value=500, n_periods=10, rate=1.0)
self.assertEqual(len(steps), 1)
self.assertAlmostEqual(steps[0].period_amount, 9500, places=2)
@tagged('post_install', '-at_install')
class TestUnitsOfProduction(TransactionCase):
def test_total_proportional_to_units_used(self):
steps = units_of_production(
cost=20000, salvage_value=2000,
total_units_expected=10000,
units_per_period=[1000, 2000, 3000, 4000],
)
total = sum(s.period_amount for s in steps)
self.assertAlmostEqual(total, 18000, places=1)
def test_partial_use_partial_depreciation(self):
steps = units_of_production(
cost=10000, salvage_value=0,
total_units_expected=1000,
units_per_period=[200],
)
self.assertAlmostEqual(steps[0].period_amount, 2000, places=2)
def test_zero_total_units_returns_empty(self):
self.assertEqual(
units_of_production(cost=10000, total_units_expected=0, units_per_period=[100]),
[],
)
def test_does_not_overshoot_salvage(self):
steps = units_of_production(
cost=10000, salvage_value=1000,
total_units_expected=1000,
units_per_period=[2000],
)
self.assertAlmostEqual(steps[0].period_amount, 9000, places=2)

View File

@@ -0,0 +1,151 @@
"""End-to-end engine integration tests.
Each test creates a complete realistic asset (with category and accounts),
runs the engine through a full lifecycle, and asserts both the model state
and the journal entries (where category accounts are configured).
"""
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.exceptions import ValidationError
@tagged('post_install', '-at_install', 'integration')
class TestAssetEngineIntegration(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.asset.engine']
Account = self.env['account.account']
company_id = self.env.company.id
self.expense_account = Account.search([
('account_type', '=', 'expense_depreciation'),
('company_ids', 'in', company_id),
], limit=1)
if not self.expense_account:
self.expense_account = Account.create({
'name': 'Test Depreciation Expense',
'code': '7180',
'account_type': 'expense_depreciation',
'company_ids': [(6, 0, [company_id])],
})
self.dep_account = Account.search([
('account_type', '=', 'asset_fixed'),
('company_ids', 'in', company_id),
], limit=1)
if not self.dep_account:
self.dep_account = Account.create({
'name': 'Test Accumulated Depreciation',
'code': '1690',
'account_type': 'asset_fixed',
'company_ids': [(6, 0, [company_id])],
})
self.category = self.env['fusion.asset.category'].create({
'name': 'Test Category',
'method': 'straight_line',
'useful_life_years': 5,
'asset_account_id': self.dep_account.id,
'depreciation_account_id': self.dep_account.id,
'expense_account_id': self.expense_account.id,
})
def _make_asset(self, **kwargs):
defaults = {
'name': 'Integration Asset',
'cost': 12000,
'salvage_value': 0,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line',
'useful_life_years': 4,
'category_id': self.category.id,
}
defaults.update(kwargs)
return self.env['fusion.asset'].create(defaults)
def test_full_lifecycle_straight_line(self):
asset = self._make_asset()
self.engine.compute_depreciation_schedule(asset)
self.assertEqual(len(asset.depreciation_line_ids), 4)
self.assertAlmostEqual(
sum(asset.depreciation_line_ids.mapped('amount')), 12000, places=2,
)
asset.action_set_running()
for _i in range(2):
result = self.engine.post_depreciation_entry(asset)
self.assertEqual(result['posted_count'], 1)
asset.invalidate_recordset(['book_value', 'total_depreciated'])
self.assertAlmostEqual(asset.total_depreciated, 6000, places=2)
def test_post_creates_journal_entry_when_accounts_configured(self):
asset = self._make_asset()
self.engine.compute_depreciation_schedule(asset)
asset.action_set_running()
self.engine.post_depreciation_entry(asset)
first = asset.depreciation_line_ids.sorted('period_index')[0]
self.assertTrue(first.move_id, "Expected journal entry on posted line")
moves = first.move_id
self.assertAlmostEqual(
sum(moves.line_ids.mapped('debit')),
sum(moves.line_ids.mapped('credit')),
places=2,
)
def test_dispose_caps_future_lines(self):
asset = self._make_asset()
self.engine.compute_depreciation_schedule(asset)
asset.action_set_running()
self.engine.post_depreciation_entry(asset)
self.engine.dispose_asset(
asset, sale_amount=5000, sale_date=date(2027, 6, 1),
)
self.assertEqual(asset.state, 'disposed')
unposted = asset.depreciation_line_ids.filtered(lambda l: not l.is_posted)
for line in unposted:
self.assertLessEqual(line.scheduled_date, date(2027, 6, 1))
def test_dispose_records_correct_book_value(self):
asset = self._make_asset()
self.engine.compute_depreciation_schedule(asset)
asset.action_set_running()
for _i in range(2):
self.engine.post_depreciation_entry(asset)
result = self.engine.dispose_asset(
asset, sale_amount=8000, sale_date=date(2028, 6, 1),
)
# Book value at disposal = cost - accumulated = 12000 - 6000 = 6000.
self.assertAlmostEqual(result['book_value_at_disposal'], 6000, places=2)
# Gain = 8000 - 6000 = 2000.
self.assertAlmostEqual(result['gain_loss_amount'], 2000, places=2)
def test_partial_sale_30pct(self):
asset = self._make_asset(cost=10000, salvage_value=0)
self.engine.compute_depreciation_schedule(asset)
asset.action_set_running()
result = self.engine.partial_sale(
asset, sold_amount=3500, sold_qty=0.3,
sale_date=date(2027, 1, 1),
)
asset.invalidate_recordset(['cost'])
self.assertAlmostEqual(asset.cost, 7000, places=2)
child = self.env['fusion.asset'].browse(result['child_asset_id'])
self.assertAlmostEqual(child.cost, 3000, places=2)
self.assertEqual(child.state, 'disposed')
# Child has no posted depreciation; book_value at disposal = 3000.
# Gain = 3500 - 3000 = 500.
self.assertAlmostEqual(result['gain_loss_amount'], 500, places=0)
def test_pause_then_resume_lifecycle(self):
asset = self._make_asset()
self.engine.compute_depreciation_schedule(asset)
asset.action_set_running()
self.engine.post_depreciation_entry(asset)
self.engine.pause_asset(asset)
with self.assertRaises(ValidationError):
self.engine.post_depreciation_entry(asset)
self.engine.resume_asset(asset)
result = self.engine.post_depreciation_entry(asset)
self.assertEqual(result['posted_count'], 1)

View File

@@ -0,0 +1,59 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.exceptions import ValidationError
@tagged('post_install', '-at_install')
class TestFusionAsset(TransactionCase):
def setUp(self):
super().setUp()
self.asset_vals = {
'name': 'Test Asset',
'cost': 10000,
'salvage_value': 1000,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line',
'useful_life_years': 5,
}
def test_create_minimal(self):
a = self.env['fusion.asset'].create(self.asset_vals)
self.assertEqual(a.state, 'draft')
self.assertEqual(a.book_value, 10000)
def test_state_transitions_draft_to_running(self):
a = self.env['fusion.asset'].create(self.asset_vals)
a.action_set_running()
self.assertEqual(a.state, 'running')
self.assertTrue(a.in_service_date)
def test_pause_resume(self):
a = self.env['fusion.asset'].create(self.asset_vals)
a.action_set_running()
a.action_pause()
self.assertEqual(a.state, 'paused')
a.action_resume()
self.assertEqual(a.state, 'running')
def test_cannot_pause_from_draft(self):
a = self.env['fusion.asset'].create(self.asset_vals)
with self.assertRaises(ValidationError):
a.action_pause()
def test_negative_cost_rejected(self):
with self.assertRaises(Exception):
self.env['fusion.asset'].create({**self.asset_vals, 'cost': -100})
def test_salvage_exceeds_cost_rejected(self):
with self.assertRaises(Exception):
self.env['fusion.asset'].create(
{**self.asset_vals, 'cost': 1000, 'salvage_value': 5000},
)
def test_book_value_starts_at_cost(self):
a = self.env['fusion.asset'].create(self.asset_vals)
self.assertEqual(a.book_value, a.cost)
self.assertEqual(a.total_depreciated, 0)

View File

@@ -0,0 +1,49 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionAssetAnomaly(TransactionCase):
def setUp(self):
super().setUp()
self.asset = self.env['fusion.asset'].create({
'name': 'Watched Asset',
'cost': 5000,
'acquisition_date': date(2026, 1, 1),
})
def _make_anomaly(self, **kw):
vals = {
'asset_id': self.asset.id,
'anomaly_type': 'behind_schedule',
'severity': 'medium',
'expected': 1000.0,
'actual': 700.0,
'variance_pct': -30.0,
}
vals.update(kw)
return self.env['fusion.asset.anomaly'].create(vals)
def test_create_defaults_state_new(self):
a = self._make_anomaly()
self.assertEqual(a.state, 'new')
self.assertTrue(a.detected_at)
self.assertEqual(a.company_id, self.asset.company_id)
def test_acknowledge_transitions(self):
a = self._make_anomaly()
a.action_acknowledge()
self.assertEqual(a.state, 'acknowledged')
def test_dismiss_transitions(self):
a = self._make_anomaly()
a.action_dismiss()
self.assertEqual(a.state, 'dismissed')
def test_resolve_transitions(self):
a = self._make_anomaly(anomaly_type='low_utilization', severity='high')
a.action_resolve()
self.assertEqual(a.state, 'resolved')

View File

@@ -0,0 +1,35 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionAssetCategory(TransactionCase):
def test_create_with_defaults(self):
cat = self.env['fusion.asset.category'].create({'name': 'Computers'})
self.assertEqual(cat.method, 'straight_line')
self.assertEqual(cat.useful_life_years, 5)
self.assertEqual(cat.prorate_convention, 'days_period')
self.assertEqual(cat.asset_count, 0)
def test_asset_count_reflects_linked_assets(self):
cat = self.env['fusion.asset.category'].create({'name': 'Vehicles'})
for i in range(3):
self.env['fusion.asset'].create({
'name': f'Truck {i}',
'cost': 50000,
'acquisition_date': date(2026, 1, 1),
'method': 'declining_balance',
'category_id': cat.id,
})
cat.invalidate_recordset(['asset_count'])
self.assertEqual(cat.asset_count, 3)
def test_method_must_be_in_selection(self):
with self.assertRaises(Exception):
self.env['fusion.asset.category'].create({
'name': 'Bogus',
'method': 'not_a_method',
})

View File

@@ -0,0 +1,62 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionAssetDepreciationLine(TransactionCase):
def setUp(self):
super().setUp()
self.asset = self.env['fusion.asset'].create({
'name': 'Asset for Lines',
'cost': 12000,
'salvage_value': 0,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line',
'useful_life_years': 1,
})
def _make_line(self, period_index, amount=1000.0, scheduled_date=None):
return self.env['fusion.asset.depreciation.line'].create({
'asset_id': self.asset.id,
'period_index': period_index,
'scheduled_date': scheduled_date or date(2026, period_index, 28),
'amount': amount,
})
def test_create_line_defaults_unposted(self):
line = self._make_line(1)
self.assertFalse(line.is_posted)
self.assertFalse(line.posted_date)
self.assertFalse(line.move_id)
self.assertEqual(line.company_id, self.asset.company_id)
self.assertEqual(line.currency_id, self.asset.currency_id)
def test_action_post_marks_line_posted(self):
line = self._make_line(2)
line.action_post()
self.assertTrue(line.is_posted)
self.assertTrue(line.posted_date)
def test_action_post_idempotent_keeps_first_date(self):
line = self._make_line(3)
line.action_post()
first_date = line.posted_date
line.action_post()
self.assertEqual(line.posted_date, first_date)
def test_unique_period_per_asset(self):
self._make_line(4)
with self.assertRaises(Exception):
self._make_line(4)
def test_book_value_reflects_posted_lines_only(self):
l1 = self._make_line(5, amount=1000)
self._make_line(6, amount=1500)
self.assertEqual(self.asset.book_value, 12000)
l1.action_post()
self.asset.invalidate_recordset(['book_value', 'total_depreciated'])
self.assertEqual(self.asset.total_depreciated, 1000)
self.assertEqual(self.asset.book_value, 11000)

View File

@@ -0,0 +1,56 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionAssetDisposal(TransactionCase):
def setUp(self):
super().setUp()
self.asset = self.env['fusion.asset'].create({
'name': 'Disposable Asset',
'cost': 10000,
'salvage_value': 0,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line',
'useful_life_years': 5,
})
def test_create_minimal_sale(self):
d = self.env['fusion.asset.disposal'].create({
'asset_id': self.asset.id,
'disposal_type': 'sale',
'sale_amount': 7000,
'book_value_at_disposal': 6000,
})
self.assertEqual(d.gain_loss_amount, 1000)
self.assertEqual(d.company_id, self.asset.company_id)
def test_sale_at_loss(self):
d = self.env['fusion.asset.disposal'].create({
'asset_id': self.asset.id,
'disposal_type': 'sale',
'sale_amount': 4000,
'book_value_at_disposal': 6000,
})
self.assertEqual(d.gain_loss_amount, -2000)
def test_scrap_full_loss(self):
d = self.env['fusion.asset.disposal'].create({
'asset_id': self.asset.id,
'disposal_type': 'scrap',
'sale_amount': 0,
'book_value_at_disposal': 6000,
})
self.assertEqual(d.gain_loss_amount, -6000)
def test_donation_ignores_sale_amount(self):
d = self.env['fusion.asset.disposal'].create({
'asset_id': self.asset.id,
'disposal_type': 'donation',
'sale_amount': 999,
'book_value_at_disposal': 6000,
})
self.assertEqual(d.gain_loss_amount, -6000)

View File

@@ -0,0 +1,115 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.exceptions import ValidationError
@tagged('post_install', '-at_install')
class TestFusionAssetEngine(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.asset.engine']
self.asset = self.env['fusion.asset'].create({
'name': 'Test Engine Asset',
'cost': 10000,
'salvage_value': 1000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line',
'useful_life_years': 5,
})
def test_engine_model_exists(self):
self.assertIn('fusion.asset.engine', self.env.registry)
def test_compute_schedule_straight_line(self):
result = self.engine.compute_depreciation_schedule(self.asset)
self.assertEqual(result['lines_created'], 5)
lines = self.asset.depreciation_line_ids
self.assertEqual(len(lines), 5)
# Total depreciation should equal cost - salvage = 9000
total = sum(lines.mapped('amount'))
self.assertAlmostEqual(total, 9000, places=2)
def test_compute_schedule_declining_balance(self):
self.asset.write({'method': 'declining_balance', 'declining_rate_pct': 30.0})
self.engine.compute_depreciation_schedule(self.asset)
lines = self.asset.depreciation_line_ids
self.assertGreater(len(lines), 0)
# First-period amount should be cost * rate = 10000 * 0.3 = 3000
first = lines.sorted('period_index')[0]
self.assertAlmostEqual(first.amount, 3000, places=2)
def test_compute_schedule_recompute_wipes_unposted(self):
self.engine.compute_depreciation_schedule(self.asset)
self.asset.write({'useful_life_years': 8})
self.engine.compute_depreciation_schedule(self.asset, recompute=True)
self.assertEqual(len(self.asset.depreciation_line_ids), 8)
def test_compute_schedule_validates_zero_cost(self):
# Bypass DB constraint with sudo + the constraint allows cost >= 0,
# but engine validation requires cost > 0.
bad = self.env['fusion.asset'].create({
'name': 'Zero',
'cost': 0,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line',
'useful_life_years': 5,
})
with self.assertRaises(ValidationError):
self.engine.compute_depreciation_schedule(bad)
def test_post_depreciation_entry_marks_line_posted(self):
self.engine.compute_depreciation_schedule(self.asset)
self.asset.action_set_running()
result = self.engine.post_depreciation_entry(self.asset)
self.assertEqual(result['posted_count'], 1)
first_line = self.asset.depreciation_line_ids.sorted('period_index')[0]
self.assertTrue(first_line.is_posted)
def test_post_depreciation_only_after_running(self):
self.engine.compute_depreciation_schedule(self.asset)
# asset is still in 'draft' state
with self.assertRaises(ValidationError):
self.engine.post_depreciation_entry(self.asset)
def test_dispose_asset_creates_disposal_record(self):
self.engine.compute_depreciation_schedule(self.asset)
self.asset.action_set_running()
result = self.engine.dispose_asset(
self.asset, sale_amount=5000, sale_date=date(2027, 6, 1),
)
self.assertEqual(self.asset.state, 'disposed')
self.assertIn('disposal_id', result)
self.assertEqual(result['book_value_at_disposal'], self.asset.book_value)
def test_partial_sale_creates_child_and_disposes(self):
self.engine.compute_depreciation_schedule(self.asset)
self.asset.action_set_running()
original_cost = self.asset.cost
result = self.engine.partial_sale(
self.asset, sold_amount=3000, sold_qty=0.3,
sale_date=date(2027, 6, 1),
)
self.assertIn('parent_asset_id', result)
self.assertIn('child_asset_id', result)
self.asset.invalidate_recordset(['cost'])
expected_remaining = round(original_cost * 0.7, 2)
self.assertAlmostEqual(self.asset.cost, expected_remaining, places=2)
def test_pause_resume_round_trip(self):
self.asset.action_set_running()
self.engine.pause_asset(self.asset)
self.assertEqual(self.asset.state, 'paused')
self.engine.resume_asset(self.asset)
self.assertEqual(self.asset.state, 'running')
def test_reverse_disposal_restores_running_state(self):
self.engine.compute_depreciation_schedule(self.asset)
self.asset.action_set_running()
self.engine.dispose_asset(self.asset, sale_amount=5000)
self.assertEqual(self.asset.state, 'disposed')
self.engine.reverse_disposal(self.asset)
self.assertEqual(self.asset.state, 'running')

View File

@@ -0,0 +1,65 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_assets.services.prorate import prorate_factor
@tagged('post_install', '-at_install')
class TestProrate(TransactionCase):
def test_full_month_convention_always_one(self):
f = prorate_factor(
period_start=date(2026, 1, 1),
period_end=date(2026, 1, 31),
asset_start=date(2026, 1, 15),
convention='full_month',
)
self.assertEqual(f, 1.0)
def test_asset_starts_before_period_full_factor(self):
f = prorate_factor(
period_start=date(2026, 1, 1),
period_end=date(2026, 1, 31),
asset_start=date(2025, 12, 1),
convention='days_period',
)
self.assertEqual(f, 1.0)
def test_asset_starts_after_period_zero_factor(self):
f = prorate_factor(
period_start=date(2026, 1, 1),
period_end=date(2026, 1, 31),
asset_start=date(2026, 2, 5),
convention='days_period',
)
self.assertEqual(f, 0.0)
def test_days_period_mid_month(self):
# Jan 16 -> Jan 31 inclusive = 16 days; period = 31 days
f = prorate_factor(
period_start=date(2026, 1, 1),
period_end=date(2026, 1, 31),
asset_start=date(2026, 1, 16),
convention='days_period',
)
self.assertAlmostEqual(f, 16 / 31, places=5)
def test_days_365_mid_month(self):
# 16 days / 365
f = prorate_factor(
period_start=date(2026, 1, 1),
period_end=date(2026, 1, 31),
asset_start=date(2026, 1, 16),
convention='days_365',
)
self.assertAlmostEqual(f, 16 / 365.0, places=5)
def test_unknown_convention_raises(self):
with self.assertRaises(ValueError):
prorate_factor(
period_start=date(2026, 1, 1),
period_end=date(2026, 1, 31),
asset_start=date(2026, 1, 15),
convention='bogus', # type: ignore[arg-type]
)

View File

@@ -0,0 +1,45 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_assets.services.salvage_value import (
SalvageConfig, compute_salvage_value, remaining_useful_life_value,
)
@tagged('post_install', '-at_install')
class TestSalvageValue(TransactionCase):
def test_zero_method_returns_zero(self):
v = compute_salvage_value(cost=10000, config=SalvageConfig(method='zero'))
self.assertEqual(v, 0.0)
def test_percentage_method(self):
v = compute_salvage_value(
cost=10000, config=SalvageConfig(method='percentage', value=10),
)
self.assertAlmostEqual(v, 1000.0, places=2)
def test_fixed_method(self):
v = compute_salvage_value(
cost=10000, config=SalvageConfig(method='fixed', value=750),
)
self.assertAlmostEqual(v, 750.0, places=2)
def test_unknown_method_raises(self):
with self.assertRaises(ValueError):
compute_salvage_value(
cost=10000,
config=SalvageConfig(method='bogus', value=0), # type: ignore[arg-type]
)
def test_remaining_useful_life_value_midway(self):
# Halfway through life; current book 6000, salvage 1000 -> 1000 + 5000*0.5 = 3500
v = remaining_useful_life_value(
current_book=6000, salvage=1000, periods_used=5, total_periods=10,
)
self.assertAlmostEqual(v, 3500.0, places=2)
def test_remaining_useful_life_value_at_end_returns_salvage(self):
v = remaining_useful_life_value(
current_book=1200, salvage=1000, periods_used=10, total_periods=10,
)
self.assertEqual(v, 1000.0)

View File

@@ -0,0 +1,61 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_assets.services.useful_life_predictor import (
predict_useful_life,
)
from odoo.addons.fusion_accounting_assets.services.useful_life_prompt import (
SYSTEM_PROMPT, build_prompt,
)
@tagged('post_install', '-at_install')
class TestUsefulLifePredictor(TransactionCase):
def setUp(self):
super().setUp()
# Ensure no provider configured for these fallback tests.
self.env['ir.config_parameter'].sudo().search([
('key', 'in', [
'fusion_accounting.provider.asset_useful_life',
'fusion_accounting.provider.default',
])
]).unlink()
def test_fallback_computer(self):
result = predict_useful_life(self.env, description="Dell laptop")
self.assertEqual(result['useful_life_years'], 4)
self.assertEqual(result['depreciation_method'], 'straight_line')
def test_fallback_furniture(self):
result = predict_useful_life(self.env, description="office desk")
self.assertEqual(result['useful_life_years'], 7)
def test_fallback_vehicle_uses_declining(self):
result = predict_useful_life(self.env, description="Ford F-150 truck")
self.assertEqual(result['useful_life_years'], 5)
self.assertEqual(result['depreciation_method'], 'declining_balance')
def test_fallback_default_for_unknown(self):
result = predict_useful_life(self.env, description="mystery widget")
self.assertEqual(result['useful_life_years'], 5)
self.assertEqual(result['confidence'], 0.3)
def test_returns_dict_with_required_keys(self):
result = predict_useful_life(self.env, description="server")
for key in ('useful_life_years', 'depreciation_method', 'rationale', 'confidence'):
self.assertIn(key, result)
@tagged('post_install', '-at_install')
class TestUsefulLifePrompt(TransactionCase):
def test_system_prompt_requires_json(self):
self.assertIn('JSON', SYSTEM_PROMPT)
def test_build_prompt_returns_tuple(self):
result = build_prompt(description='test')
self.assertEqual(len(result), 2)
def test_user_prompt_includes_amount(self):
_, user = build_prompt(description='laptop', amount=2000)
self.assertIn('2,000', user)

View File

@@ -0,0 +1,147 @@
# fusion_accounting_reports — Cursor / Claude Context
## Purpose
AI-augmented financial reports — a Fusion-native replacement for Odoo
Enterprise's `account_reports` module. Phase 2 of the fusion_accounting
roadmap.
CORE scope:
- Income Statement (P&L)
- Balance Sheet
- Trial Balance
- General Ledger (with drill-down)
AI augmentation:
- Anomaly detection (variance vs prior period)
- AI commentary (LLM-generated narrative)
## Architecture
Hybrid: the engine (`fusion.report.engine`, AbstractModel) is the SINGLE
read surface for reports. Per-report definitions are stored as `fusion.report`
records with JSON `line_specs` so non-developers can tweak the layouts.
Public engine API (5 methods):
- `compute_pnl(period, *, comparison='none', company_id=None)`
- `compute_balance_sheet(date_to, *, comparison='none', company_id=None)`
- `compute_trial_balance(period, *, company_id=None)`
- `compute_gl(period, *, account_ids=None, company_id=None)`
- `drill_down(*, account_id, period, company_id=None)`
Pure-Python services in `services/` (no Odoo imports — independently
unit-testable):
- `date_periods``Period` dataclass + comparison-period math
- `account_hierarchy` — chart-of-accounts tree walk
- `totaling` — debit/credit/balance roll-ups
- `currency_conversion` — multi-currency conversion via `res.currency.rate`
- `line_resolver` — JSON `line_specs` → rendered rows
- `drill_down_resolver` — line → underlying journal items
- `anomaly_detection` — variance vs prior period (z-score + abs/pct gates)
- `commentary_generator` — LLM narrative with templated fallback
- `commentary_prompt` — provider-agnostic system + user prompt
Persisted models in `models/`:
- `fusion.report` — definition with JSON `line_specs`
- `fusion.report.commentary` — LLM-output cache (one per period+mode)
- `fusion.report.anomaly` — flagged variances
- `fusion.account.balance.mv` — pre-aggregated materialized view
- `fusion.report.engine` — AbstractModel (the API)
- `fusion.reports.cron` — cron handlers (commentary refresh, MV refresh)
- `fusion.xlsx.export.wizard` — TransientModel (XLSX export)
- `fusion.period.picker.wizard` — TransientModel (UX entry-point)
- `fusion.migration.wizard` (inherits) — adds `_reports_bootstrap_step`
Controller: `controllers/reports_controller.py` exposes 8 JSON-RPC endpoints
under `/fusion/reports/*`. All read paths route through the engine.
OWL frontend: `static/src/`
- `scss/` — variables, base styles, dark-mode overrides
- `services/reports_service.js` — central reactive state + RPC wrappers
- `views/report_viewer/` — top-level OWL view + view-registry adapter
- `components/report_table/` — generic financial-table renderer
- `components/drill_down_dialog/` — modal for journal-item listing
- `components/period_filter/` — date-range + comparison picker
- `components/ai_commentary_panel/` — LLM commentary surface
- `components/anomaly_strip/` — variance summary banner
- `tours/reports_tours.js` — 5 OWL tour smoke tests
## Coexistence
When `account_reports` is installed, the Reports menu hides via
`fusion_accounting_core.group_fusion_show_when_enterprise_absent`
(a computed group). The engine + AI tools (commentary, anomaly detection)
remain available for the chat regardless.
## Conventions
- **V19 deprecations to avoid:** `_sql_constraints` (use `models.Constraint`),
`@api.depends('id')` (raises `NotImplementedError`), `@route(type='json')`
(use `type='jsonrpc'`), `numbercall` field on `ir.cron` (removed),
`groups_id` on `res.users` (use `all_group_ids` for searching),
`users` field on `res.groups` (use `user_ids`), `groups_id` on
`ir.ui.menu` (use `group_ids`).
- **Engine signature:** Public methods are keyword-only after the leading
positional `period` / `date_to`. Always pass `company_id=...` explicitly.
- **`fusion.report` lookup:** `_get_report` falls back from per-company
override to global (`company_id=False`) — order is `company_id desc nulls
last`.
- **Materialized view refresh:** `fusion.account.balance.mv` rebuilds via a
dedicated autocommit cursor (REFRESH CONCURRENTLY can't run inside Odoo's
regular transaction). Triggered by cron + on demand from the engine when
data is older than the configured TTL.
- **JSON `line_specs`:** Strings prefixed `account:`, `prefix:`, `formula:`
or `header``line_resolver.py` resolves each spec to a row. Header rows
have no compute payload and are silently skipped by downstream totals.
- **Commentary cache:** Keyed on `(report_id, company_id, period_from,
period_to, comparison_mode)` with a unique constraint. Re-runs use the
cache unless `force_refresh=True`.
## Test counts (Phase 2 ship)
- 130 logical tests, 0 failed, 0 errors
- Includes:
- 6 benchmarks (tagged `benchmark`)
- 1 LLM compat smoke (tagged `local_llm`, skips when no LLM)
- 5 OWL tours (tagged `tour`, skips without `websocket-client`)
- Property-based, integration, controller, materialized-view, coexistence,
migration round-trip, PDF/XLSX export
## Performance baseline
| Operation | Median | P95 | Budget |
|---|---|---|---|
| `engine.compute_pnl` | 3ms | 8ms | <2000ms |
| `engine.compute_balance_sheet` | 15ms | 20ms | <2000ms |
| `engine.compute_trial_balance` | 3ms | 8ms | <1000ms |
| `engine.compute_gl` | 25ms | 81ms | <3000ms |
| `engine.drill_down` | 2ms | 10ms | <500ms |
| `controller.run` (HTTP round-trip) | 9ms | 46ms | <2500ms |
All metrics within 1x of budget at Phase 2 ship. Numbers from
`tests/test_performance_benchmarks.py` against the dev VM
(`westin-v19`, ~1 fiscal year of data).
## Known concerns / Phase 2.5 backlog
- Trial balance period-only sum doesn't auto-close to retained earnings
(drift visible in `test_trial_balance_total_near_zero`, currently skipped)
- Balance sheet `TOTAL LIABILITIES + EQUITY` math limited (no
subtotal-of-subtotals expansion in `formula:` specs)
- GL `line_specs` need `prefix:` empty-string handling for
"all accounts" semantics
- Header rows (no compute payload) silently skipped by `line_resolver` —
fine for layout, but a `header_only=True` flag would be clearer
- `expense` prefix overlaps with subtypes (`expense_direct_cost`,
`expense_depreciation`) — current line_specs need explicit ordering or a
longer-prefix-wins rule
- `wkhtmltopdf` may need configuration for PDF export on first install
- `ReportsAdapter.run_report` vs `run_fusion_report` naming (legacy clash
with Enterprise wrapper)
- Tour tests skip when `websocket-client` is absent — install it in CI to
exercise the OWL surface end-to-end

View File

@@ -0,0 +1,103 @@
# fusion_accounting_reports
AI-augmented financial reports for Odoo 19 Community — a Fusion-native
replacement for Enterprise's `account_reports` module.
## What it does
- **CORE reports**: Income Statement (P&L), Balance Sheet, Trial Balance,
General Ledger (with drill-down to journal items)
- **AI augmentation**: variance-based anomaly detection + LLM-generated
commentary (Claude / GPT / local LM Studio / Ollama)
- **Wizards**: period picker (common presets — MTD, QTD, YTD, last month,
custom range) + XLSX export
- **Coexists** with Enterprise's `account_reports` (Enterprise wins by
default; the Fusion menu appears only when Enterprise is uninstalled —
the engine and AI tools are always available via the AI chat)
- **Multi-currency** aware via `services/currency_conversion.py`
- **Multi-company** aware (per-company `fusion.report` overrides fall back
to global definitions)
## Quick start
```bash
# Install
odoo --addons-path=... -i fusion_accounting_reports
# Open the reports menu (when Enterprise's account_reports is NOT installed)
# Apps → Reports → Open Financial Report
```
## Configuration
### LLM commentary (optional)
For LM Studio / Ollama (local):
- `fusion_accounting.openai_base_url` = `http://host.docker.internal:1234/v1`
- `fusion_accounting.openai_model` = your local model name
- `fusion_accounting.openai_api_key` = `lm-studio` (or anything non-empty)
- `fusion_accounting.provider.reports_commentary` = `openai`
For OpenAI / Anthropic, set the corresponding API keys via the
`fusion_accounting_ai` config screen — `reports_commentary` will route
through whatever provider you choose.
If no provider is configured, commentary falls back to a deterministic
templated summary (no LLM call).
### Cron jobs
Two cron handlers live in `models/fusion_reports_cron.py`:
- `fusion_reports_commentary_refresh` — daily, regenerates commentary for
the most recently completed period
- `fusion_reports_mv_refresh` — every 15 min, refreshes
`fusion.account.balance.mv`
## Public engine API
```python
engine = env['fusion.report.engine']
# Income statement
result = engine.compute_pnl(period, comparison='previous_year')
# Balance sheet (point-in-time)
result = engine.compute_balance_sheet(date(2026, 12, 31))
# Trial balance
result = engine.compute_trial_balance(period)
# General ledger (journal items per account)
result = engine.compute_gl(period, account_ids=[1, 2, 3])
# Drill-down (one account, period)
items = engine.drill_down(account_id=1, period=period)
```
## JSON-RPC endpoints
All under `/fusion/reports/`:
- `POST /fusion/reports/run` — single entry-point (dispatches by `report_type`)
- `POST /fusion/reports/drill_down` — journal items for an account+period
- `POST /fusion/reports/commentary` — fetch/refresh LLM commentary
- `POST /fusion/reports/anomalies` — flagged variances for a period
- `POST /fusion/reports/export_xlsx` — XLSX bytes
- `POST /fusion/reports/export_pdf` — PDF bytes (via wkhtmltopdf)
- `POST /fusion/reports/list_definitions` — available `fusion.report` records
- `POST /fusion/reports/period_presets` — date-range presets for the picker
## Test counts
- 130 logical tests, 0 failures, 0 errors
- 6 performance benchmarks (tagged `benchmark`)
- 1 local-LLM compat smoke (tagged `local_llm`, skips without LLM)
- 5 OWL tour tests (tagged `tour`, skips without `websocket-client`)
## See also
- `CLAUDE.md` — agent context (architecture, conventions, perf baseline,
Phase 2.5 backlog)
- `UPGRADE_NOTES.md` — V19 anchor + migration strategy

View File

@@ -0,0 +1,60 @@
# fusion_accounting_reports — Upgrade Notes
## Odoo Version Anchor
This module targets **Odoo 19.0** (community-base).
Reference snapshot of Enterprise code mirrored from:
- `account_reports` (Odoo 19.0.x)
- Source: `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_reports/`
## Cross-Version Diff Strategy
When a new Odoo version ships:
1. Run `check_odoo_diff.sh` (in repo root) against the new Enterprise version
2. Note any breaking changes in `account.move.line` / `account.account` API
surfaces relied on by `services/totaling.py` and
`services/drill_down_resolver.py`
3. For mirrored OWL components, diff Enterprise's new versions against ours
and port material changes (signature renames, new behaviour we want to
inherit)
4. Re-run the full test suite + tour tests + benchmarks against the new Odoo
version
5. Update this file with the new version anchor + any deviations
## V19 Migration Notes (already applied — Phase 1 lessons)
These were the bite-points from Phase 1 (`fusion_accounting_bank_rec`); we
preempted them in Phase 2 from day one:
- `_sql_constraints``models.Constraint` (used in `fusion.report`,
`fusion.report.commentary`, `fusion.report.anomaly`)
- `@api.depends('id')` → removed everywhere; computed fields depend on real
field names instead
- `@route(type='json')``type='jsonrpc'` (all 8 endpoints)
- `numbercall` field on `ir.cron` → omitted (removed in V19)
- `res.groups.users``user_ids`
- `ir.ui.menu.groups_id``group_ids` (used in `views/menu_views.xml` and
the two wizard view files for the coexistence-group filter)
## Engine API Stability
The 5 public engine methods (`compute_pnl`, `compute_balance_sheet`,
`compute_trial_balance`, `compute_gl`, `drill_down`) are the public contract.
Their signatures are keyword-only after the first positional argument and
will be treated as semver-stable across patch releases. Breaking changes
will bump the minor version (e.g. 19.0.2.x.y).
## Phase 2 → Phase 2.5 Migration
If we ship Phase 2.5 (line_spec polish, deferred features, header_only
flag, prefix overlap fix), changes will go in incremental commits. No DB
migration needed — Phase 2 schema is forward-compatible:
- `fusion.report.line_specs` is a JSON column; the migration path is to
rewrite specs in place
- `fusion.account.balance.mv` can be dropped/re-created freely
- `fusion.report.commentary` is a cache; safe to truncate on upgrade
- `fusion.report.anomaly` records carry Period as date_from/date_to fields;
no schema-level changes anticipated

View File

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

View File

@@ -0,0 +1,76 @@
{
'name': 'Fusion Accounting Reports',
'version': '19.0.1.0.38',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
'description': """
Fusion Accounting Reports
=========================
A Fusion-native replacement for Odoo Enterprise's account_reports module.
CORE scope (Phase 2):
- Income Statement (P&L)
- Balance Sheet
- Trial Balance
- General Ledger (with drill-down)
AI augmentation:
- Anomaly detection (variance vs prior period)
- AI commentary (LLM-generated narrative)
Coexists with Enterprise: when account_reports is installed, the Fusion
menu hides; the engine and AI tools remain available for the chat.
""",
'author': 'Fusion Accounting',
'license': 'LGPL-3',
'depends': [
'fusion_accounting_core',
'fusion_accounting_ai',
'fusion_accounting_migration',
'account',
],
'data': [
'security/ir.model.access.csv',
'data/report_pnl.xml',
'data/report_balance_sheet.xml',
'data/report_trial_balance.xml',
'data/report_general_ledger.xml',
'data/cron.xml',
'reports/report_pdf_template.xml',
'wizards/xlsx_export_wizard_views.xml',
'wizards/period_picker_wizard_views.xml',
'views/menu_views.xml',
],
'external_dependencies': {
'python': ['xlsxwriter'],
},
'assets': {
'web.assets_backend': [
'fusion_accounting_reports/static/src/scss/_variables.scss',
'fusion_accounting_reports/static/src/scss/reports.scss',
'fusion_accounting_reports/static/src/scss/dark_mode.scss',
'fusion_accounting_reports/static/src/services/reports_service.js',
'fusion_accounting_reports/static/src/views/report_viewer/report_viewer.js',
'fusion_accounting_reports/static/src/views/report_viewer/report_viewer.xml',
'fusion_accounting_reports/static/src/views/report_viewer/report_viewer_view.js',
'fusion_accounting_reports/static/src/components/report_table/report_table.js',
'fusion_accounting_reports/static/src/components/report_table/report_table.xml',
'fusion_accounting_reports/static/src/components/drill_down_dialog/drill_down_dialog.js',
'fusion_accounting_reports/static/src/components/drill_down_dialog/drill_down_dialog.xml',
'fusion_accounting_reports/static/src/components/period_filter/period_filter.js',
'fusion_accounting_reports/static/src/components/period_filter/period_filter.xml',
'fusion_accounting_reports/static/src/components/ai_commentary_panel/ai_commentary_panel.js',
'fusion_accounting_reports/static/src/components/ai_commentary_panel/ai_commentary_panel.xml',
'fusion_accounting_reports/static/src/components/anomaly_strip/anomaly_strip.js',
'fusion_accounting_reports/static/src/components/anomaly_strip/anomaly_strip.xml',
],
'web.assets_tests': [
'fusion_accounting_reports/static/src/tours/reports_tours.js',
],
},
'installable': True,
'auto_install': False,
'application': False,
'icon': '/fusion_accounting_reports/static/description/icon.png',
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More