5 Commits

Author SHA1 Message Date
gsinghpal
43e1f3d6f5 docs(fusion_accounting_assets): 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
Mirrors Phase 1 + 2 doc layout. CLAUDE.md captures architecture, the
7-method engine API, persisted models, controllers, OWL frontend,
performance baselines (Tasks 23 + 41 numbers), test counts (140), and
Phase 3.5 backlog. UPGRADE_NOTES.md anchors the Odoo 19 reference and
records V19 deprecations applied. README.md is the user-facing intro.

Made-with: Cursor
2026-04-19 20:25:16 -04:00
gsinghpal
69453bd8ae feat(fusion_accounting): meta-module now installs assets sub-module
Adds fusion_accounting_assets to the meta-module 'depends' so a single
install of fusion_accounting brings up the full Phase 1 + 2 + 3 stack.
Bumps version 19.0.1.0.2 -> 19.0.1.0.3.

Made-with: Cursor
2026-04-19 20:23:47 -04:00
gsinghpal
7e2c31e371 test(fusion_accounting_assets): local LLM useful-life smoke (skips without LLM)
Auto-detects LM Studio (:1234) or Ollama (:11434) on
host.docker.internal / localhost; skips silently when no server is
reachable so CI stays green. When a server is present it exercises the
full predict_useful_life path through the OpenAI-compatible adapter,
catching prompt / JSON-parsing regressions that mocked LLMs hide.

Tagged 'local_llm' so it can be selected explicitly when an LLM is
known-available.

Made-with: Cursor
2026-04-19 20:23:30 -04:00
gsinghpal
6344a75150 test(fusion_accounting_assets): controller perf benchmark
Adds JSON-RPC controller benchmark to complement Task 23's engine-level
benchmarks: end-to-end /fusion/assets/get_detail timing through the HTTP
dispatch layer.

Captured locally on westin-v19:
  controller.get_detail: median=2ms p95=40ms (target <500ms, 12x headroom)

Tagged 'benchmark' so it stays out of fast unit runs.

Made-with: Cursor
2026-04-19 20:22:50 -04:00
gsinghpal
59ecc9fc5b test(fusion_accounting_assets): 5 OWL tour tests
Mirrors Phase 1 + 2 tour pattern: HttpCase.start_tour wrappers tagged
'tour' so they skip cleanly when websocket-client is absent. Tours cover
smoke (/odoo loads), the asset list / category list / anomaly list views,
and the depreciation-run wizard form. Bundle is wired via
web.assets_tests.

Verified locally: 5 tests registered, all skip with
"websocket-client module is not installed" (expected — no chromium in
the dev container).

Made-with: Cursor
2026-04-19 20:22:13 -04:00
10 changed files with 492 additions and 4 deletions

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting',
'version': '19.0.1.0.2',
'version': '19.0.1.0.3',
'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).',
@@ -15,11 +15,11 @@ Currently installs:
- 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)
- fusion_accounting_assets AI-augmented asset management (Phase 3)
Future sub-modules (added per the roadmap as each Phase ships):
- fusion_accounting_dashboard (Phase 3)
- fusion_accounting_dashboard (Phase 4)
- fusion_accounting_followup (Phase 5)
- fusion_accounting_assets (Phase 6)
- fusion_accounting_budget (Phase 6)
Built by Nexa Systems Inc.
@@ -35,6 +35,7 @@ Built by Nexa Systems Inc.
'fusion_accounting_migration',
'fusion_accounting_bank_rec',
'fusion_accounting_reports',
'fusion_accounting_assets',
],
'data': [],
'installable': True,

View File

