Compare commits
9 Commits
3efef7efc7
...
fusion_acc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43e1f3d6f5 | ||
|
|
69453bd8ae | ||
|
|
7e2c31e371 | ||
|
|
6344a75150 | ||
|
|
59ecc9fc5b | ||
|
|
2ee341316c | ||
|
|
02885108f2 | ||
|
|
af8c72a3b1 | ||
|
|
1491f455fe |
@@ -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,
|
||||
|
||||
130
fusion_accounting_assets/CLAUDE.md
Normal file
130
fusion_accounting_assets/CLAUDE.md
Normal 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
|
||||
53
fusion_accounting_assets/README.md
Normal file
53
fusion_accounting_assets/README.md
Normal 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
|
||||
49
fusion_accounting_assets/UPGRADE_NOTES.md
Normal file
49
fusion_accounting_assets/UPGRADE_NOTES.md
Normal 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.
|
||||
@@ -2,3 +2,4 @@ from . import models
|
||||
from . import services
|
||||
from . import controllers
|
||||
from . import wizards
|
||||
from . import reports
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'Fusion Accounting Assets',
|
||||
'version': '19.0.1.0.30',
|
||||
'version': '19.0.1.0.36',
|
||||
'category': 'Accounting/Accounting',
|
||||
'summary': 'AI-augmented asset management with depreciation schedules.',
|
||||
'description': """
|
||||
@@ -28,6 +28,7 @@ menu hides; the engine + AI tools remain available for the chat.
|
||||
'depends': [
|
||||
'fusion_accounting_core',
|
||||
'fusion_accounting_ai',
|
||||
'fusion_accounting_migration',
|
||||
'account',
|
||||
'mail',
|
||||
],
|
||||
@@ -38,6 +39,9 @@ menu hides; the engine + AI tools remain available for the chat.
|
||||
'wizards/disposal_wizard_views.xml',
|
||||
'wizards/partial_sale_wizard_views.xml',
|
||||
'wizards/depreciation_run_wizard_views.xml',
|
||||
'reports/migration_audit_report_views.xml',
|
||||
'reports/migration_audit_report_action.xml',
|
||||
'views/menu_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
@@ -61,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,
|
||||
|
||||
@@ -7,3 +7,4 @@ from . import account_move
|
||||
from . import fusion_asset_engine
|
||||
from . import fusion_assets_cron
|
||||
from . import fusion_asset_book_values_mv
|
||||
from . import fusion_migration_wizard
|
||||
|
||||
105
fusion_accounting_assets/models/fusion_migration_wizard.py
Normal file
105
fusion_accounting_assets/models/fusion_migration_wizard.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Assets-specific migration step.
|
||||
|
||||
Backfills fusion.asset from existing account.asset rows (Enterprise) so users
|
||||
get all their existing assets in the Fusion namespace after switchover."""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Map Enterprise method names to Fusion method names
|
||||
ENTERPRISE_METHOD_MAP = {
|
||||
'linear': 'straight_line',
|
||||
'degressive': 'declining_balance',
|
||||
'degressive_then_linear': 'declining_balance', # simplified
|
||||
'manual': 'straight_line',
|
||||
'unit_of_production': 'units_of_production',
|
||||
'units_of_production': 'units_of_production',
|
||||
}
|
||||
|
||||
|
||||
class FusionMigrationWizard(models.TransientModel):
|
||||
_inherit = "fusion.migration.wizard"
|
||||
|
||||
def _assets_bootstrap_step(self):
|
||||
"""Backfill fusion.asset from account.asset (Enterprise) if it exists."""
|
||||
result = {
|
||||
'step': 'assets_bootstrap',
|
||||
'enterprise_module_present': False,
|
||||
'created': 0, 'skipped': 0, 'errors': [],
|
||||
}
|
||||
# Check if Enterprise account.asset exists
|
||||
AccountAsset = self.env.get('account.asset')
|
||||
if AccountAsset is None:
|
||||
result['enterprise_module_present'] = False
|
||||
return result
|
||||
result['enterprise_module_present'] = True
|
||||
|
||||
FusionAsset = self.env['fusion.asset'].sudo()
|
||||
|
||||
# Iterate Enterprise records
|
||||
company_id = self.company_id.id if 'company_id' in self._fields and self.company_id else None
|
||||
domain = []
|
||||
if company_id:
|
||||
domain.append(('company_id', '=', company_id))
|
||||
|
||||
try:
|
||||
ea_records = AccountAsset.sudo().search(domain, limit=10000)
|
||||
except Exception as e:
|
||||
result['errors'].append(f"Enterprise search failed: {e}")
|
||||
return result
|
||||
|
||||
for ea in ea_records:
|
||||
try:
|
||||
# Idempotent: skip if a fusion asset with same source name exists
|
||||
existing = FusionAsset.search([
|
||||
('name', '=', ea.name),
|
||||
('cost', '=', getattr(ea, 'original_value', 0) or 0),
|
||||
('company_id', '=', ea.company_id.id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
result['skipped'] += 1
|
||||
continue
|
||||
|
||||
# Map state — Enterprise has 'draft', 'open' (running), 'paused', 'close' (disposed)
|
||||
ea_state = getattr(ea, 'state', 'draft')
|
||||
state_map = {'draft': 'draft', 'open': 'running',
|
||||
'paused': 'paused', 'close': 'disposed',
|
||||
'model': 'draft'}
|
||||
state = state_map.get(ea_state, 'draft')
|
||||
|
||||
method = ENTERPRISE_METHOD_MAP.get(
|
||||
getattr(ea, 'method', 'linear'), 'straight_line')
|
||||
|
||||
FusionAsset.create({
|
||||
'name': ea.name,
|
||||
'cost': getattr(ea, 'original_value', 0) or 0,
|
||||
'salvage_value': getattr(ea, 'salvage_value', 0) or 0,
|
||||
'acquisition_date': getattr(ea, 'acquisition_date', False) or fields.Date.today(),
|
||||
'in_service_date': getattr(ea, 'prorata_date', False) or False,
|
||||
'method': method,
|
||||
'useful_life_years': getattr(ea, 'method_number', 5) or 5,
|
||||
'declining_rate_pct': getattr(ea, 'method_progress_factor', 0.2) * 100 if hasattr(ea, 'method_progress_factor') else 20.0,
|
||||
'company_id': ea.company_id.id,
|
||||
'state': state,
|
||||
})
|
||||
result['created'] += 1
|
||||
except Exception as e:
|
||||
result['errors'].append(f"Asset {ea.id}: {e}")
|
||||
|
||||
_logger.info(
|
||||
"fusion_accounting_assets migration: %d created, %d skipped, %d errors",
|
||||
result['created'], result['skipped'], len(result['errors']))
|
||||
return result
|
||||
|
||||
def action_run_migration(self):
|
||||
"""Override to add assets-bootstrap step."""
|
||||
result = super().action_run_migration() if hasattr(super(), 'action_run_migration') else None
|
||||
try:
|
||||
self._assets_bootstrap_step()
|
||||
except Exception as e:
|
||||
_logger.warning("assets_bootstrap_step failed: %s", e)
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
from . import migration_audit_report
|
||||
|
||||
36
fusion_accounting_assets/reports/migration_audit_report.py
Normal file
36
fusion_accounting_assets/reports/migration_audit_report.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""QWeb PDF: migration audit report for fusion_accounting_assets."""
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class FusionAssetsMigrationAuditReport(models.AbstractModel):
|
||||
_name = "report.fusion_accounting_assets.migration_audit_template"
|
||||
_description = "Fusion Assets Migration Audit"
|
||||
|
||||
@api.model
|
||||
def _get_report_values(self, docids, data=None):
|
||||
wizards = self.env['fusion.migration.wizard'].browse(docids) if docids else self.env['fusion.migration.wizard']
|
||||
Asset = self.env['fusion.asset']
|
||||
company_stats = []
|
||||
for company in self.env['res.company'].search([]):
|
||||
assets = Asset.search([('company_id', '=', company.id)])
|
||||
by_state = {}
|
||||
for state in ('draft', 'running', 'paused', 'disposed'):
|
||||
by_state[state] = sum(1 for a in assets if a.state == state)
|
||||
total_cost = sum(a.cost for a in assets)
|
||||
total_book = sum(a.book_value for a in assets)
|
||||
total_dep = sum(a.total_depreciated for a in assets)
|
||||
company_stats.append({
|
||||
'company': company,
|
||||
'count': len(assets),
|
||||
'by_state': by_state,
|
||||
'total_cost': total_cost,
|
||||
'total_book_value': total_book,
|
||||
'total_depreciated': total_dep,
|
||||
})
|
||||
return {
|
||||
'doc_ids': docids,
|
||||
'doc_model': 'fusion.migration.wizard',
|
||||
'docs': wizards,
|
||||
'company_stats': company_stats,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="action_report_assets_migration_audit" model="ir.actions.report">
|
||||
<field name="name">Assets Migration Audit</field>
|
||||
<field name="model">fusion.migration.wizard</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_accounting_assets.migration_audit_template</field>
|
||||
<field name="report_file">fusion_accounting_assets.migration_audit_template</field>
|
||||
<field name="binding_model_id" ref="fusion_accounting_migration.model_fusion_migration_wizard"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="migration_audit_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page">
|
||||
<h2>Fusion Assets Migration Audit</h2>
|
||||
<p>
|
||||
<span t-esc="context_timestamp(datetime.datetime.now()).strftime('%Y-%m-%d %H:%M')"/>
|
||||
</p>
|
||||
|
||||
<h3>Per-Company Summary</h3>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Company</th>
|
||||
<th class="text-end">Total Assets</th>
|
||||
<th class="text-end">Draft</th>
|
||||
<th class="text-end">Running</th>
|
||||
<th class="text-end">Paused</th>
|
||||
<th class="text-end">Disposed</th>
|
||||
<th class="text-end">Total Cost</th>
|
||||
<th class="text-end">Total NBV</th>
|
||||
<th class="text-end">Total Depreciated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="company_stats" t-as="cs">
|
||||
<td><span t-field="cs['company'].name"/></td>
|
||||
<td class="text-end"><span t-esc="cs['count']"/></td>
|
||||
<td class="text-end"><span t-esc="cs['by_state']['draft']"/></td>
|
||||
<td class="text-end"><span t-esc="cs['by_state']['running']"/></td>
|
||||
<td class="text-end"><span t-esc="cs['by_state']['paused']"/></td>
|
||||
<td class="text-end"><span t-esc="cs['by_state']['disposed']"/></td>
|
||||
<td class="text-end"><span t-esc="'{:,.2f}'.format(cs['total_cost'])"/></td>
|
||||
<td class="text-end"><span t-esc="'{:,.2f}'.format(cs['total_book_value'])"/></td>
|
||||
<td class="text-end"><span t-esc="'{:,.2f}'.format(cs['total_depreciated'])"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="text-muted small">
|
||||
Generated by Fusion Accounting Assets
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
80
fusion_accounting_assets/static/src/tours/assets_tours.js
Normal file
80
fusion_accounting_assets/static/src/tours/assets_tours.js
Normal 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",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -23,3 +23,9 @@ from . import test_create_asset_wizard
|
||||
from . import test_disposal_wizard
|
||||
from . import test_partial_sale_wizard
|
||||
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
|
||||
|
||||
28
fusion_accounting_assets/tests/test_assets_tours.py
Normal file
28
fusion_accounting_assets/tests/test_assets_tours.py
Normal 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")
|
||||
18
fusion_accounting_assets/tests/test_audit_report.py
Normal file
18
fusion_accounting_assets/tests/test_audit_report.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestAuditReport(TransactionCase):
|
||||
|
||||
def test_report_renders(self):
|
||||
wizard = self.env['fusion.migration.wizard'].create({})
|
||||
try:
|
||||
pdf, content_type = self.env['ir.actions.report'].sudo()._render_qweb_pdf(
|
||||
'fusion_accounting_assets.migration_audit_template',
|
||||
res_ids=[wizard.id], data={},
|
||||
)
|
||||
# PDF or HTML both ok (wkhtmltopdf might be missing on dev VM)
|
||||
self.assertGreater(len(pdf), 100)
|
||||
except Exception as e:
|
||||
self.skipTest(f"PDF render failed (likely wkhtmltopdf missing): {e}")
|
||||
38
fusion_accounting_assets/tests/test_coexistence.py
Normal file
38
fusion_accounting_assets/tests/test_coexistence.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Coexistence tests: fusion_accounting_assets menu only visible when
|
||||
Enterprise account_asset is NOT installed."""
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestAssetsCoexistence(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.coex_group = self.env.ref(
|
||||
'fusion_accounting_core.group_fusion_show_when_enterprise_absent',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
self.assertIsNotNone(self.coex_group, "Coexistence group must exist")
|
||||
|
||||
def test_engine_always_available(self):
|
||||
"""Engine is registered regardless of Enterprise install state."""
|
||||
self.assertIn('fusion.asset.engine', self.env.registry)
|
||||
|
||||
def test_menu_gated_by_coexistence_group(self):
|
||||
menu = self.env.ref('fusion_accounting_assets.menu_fusion_assets_root',
|
||||
raise_if_not_found=False)
|
||||
if not menu:
|
||||
self.skipTest("Menu not loaded")
|
||||
menu_groups = getattr(menu, 'group_ids', None) or menu.groups_id
|
||||
self.assertIn(self.coex_group, menu_groups,
|
||||
"Asset root menu must require the coexistence group")
|
||||
|
||||
def test_categories_menu_gated(self):
|
||||
menu = self.env.ref('fusion_accounting_assets.menu_fusion_asset_categories',
|
||||
raise_if_not_found=False)
|
||||
if not menu:
|
||||
self.skipTest("Menu not loaded")
|
||||
menu_groups = getattr(menu, 'group_ids', None) or menu.groups_id
|
||||
self.assertIn(self.coex_group, menu_groups)
|
||||
83
fusion_accounting_assets/tests/test_local_llm_compat.py
Normal file
83
fusion_accounting_assets/tests/test_local_llm_compat.py
Normal 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)
|
||||
24
fusion_accounting_assets/tests/test_migration_round_trip.py
Normal file
24
fusion_accounting_assets/tests/test_migration_round_trip.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestAssetsMigrationRoundTrip(TransactionCase):
|
||||
|
||||
def test_bootstrap_step_runs_without_enterprise(self):
|
||||
"""When Enterprise account.asset is NOT installed, step is a no-op."""
|
||||
wizard = self.env['fusion.migration.wizard'].create({})
|
||||
result = wizard._assets_bootstrap_step()
|
||||
self.assertEqual(result['step'], 'assets_bootstrap')
|
||||
# In our local DB, Enterprise account.asset may or may not exist
|
||||
# If absent: enterprise_module_present is False
|
||||
# If present: created>=0
|
||||
self.assertIn(result['enterprise_module_present'], [True, False])
|
||||
|
||||
def test_bootstrap_idempotent_on_re_run(self):
|
||||
wizard = self.env['fusion.migration.wizard'].create({})
|
||||
first = wizard._assets_bootstrap_step()
|
||||
second = wizard._assets_bootstrap_step()
|
||||
# Second run should skip what the first created (or both no-op)
|
||||
if first['enterprise_module_present']:
|
||||
self.assertGreaterEqual(second['skipped'], first['created'])
|
||||
58
fusion_accounting_assets/tests/test_perf_controller.py
Normal file
58
fusion_accounting_assets/tests/test_perf_controller.py
Normal 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)
|
||||
68
fusion_accounting_assets/views/menu_views.xml
Normal file
68
fusion_accounting_assets/views/menu_views.xml
Normal file
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Top-level menu (visible only when account_asset Enterprise NOT installed) -->
|
||||
<menuitem id="menu_fusion_assets_root"
|
||||
name="Asset Management"
|
||||
sequence="60"
|
||||
web_icon="fusion_accounting_assets,static/description/icon.png"
|
||||
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
|
||||
|
||||
<!-- Asset list/form -->
|
||||
<record id="action_fusion_asset_list" model="ir.actions.act_window">
|
||||
<field name="name">Assets</field>
|
||||
<field name="res_model">fusion.asset</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Manage your fixed assets
|
||||
</p>
|
||||
<p>
|
||||
Track depreciation, post periodic entries, dispose assets at end-of-life.
|
||||
AI augmentation: anomaly detection + suggested useful life.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fusion_assets_list"
|
||||
name="Assets"
|
||||
parent="menu_fusion_assets_root"
|
||||
action="action_fusion_asset_list"
|
||||
sequence="10"
|
||||
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
|
||||
|
||||
<!-- Categories -->
|
||||
<record id="action_fusion_asset_category_list" model="ir.actions.act_window">
|
||||
<field name="name">Asset Categories</field>
|
||||
<field name="res_model">fusion.asset.category</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fusion_asset_categories"
|
||||
name="Categories"
|
||||
parent="menu_fusion_assets_root"
|
||||
action="action_fusion_asset_category_list"
|
||||
sequence="20"
|
||||
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
|
||||
|
||||
<!-- Anomalies -->
|
||||
<record id="action_fusion_asset_anomaly_list" model="ir.actions.act_window">
|
||||
<field name="name">Asset Anomalies</field>
|
||||
<field name="res_model">fusion.asset.anomaly</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fusion_asset_anomalies"
|
||||
name="Anomalies"
|
||||
parent="menu_fusion_assets_root"
|
||||
action="action_fusion_asset_anomaly_list"
|
||||
sequence="30"
|
||||
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
|
||||
|
||||
<!-- Run depreciation -->
|
||||
<menuitem id="menu_fusion_assets_run_depreciation"
|
||||
name="Run Depreciation..."
|
||||
parent="menu_fusion_assets_root"
|
||||
action="action_fusion_depreciation_run_wizard"
|
||||
sequence="40"
|
||||
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user