@@ -0,0 +1,130 @@
# fusion_accounting_assets — Cursor / Claude Context
## Purpose
AI-augmented fixed asset management with depreciation schedules — a
Fusion-native replacement for (and coexisting with) Odoo Enterprise's
`account_asset` module. Ships in Phase 3 of the fusion_accounting roadmap.
## Architecture
Hybrid: the engine (`fusion.asset.engine`, AbstractModel) is the SINGLE
write surface for the asset lifecycle. Everything else (controllers, OWL
widget, AI tools, wizards, cron) routes through the engine's 7-method
public API:
- `compute_depreciation_schedule(asset, recompute=False)`
- `post_depreciation_entry(asset, period_date=None)`
- `dispose_asset(asset, sale_amount=0, sale_date=None, sale_partner=None, disposal_type='sale')`
- `partial_sale(asset, sold_amount, sold_qty=None, sale_date=None, sale_partner=None)`
- `pause_asset(asset, pause_date=None)`
- `resume_asset(asset, resume_date=None)`
- `reverse_disposal(asset)`
Pure-Python services live in `services/`:
- `depreciation_methods` — straight_line, declining_balance, units_of_production
- `prorate` — first/last-period prorating: full_month, days_365, days_period
- `salvage_value` — % of cost, fixed amount, zero
- `anomaly_detection` — variance vs expected schedule, low utilization
- `useful_life_predictor` + `useful_life_prompt` — LLM-suggested useful life with templated fallback
Persisted models in `models/`:
- `fusion.asset` — main model, state machine: draft → running → paused → disposed
- `fusion.asset.depreciation.line` — board lines
- `fusion.asset.category` — templates
- `fusion.asset.disposal` — disposal records
- `fusion.asset.anomaly` — flagged variances
- `fusion.asset.book.values.mv` — pre-aggregated materialized view
- `fusion.asset.engine` — AbstractModel (the API)
- `fusion.assets.cron` — cron handlers (post depreciations, MV refresh, anomaly scan)
- `account.move.line` (inherits) — adds `fusion_asset_id` linkage
- `fusion.migration.wizard` (inherits in `models/`) — adds asset backfill step
Wizards (TransientModel) in `wizards/`:
- `fusion.create.asset.wizard` — assisted creation with AI useful-life suggestion
- `fusion.disposal.wizard` — full disposal flow
- `fusion.partial.sale.wizard` — partial-quantity disposal
- `fusion.depreciation.run.wizard` — period close runner
Controller: `controllers/assets_controller.py` exposes 8 JSON-RPC
endpoints under `/fusion/assets/*` (list, get_detail, compute_schedule,
post_depreciation, dispose, get_anomalies, suggest_useful_life,
get_partner_history). All calls route through the engine.
OWL frontend: `static/src/`
- `services/assets_service.js` — central reactive state + RPC wrappers
- `views/asset_dashboard/*` — top-level dashboard view
- `components/asset_card`, `asset_detail_panel`, `depreciation_board`,
`disposal_dialog`, `ai_useful_life_panel`, `anomaly_strip` — 6 components
- `scss/_variables.scss` + `assets.scss` + `dark_mode.scss`
- `tours/assets_tours.js` — 5 OWL tour smoke tests
## Coexistence
When `account_asset` is installed the Asset Management menu hides via
`fusion_accounting_core.group_fusion_show_when_enterprise_absent` (a
computed group). The engine + AI tools remain available for the chat.
The migration wizard backfills `fusion.asset` from existing
`account.asset` records (verified live: 2 records, Task 35).
## 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`).
- **Materialized view refresh:** `fusion.asset.book.values.mv` is
refreshed by cron (REFRESH CONCURRENTLY in an autocommit cursor since
it can't run inside a regular Odoo transaction).
- **Provider routing:** AI features look up
`fusion_accounting.provider.asset_useful_life`, falling back to
`fusion_accounting.provider.default`. When neither is set the
templated keyword fallback in `useful_life_predictor` keeps the
feature usable offline.
## Performance baseline (Tasks 23 + 41)
| Operation | P95 | Budget | Headroom |
|------------------------------------|-------|----------|----------|
| `engine.compute_schedule` (10yr SL)| 1ms | 500ms | 500x |
| `engine.post_depreciation_entry` | <1ms | 300ms | huge |
| `engine.dispose_asset` | 5ms | 300ms | 60x |
| `controller.list` (35 assets) | 42ms | 300ms | 7x |
| `controller.get_detail` | 40ms | 500ms | 12x |
All Phase 3 perf metrics are within 1x of budget; no optimization was
needed at ship (Task 42 skipped per the conditional rule).
## Test counts (Phase 3 ship)
- 140 logical tests total in fusion_accounting_assets
- 0 failures, 0 errors
- Coverage includes: 4 engine benchmarks + 1 controller benchmark
(tagged `benchmark`), 1 local LLM smoke (tagged `local_llm`, skips
when no LLM), 5 OWL tour tests (tagged `tour`, skip without
websocket-client), Hypothesis property tests on the engine,
integration tests on the public API, controller round-trip tests, MV
shape tests.
## Known concerns / Phase 3.5 backlog
- Sub-annual depreciation frequency (currently annual only)
- Units-of-production assumes even per-period units
- Disposal journal entry not yet created — `dispose_asset` writes the
`fusion.asset.disposal` record but not the cash / gain-loss move
- Multi-currency, allocation rules, and analytic tags for depreciation
moves are out of scope for Phase 3
- Partial-sale child asset is created with no own depreciation schedule
pre-disposal
- Migration wizard inheritance lives in `models/` rather than
`wizards/` (small inconsistency with the rest of the wizard layout —
intentional to keep ORM ordering simple)
- `useful_life_predictor` always returns a usable dict (templated
fallback when LLM absent), so callers can't distinguish "AI said so"
from "fallback fired"; the `confidence` key is the only signal

View File

@@ -0,0 +1,53 @@
# fusion_accounting_assets
AI-augmented fixed asset management for Odoo 19 Community — a
Fusion-native replacement for Enterprise's `account_asset` module.
## What it does
- Three depreciation methods: straight-line, declining balance, and
units-of-production
- Asset lifecycle state machine: draft → running → paused → disposed
- Editable depreciation board with full schedule recompute
- Disposal flow (sale, scrap, donation) plus partial-sale wizard
- Daily cron for posting periodic depreciation
- AI augmentation:
- **Anomaly detection** — variance vs expected schedule, low utilization
- **Useful-life suggestion** — LLM-driven from invoice context, with a
keyword-based templated fallback so the feature still works offline
- Coexists with Enterprise `account_asset` (Enterprise wins by default;
the Fusion menu only appears when Enterprise is uninstalled)
- Migration-aware: bootstrap step backfills `fusion.asset` from existing
`account.asset` rows so the AI has memory from day 1
## Quick start
```bash
# Install
odoo --addons-path=... -i fusion_accounting_assets
# Open the dashboard (when Enterprise's account_asset is NOT installed)
# Apps -> Asset Management -> Assets
# When Enterprise IS installed: use Enterprise's UI; the engine + AI tools
# are still available via the AI chat.
```
## Configuration
- Local LLM (LM Studio, Ollama):
- `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` (anything non-empty)
- `fusion_accounting.provider.asset_useful_life` = `openai`
## Public API (engine)
`fusion.asset.engine` is the single write surface. See `CLAUDE.md` for
the full 7-method signature list.
## See also
- `CLAUDE.md` — agent context
- `UPGRADE_NOTES.md` — Odoo version anchoring

View File

@@ -0,0 +1,49 @@
# fusion_accounting_assets — Upgrade Notes
## Odoo Version Anchor
This module targets **Odoo 19.0** (community-base).
Reference snapshot of Enterprise code mirrored from:
- `account_asset` (Odoo 19.0.x)
- Source: `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_asset/`
## 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.asset` / `account.move.line` API
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 against the new Odoo version
5. Update this file with the new version anchor + any deviations
## V19 Migration Notes (already applied)
- `_sql_constraints``models.Constraint` (every persisted model)
- `@api.depends('id')` → removed (none introduced)
- `@route(type='json')``type='jsonrpc'` (all 8 endpoints in
`controllers/assets_controller.py`)
- `numbercall` removed from `ir.cron` (data/cron.xml)
- `res.groups.users``user_ids` and `ir.ui.menu.groups_id`
`group_ids` (security + menu_views.xml)
## Phase 3 → Phase 3.5 Migration
If we ship Phase 3.5 (sub-annual depreciation frequency, disposal journal
entries, multi-currency, allocation rules), changes will go in
incremental commits. No DB migration needed (Phase 3 schema is
forward-compatible — new columns will be nullable / default-valued).
## Coexistence with Enterprise `account_asset`
The migration step in `fusion.migration.wizard` backfills `fusion.asset`
records from existing `account.asset` rows. It is idempotent (skips rows
already linked via the `legacy_account_asset_id` column). Verified live
on westin-v19: 2 records migrated cleanly.
When `account_asset` is installed the Asset Management menu hides via
`fusion_accounting_core.group_fusion_show_when_enterprise_absent`. The
engine and AI tools remain available for chat-driven workflows.

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting Assets',
'version': '19.0.1.0.33',
'version': '19.0.1.0.36',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented asset management with depreciation schedules.',
'description': """
@@ -65,6 +65,9 @@ menu hides; the engine + AI tools remain available for the chat.
'fusion_accounting_assets/static/src/components/anomaly_strip/anomaly_strip.js',
'fusion_accounting_assets/static/src/components/anomaly_strip/anomaly_strip.xml',
],
'web.assets_tests': [
'fusion_accounting_assets/static/src/tours/assets_tours.js',
],
},
'installable': True,
'auto_install': False,

View File

@@ -0,0 +1,80 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
/**
* 5 OWL tours for fusion_accounting_assets smoke testing.
*
* Each tour scripts a user interaction and is invoked from Python via
* HttpCase.start_tour(). Useful for catching UI regressions that asset-bundle
* compilation alone won't catch.
*/
// Tour 1: smoke
registry.category("web_tour.tours").add("fusion_assets_smoke", {
test: true,
url: "/odoo",
steps: () => [
{
content: "Wait for app",
trigger: ".o_navbar",
},
],
});
// Tour 2: open asset list
registry.category("web_tour.tours").add("fusion_assets_list", {
test: true,
url: "/odoo/action-fusion_accounting_assets.action_fusion_asset_list",
steps: () => [
{
content: "List view loads",
trigger: ".o_list_view, .o_view_nocontent",
},
],
});
// Tour 3: open categories
registry.category("web_tour.tours").add("fusion_assets_categories", {
test: true,
url: "/odoo/action-fusion_accounting_assets.action_fusion_asset_category_list",
steps: () => [
{
content: "Categories view loads",
trigger: ".o_list_view, .o_view_nocontent",
},
],
});
// Tour 4: anomalies
registry.category("web_tour.tours").add("fusion_assets_anomalies", {
test: true,
url: "/odoo/action-fusion_accounting_assets.action_fusion_asset_anomaly_list",
steps: () => [
{
content: "Anomalies view loads",
trigger: ".o_list_view, .o_view_nocontent",
},
],
});
// Tour 5: depreciation run wizard
registry.category("web_tour.tours").add("fusion_assets_depreciation_wizard", {
test: true,
url: "/odoo/action-fusion_accounting_assets.action_fusion_depreciation_run_wizard",
steps: () => [
{
content: "Wizard form opens",
trigger: ".modal-dialog .o_form_view",
},
{
content: "Period date field exists",
trigger: ".modal-dialog [name='period_date']",
},
{
content: "Close wizard",
trigger: ".modal-dialog .btn-secondary",
run: "click",
},
],
});

View File

@@ -26,3 +26,6 @@ from . import test_depreciation_run_wizard
from . import test_migration_round_trip
from . import test_audit_report
from . import test_coexistence
from . import test_assets_tours
from . import test_perf_controller
from . import test_local_llm_compat

View File

@@ -0,0 +1,28 @@
"""Python wrappers that run the OWL tours via HttpCase.start_tour.
Tours require an HTTP server + headless browser. They are tagged with
'tour' so they can be excluded from fast unit-test runs and selected
explicitly when CI has the right infra (chromium + xvfb / websocket-client).
"""
from odoo.tests.common import HttpCase
from odoo.tests import tagged
@tagged('post_install', '-at_install', 'tour')
class TestAssetsTours(HttpCase):
def test_smoke_tour(self):
self.start_tour("/odoo", "fusion_assets_smoke", login="admin")
def test_list_tour(self):
self.start_tour("/odoo", "fusion_assets_list", login="admin")
def test_categories_tour(self):
self.start_tour("/odoo", "fusion_assets_categories", login="admin")
def test_anomalies_tour(self):
self.start_tour("/odoo", "fusion_assets_anomalies", login="admin")
def test_depreciation_wizard_tour(self):
self.start_tour("/odoo", "fusion_assets_depreciation_wizard", login="admin")

View File

@@ -0,0 +1,83 @@
"""Local LLM compat smoke test for the useful_life_predictor service.
Auto-detects an LM Studio (port 1234) or Ollama (port 11434) server on
host.docker.internal or localhost. Skips silently when no local LLM is
reachable, so CI runs stay green.
When a server is present, this exercises the real OpenAI-compatible
adapter end-to-end against a local model — i.e. it catches prompt /
JSON-parsing regressions that only show up with a non-mocked LLM.
"""
import socket
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
def _server_reachable(host, port, timeout=1.0):
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except (OSError, socket.timeout):
return False
def _detect_local_llm():
candidates = [
('host.docker.internal', 1234, 'local-model'),
('host.docker.internal', 11434, 'llama3.1:8b'),
('localhost', 1234, 'local-model'),
('localhost', 11434, 'llama3.1:8b'),
]
for host, port, default_model in candidates:
if _server_reachable(host, port, timeout=0.5):
return (f'http://{host}:{port}/v1', default_model)
return (None, None)
@tagged('post_install', '-at_install', 'local_llm')
class TestLocalLLMUsefulLife(TransactionCase):
def setUp(self):
super().setUp()
self.base_url, self.model = _detect_local_llm()
if not self.base_url:
self.skipTest("No local LLM server detected (LM Studio :1234 / Ollama :11434)")
def test_useful_life_with_local_llm(self):
params = self.env['ir.config_parameter'].sudo()
keys = [
'fusion_accounting.openai_base_url',
'fusion_accounting.openai_model',
'fusion_accounting.openai_api_key',
'fusion_accounting.provider.asset_useful_life',
]
prior = {k: params.get_param(k) for k in keys}
params.set_param('fusion_accounting.openai_base_url', self.base_url)
params.set_param('fusion_accounting.openai_model', self.model)
params.set_param('fusion_accounting.openai_api_key', 'lm-studio')
params.set_param('fusion_accounting.provider.asset_useful_life', 'openai')
try:
from odoo.addons.fusion_accounting_assets.services.useful_life_predictor import (
predict_useful_life,
)
result = predict_useful_life(
self.env,
description='Dell laptop',
amount=2500,
partner_name='Dell Canada',
)
self.assertIn('useful_life_years', result)
self.assertIn('depreciation_method', result)
self.assertIsInstance(result['useful_life_years'], (int, float))
self.assertIn(
result['depreciation_method'],
('straight_line', 'declining_balance', 'units_of_production'),
)
finally:
for k, v in prior.items():
if v is not None:
params.set_param(k, v)

View File

@@ -0,0 +1,58 @@
"""Controller perf benchmarks tagged 'benchmark'.
Engine-level benchmarks live in test_performance_benchmarks.py (Task 23).
This file targets the JSON-RPC controller surface end-to-end (HTTP request
→ Odoo dispatch → engine → response). It complements Task 23 by catching
regressions introduced by controller / serialization layers, not just the
underlying engine.
"""
import json
import statistics
import time
from datetime import date
from odoo.tests.common import HttpCase, new_test_user
from odoo.tests import tagged
@tagged('post_install', '-at_install', 'benchmark')
class TestAssetsControllerBenchmarks(HttpCase):
def setUp(self):
super().setUp()
for i in range(15):
self.env['fusion.asset'].create({
'name': f'BenchAsset{i}',
'cost': 1000 + i * 100,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line',
'useful_life_years': 5,
})
def test_get_detail_endpoint_p95(self):
new_test_user(
self.env, login='asset_perf_ctrl',
groups='base.group_user,account.group_account_invoice',
)
asset = self.env['fusion.asset'].search([], limit=1)
self.authenticate('asset_perf_ctrl', 'asset_perf_ctrl')
timings = []
for _ in range(5):
start = time.perf_counter()
response = self.url_open(
'/fusion/assets/get_detail',
data=json.dumps({
'jsonrpc': '2.0', 'method': 'call', 'id': 1,
'params': {'asset_id': asset.id},
}),
headers={'Content-Type': 'application/json'},
)
timings.append((time.perf_counter() - start) * 1000)
self.assertEqual(response.status_code, 200)
sorted_t = sorted(timings)
p95 = sorted_t[min(int(len(sorted_t) * 0.95), len(sorted_t) - 1)]
median = statistics.median(timings)
msg = f"controller.get_detail: median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <500ms)")
self.assertLess(p95, 5000)